diff --git a/README.md b/README.md index 5d64713..138a028 100644 --- a/README.md +++ b/README.md @@ -162,24 +162,33 @@ clix doctor ### `clix ios-setup` -Configure iOS capabilities and Notification Service Extension (NSE) for the Clix SDK. +Configure iOS capabilities, Notification Service Extension (NSE), and APNS key for the Clix SDK. ```bash clix ios-setup ``` **What it does:** -1. Analyzes your iOS project structure -2. Checks current capabilities status (Push Notifications, App Groups) -3. Creates/modifies entitlements files -4. Guides NSE setup for rich push notifications: - - Creates `{AppName}NotificationServiceExtension` target - - Implements `NotificationService.swift` with `ClixNotificationServiceExtension` - - Configures CocoaPods/SPM dependencies for extension target - - Sets build settings (`ENABLE_USER_SCRIPT_SANDBOXING` for Xcode 15+) -5. Guides you through Xcode and Apple Developer Portal configuration - -**Note:** Some steps require manual action in Xcode and Apple Developer Portal. +1. **Phase 1 - Capabilities & Entitlements (Automatic):** + - Analyzes your iOS project structure + - Syncs capabilities with Apple Developer Portal (Push Notifications, App Groups) + - Creates/modifies entitlements files + +2. **Phase 2 - Extension Setup (Guided):** + - Auto-generates NSE files: + - `NotificationService.swift` with `ClixNotificationServiceExtension` + - `Info.plist` for extension + - Extension entitlements file + - Step-by-step guide for Xcode configuration: + - Creating extension target in Xcode + - Configuring build settings (`ENABLE_USER_SCRIPT_SANDBOXING` for Xcode 15+) + - Adding CocoaPods/SPM dependencies + +3. **Phase 3 - APNS Key Setup (Optional):** + - APNS authentication key (.p8 file) creation guide + - Firebase Console upload for push notification delivery + +**Note:** Phase 2 & 3 require manual action in Xcode, Apple Developer Portal, and Firebase Console. ### `clix update` diff --git a/bun.lock b/bun.lock index 1975ec9..b310a85 100644 --- a/bun.lock +++ b/bun.lock @@ -14,11 +14,14 @@ "ink-select-input": "^6.2.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", + "keychain": "^1.5.0", "meow": "^14.0.0", "open": "^11.0.0", "picocolors": "^1.1.1", "plist": "^3.1.0", "react": "^19.2.3", + "simple-plist": "1.4.0", + "xcode": "^3.0.1", "xdg-app-paths": "^8.3.0", "zod": "^4.3.5", }, @@ -34,6 +37,9 @@ }, }, }, + "overrides": { + "simple-plist": "1.4.0", + }, "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ=="], @@ -179,12 +185,18 @@ "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "bmp-js": ["bmp-js@0.1.0", "", {}, "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="], + "bplist-creator": ["bplist-creator@0.1.1", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-Ese7052fdWrxp/vqSJkydgx/1MdBnNOCV2XVfbmdGWD2H6EYza+Q4pyYSuVSnCUD22hfI/BFI4jHaC3NLXLlJQ=="], + + "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], + "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -441,6 +453,8 @@ "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "keychain": ["keychain@1.5.0", "", {}, "sha512-liyp4r+93RI7EB2jhwaRd4MWfdgHH6shuldkaPMkELCJjMFvOOVXuTvw1pGqFfhsrgA6OqfykWWPQgBjQakVag=="], + "kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], "lint-staged": ["lint-staged@16.2.7", "", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="], @@ -587,12 +601,16 @@ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "simple-plist": ["simple-plist@1.4.0", "", { "dependencies": { "bplist-creator": "0.1.1", "bplist-parser": "0.3.2", "plist": "^3.0.5" } }, "sha512-Emr2CR0T6cfQlbXxk7KtpU183WpJXWdl9c7D8uTtduX7bzVO1A6yTO6BauGzbWQhdOfpggcc9s0PN8+JyG/2gQ=="], + "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="], "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "stream-buffers": ["stream-buffers@2.2.0", "", {}, "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg=="], + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], "string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], @@ -651,7 +669,7 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "uuid": ["uuid@3.4.0", "", { "bin": { "uuid": "./bin/uuid" } }, "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="], + "uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="], "verror": ["verror@1.10.0", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw=="], @@ -679,6 +697,8 @@ "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="], + "xdg-app-paths": ["xdg-app-paths@8.3.0", "", { "dependencies": { "xdg-portable": "^10.6.0" }, "optionalDependencies": { "fsevents": "*" } }, "sha512-mgxlWVZw0TNWHoGmXq+NC3uhCIc55dDpAlDkMQUaIAcQzysb0kxctwv//fvuW61/nAAeUBJMQ8mnZjMmuYwOcQ=="], "xdg-portable": ["xdg-portable@10.6.0", "", { "dependencies": { "os-paths": "^7.4.0" }, "optionalDependencies": { "fsevents": "*" } }, "sha512-xrcqhWDvtZ7WLmt8G4f3hHy37iK7D2idtosRgkeiSPZEPmBShp0VfmRBLWAPC6zLF48APJ21yfea+RfQMF4/Aw=="], @@ -743,6 +763,8 @@ "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "request/uuid": ["uuid@3.4.0", "", { "bin": { "uuid": "./bin/uuid" } }, "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="], + "string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "string-width-cjs/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], diff --git a/llms.txt b/llms.txt index eeb5edc..891a04b 100644 --- a/llms.txt +++ b/llms.txt @@ -9,7 +9,7 @@ Clix CLI is an interactive command-line tool that provides a chat interface with **Core Features:** - Interactive chat interface as the primary interaction mode - Support for 6 AI agents: Claude, Codex, Gemini, OpenCode, Cursor, and GitHub Copilot -- 19 slash commands for quick actions +- 20 slash commands for quick actions - Skills system with 5 interactive skills + 4 autonomous commands - Interactive debug assistant for problem diagnosis - Session transfer to native agent CLIs @@ -70,14 +70,14 @@ clix **Features:** - Natural language conversation with AI - Real-time streaming responses -- 19 slash commands (type `/` to see menu) +- 20 slash commands (type `/` to see menu) - Context usage tracking (200K token window for Claude Sonnet) - History navigation with ↑/↓ arrow keys - Press Escape to cancel streaming requests - Automatic history compaction at 90% context threshold **Available within chat:** -- All 19 slash commands (4 autonomous + 5 skills + 10 system) +- All 20 slash commands (4 autonomous + 5 skills + 11 system) - Natural language queries - File exploration and code analysis by agent - Real-time tool execution visibility @@ -232,7 +232,7 @@ The interactive chat (`clix` command) is the primary way to interact with Clix C ### Slash Commands -Type `/` in the chat to see the autocomplete menu. All 19 slash commands are organized into three categories: +Type `/` in the chat to see the autocomplete menu. All 20 slash commands are organized into three categories: #### Autonomous Commands (4 commands) @@ -253,7 +253,7 @@ These execute pre-built workflows from the `@clix-so/clix-agent-skills` package - `/personalization` - Personalization template creation and debugging - `/api-triggered-campaigns` - API-triggered campaign setup -#### System Commands (10 commands) +#### System Commands (11 commands) These are built-in commands for chat management and tools: @@ -380,13 +380,13 @@ Describe the problem: Events not appearing in Clix dashboard [Provides fix with exact file location] ``` -#### `/ios-setup` - iOS Setup, Capabilities & NSE Configuration +#### `/ios-setup` - iOS Setup, Capabilities, NSE & APNS Key Configuration **Category:** Autonomous Command **Aliases:** `/capabilities`, `/ios-capabilities` -**What it does:** Configures iOS capabilities and Notification Service Extension (NSE) required for the Clix SDK. +**What it does:** Configures iOS capabilities, Notification Service Extension (NSE), and optionally sets up APNS authentication key for Firebase push notifications. **Capabilities configured:** - **Push Notifications** - Enables APNs communication (entitlement: `aps-environment`) @@ -399,21 +399,38 @@ Describe the problem: Events not appearing in Clix dashboard - **Build settings:** `ENABLE_USER_SCRIPT_SANDBOXING = No` for Xcode 15+ - **React Native + Firebase:** Move "Embed Foundation Extensions" above "[RNFB] Core Configuration" -**Workflow:** -1. Analyzes iOS project structure (finds .xcodeproj/.xcworkspace) -2. Detects Bundle ID and checks current capabilities status -3. Creates/modifies entitlements files for main app and extension -4. Guides NSE target creation and NotificationService.swift implementation -5. Provides CocoaPods/SPM setup instructions for extension target -6. Provides step-by-step instructions for Xcode configuration -7. Guides through Apple Developer Portal setup -8. Outputs verification report - -**What can be automated:** +**APNS Key Setup (Phase 3 - Optional):** +After iOS capabilities setup completes, prompts user to configure APNS authentication key: +- **APNS Key Creation:** Opens Apple Developer Portal to create/download .p8 key file +- **Key Validation:** Validates P8 file format, extracts Key ID from filename +- **Firebase Upload:** Opens Firebase Console and displays Key ID, Team ID for manual upload + +**Workflow (3 Phases):** +1. **Phase 1 - Direct Setup (Automatic):** + - Analyzes iOS project structure (finds .xcodeproj/.xcworkspace) + - Detects Bundle ID and checks current capabilities status + - Syncs capabilities with Apple Developer Portal (if credentials provided) + - Creates/modifies entitlements files for main app +2. **Phase 2 - Guided Setup (Static file generation + UI guide):** + - Auto-generates NSE files: + - `NotificationService.swift` with `ClixNotificationServiceExtension` + - `Info.plist` for extension + - Extension entitlements file + - Step-by-step Xcode configuration guide: + - Creating NSE target in Xcode + - Configuring build settings (`ENABLE_USER_SCRIPT_SANDBOXING` for Xcode 15+) + - Adding CocoaPods/SPM dependencies +3. **Phase 3 - Push Setup (Optional):** + - Prompts: "Set up APNS key for Firebase push notifications? [Y/n]" + - If yes, opens PushSetupWizard for APNS key creation and Firebase upload + +**What is automated:** - Creating/modifying entitlements files - Reading project configuration +- Generating NSE source files (NotificationService.swift, Info.plist) +- P8 file format validation -**What requires manual action:** +**What requires manual action (guided by UI):** - Creating NSE target in Xcode (File > New > Target > Notification Service Extension) - Adding capabilities in Xcode UI (Signing & Capabilities) - Adding Clix SDK to extension target (Podfile or SPM) @@ -421,27 +438,36 @@ Describe the problem: Events not appearing in Clix dashboard - Enabling capabilities in Apple Developer Portal - Registering App Group IDs - Regenerating provisioning profiles +- Creating APNS key in Apple Developer Portal +- Uploading P8 file to Firebase Console **Example:** ``` > /ios-setup Analyzing iOS project... -Bundle ID: com.example.myapp -Push Notifications: not configured -App Groups: not configured +✓ Found: MyApp.xcodeproj +✓ Bundle ID: com.example.myapp Creating entitlements files... ✓ Created MyApp.entitlements -✓ Created MyAppNotificationServiceExtension.entitlements -Manual steps required: -1. Create NSE target: MyAppNotificationServiceExtension -2. Implement NotificationService.swift with ClixNotificationServiceExtension -3. Add Clix SDK to extension (Podfile or SPM) -4. Add Push Notifications and App Groups capabilities -5. Set ENABLE_USER_SCRIPT_SANDBOXING to No (Xcode 15+) +Creating Notification Service Extension files... +✓ Created MyAppNotificationServiceExtension/NotificationService.swift +✓ Created MyAppNotificationServiceExtension/Info.plist +✓ Created MyAppNotificationServiceExtension/MyAppNotificationServiceExtension.entitlements -{verification report JSON} +Step 1: Create Notification Service Extension Target in Xcode +[Enter to continue...] + +Step 2: Configure Build Settings +[Enter to continue...] + +Step 3: Add Clix SDK to Extension Target +[Enter to continue...] + +✓ Extension setup guide complete! + +Set up APNS key for Firebase push notifications? [Y/n] ``` ### Interactive Skills @@ -1249,7 +1275,7 @@ When helping users with Clix CLI, keep these points in mind: 1. **Primary command is `clix`** - This launches interactive chat, not just a welcome screen 2. **Interactive > Commands** - The tool is primarily interactive, not command-based 3. **6 supported agents** - Gemini, Copilot, OpenCode, Cursor, Claude, Codex (recommend starting with Gemini or Copilot for free tiers) -4. **19 slash commands** - 4 autonomous commands + 5 interactive skills + 10 system commands +4. **20 slash commands** - 4 autonomous commands + 5 interactive skills + 11 system commands 5. **Autonomous vs Interactive** - Autonomous commands (`/install`, `/doctor`, `/debug`, `/ios-setup`) can run from CLI, Interactive skills (`/integration`, `/event-tracking`, etc.) require chat mode 6. **Skills from package** - Interactive skills from @clix-so/clix-agent-skills package, Autonomous commands are local 7. **/install vs /integration** - `/install` makes changes autonomously, `/integration` provides guided steps diff --git a/package.json b/package.json index 6214054..766bf24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@clix-so/clix-cli", - "version": "1.1.2-beta.1", + "version": "1.1.2-beta.2", "description": "A CLI tool for integrating and managing the Clix SDK in your mobile projects", "type": "module", "bin": { @@ -55,11 +55,14 @@ "ink-select-input": "^6.2.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", + "keychain": "^1.5.0", "meow": "^14.0.0", "open": "^11.0.0", "picocolors": "^1.1.1", "plist": "^3.1.0", "react": "^19.2.3", + "simple-plist": "1.4.0", + "xcode": "^3.0.1", "xdg-app-paths": "^8.3.0", "zod": "^4.3.5" }, @@ -75,5 +78,8 @@ }, "engines": { "node": ">=20.0.0" + }, + "overrides": { + "simple-plist": "1.4.0" } } diff --git a/scripts/build.ts b/scripts/build.ts index a0e5c1a..2f82c07 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -53,6 +53,8 @@ async function build() { 'ws', 'bufferutil', 'utf-8-validate', + // @expo/plist uses relative requires - must be external + '@expo/plist', ], define: { // Disable dev mode to prevent react-devtools-core import diff --git a/src/cli.tsx b/src/cli.tsx index 1aec137..cf0f292 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -4,7 +4,7 @@ import { chatCommand } from './commands/chat'; import { debugCommand } from './commands/debug'; import { firebaseCommand } from './commands/firebase'; import { installMCPCommand } from './commands/install-mcp'; -import { iosSetupCommand } from './commands/ios-setup/index'; +import { runIosSetupCommand } from './commands/ios-setup/index'; import { loginCommand } from './commands/login'; import { logoutCommand } from './commands/logout'; import { resumeCommand } from './commands/resume'; @@ -210,7 +210,7 @@ async function main() { process.exit(1); } const pushEnv = pushEnvRaw as 'development' | 'production' | undefined; - await iosSetupCommand({ + await runIosSetupCommand({ apiKeyPath: cli.flags.apiKey, keyId: cli.flags.keyId, issuerId: cli.flags.issuerId, diff --git a/src/commands/ios-setup/index.tsx b/src/commands/ios-setup/index.tsx index 1d4107b..74bb4be 100644 --- a/src/commands/ios-setup/index.tsx +++ b/src/commands/ios-setup/index.tsx @@ -1,8 +1,24 @@ -import { render } from 'ink'; -import type { AgentInfo } from '../../lib/agents'; -import type { AgentExecutor, AgentMessage } from '../../lib/executor'; -import { generateAgentPrompt } from '../../lib/ios'; -import { AgentExecutionUI } from '../../ui/AgentExecutionUI'; +import { Box, render, Text, useInput } from 'ink'; +import Spinner from 'ink-spinner'; +import type React from 'react'; +import { + addClixToExtensionTarget, + addNotificationServiceExtension, + backupPodfile, + backupProject, + createExtensionFiles, + type ExtensionContext, + getExtensionBundleId, + getExtensionName, + hasPodfile, +} from '../../lib/ios'; +import type { PushSetupResult } from '../../lib/push'; +import { + type GuidedSetupContext, + type GuidedSetupResult, + GuidedSetupWizard, +} from '../../ui/components/GuidedSetupWizard'; +import { PushSetupWizard } from '../../ui/components/PushSetupWizard'; import { type IosSetupOptions, type IosSetupResult, IosSetupUI } from '../../ui/IosSetupUI'; import { type FinalOutputResult, printFinalOutput } from '../../ui/utils/finalOutput'; @@ -21,6 +37,21 @@ export interface IosSetupCommandOptions { pushEnvironment?: 'development' | 'production'; } +/** + * Result of automated project modification phase. + */ +interface ProjectModificationResult { + success: boolean; + extensionFilesCreated: boolean; + pbxprojModified: boolean; + podfileModified: boolean; + createdFiles: string[]; + warnings: string[]; + /** If true, fall back to guided wizard for manual steps */ + requiresManualSteps: boolean; + error?: string; +} + function toDirectSetupOutput(result: IosSetupResult): FinalOutputResult { if (result.success) { const details: string[] = []; @@ -47,7 +78,7 @@ function toDirectSetupOutput(result: IosSetupResult): FinalOutputResult { type: 'success', title: 'Direct setup completed', message: result.agentContext - ? 'Portal sync and entitlements configured. Starting agent for remaining tasks...' + ? 'Portal sync and entitlements configured. Proceeding to extension setup...' : 'Portal sync and entitlements configured.', details: details.length > 0 ? details : undefined, }; @@ -88,31 +119,212 @@ async function runDirectSetup(options: IosSetupCommandOptions): Promise + + + + + {status} + + {warnings.map((warning) => ( + + ⚠ {warning} + + ))} + + ); +} + +/** + * Print the result of project modification. + */ +function printModificationResult(result: ProjectModificationResult): void { + if (!result.extensionFilesCreated && !result.pbxprojModified && !result.podfileModified) { + return; + } + + console.log(''); + if (result.extensionFilesCreated) { + console.log('✓ Extension files created'); + for (const file of result.createdFiles) { + console.log(` • ${file}`); + } + } + if (result.pbxprojModified) { + console.log('✓ Xcode project updated (NSE target added)'); + } + if (result.podfileModified) { + console.log('✓ Podfile updated (extension target added)'); + console.log(' Run: cd ios && pod install'); + } + if (result.warnings.length > 0) { + console.log(''); + for (const warning of result.warnings) { + console.log(`⚠ ${warning}`); + } + } +} + +/** + * Execute the project modification steps. + */ +async function executeProjectModification( + directResult: IosSetupResult, + result: ProjectModificationResult, + updateStatus: (status: string) => void, + pushEnvironment?: 'development' | 'production', +): Promise { + const { agentContext } = directResult; + if (!agentContext) return; + + const extensionName = getExtensionName(agentContext.appName); + const extensionBundleId = getExtensionBundleId(agentContext.bundleId, agentContext.appName); + const extensionDir = `${agentContext.iosDir}/${extensionName}`; + + // 1. Create extension files + const extContext: ExtensionContext = { + appName: agentContext.appName, + bundleId: agentContext.bundleId, + iosDir: agentContext.iosDir, + pushEnvironment: pushEnvironment ?? 'development', + }; + + const extResult = await createExtensionFiles(extContext); + if (extResult.success) { + result.extensionFilesCreated = true; + result.createdFiles.push(...extResult.createdFiles); + } else { + result.warnings.push(extResult.error || 'Failed to create extension files'); + result.requiresManualSteps = true; + return; + } + + // 2. Modify pbxproj + updateStatus('Modifying Xcode project...'); + backupProject(agentContext.projectPath); + + const pbxResult = await addNotificationServiceExtension({ + projectPath: agentContext.projectPath, + extensionName, + extensionBundleId, + extensionDir, + appGroupId: agentContext.appGroupId, + teamId: directResult.projectInfo?.teamId, + deploymentTarget: '14.0', + }); + + if (pbxResult.success) { + result.pbxprojModified = pbxResult.targetAdded; + result.warnings.push(...pbxResult.warnings); + } else { + result.warnings.push(pbxResult.error || 'Failed to modify pbxproj'); + result.requiresManualSteps = true; + } + + // 3. Modify Podfile (if exists) + if (hasPodfile(agentContext.iosDir)) { + updateStatus('Updating Podfile...'); + backupPodfile(agentContext.iosDir); + + const podResult = await addClixToExtensionTarget({ + iosDir: agentContext.iosDir, + extensionName, + }); + + if (podResult.success) { + result.podfileModified = podResult.modified; + } else { + result.warnings.push(podResult.error || 'Failed to modify Podfile'); + } + } + + result.success = true; +} + +/** + * Run automated project modification phase. + * Creates extension files and modifies pbxproj/Podfile programmatically. */ -async function runAgentCompletion( +async function runProjectModification( directResult: IosSetupResult, -): Promise { + pushEnvironment?: 'development' | 'production', +): Promise { + const result: ProjectModificationResult = { + success: false, + extensionFilesCreated: false, + pbxprojModified: false, + podfileModified: false, + createdFiles: [], + warnings: [], + requiresManualSteps: false, + }; + if (!directResult.agentContext) { - return undefined; + result.success = true; + return result; } - const agentPrompt = generateAgentPrompt(directResult.agentContext); + // Render progress UI + const displayWarnings: string[] = []; + let currentStatus = 'Creating extension files...'; + + const { unmount, rerender } = render( + , + { incrementalRendering: true }, + ); - // Create execute function for agent - async function* executeAgent( - executor: AgentExecutor, - _agent: AgentInfo, - ): AsyncGenerator { - yield* executor.execute(agentPrompt); + const updateStatus = (status: string) => { + currentStatus = status; + displayWarnings.push(...result.warnings.filter((w) => !displayWarnings.includes(w))); + rerender(); + }; + + try { + await executeProjectModification(directResult, result, updateStatus, pushEnvironment); + } catch (error) { + result.error = error instanceof Error ? error.message : String(error); + result.requiresManualSteps = true; + } finally { + unmount(); } + printModificationResult(result); + return result; +} + +/** + * Run the guided setup phase (Extension file generation + Xcode configuration guide) + * Used as fallback when automated modification fails or for manual verification. + */ +async function runGuidedSetup( + directResult: IosSetupResult, +): Promise { + if (!directResult.agentContext) { + return undefined; + } + + const context: GuidedSetupContext = { + bundleId: directResult.agentContext.bundleId, + appGroupId: directResult.agentContext.appGroupId, + appName: directResult.agentContext.appName, + iosDir: directResult.agentContext.iosDir, + entitlementsPath: directResult.agentContext.entitlementsPath, + }; + return new Promise((resolve) => { const { unmount } = render( - { unmount(); resolve(result); @@ -123,7 +335,86 @@ async function runAgentCompletion( }); } -export async function iosSetupCommand(options: IosSetupCommandOptions): Promise { +/** + * Confirmation prompt component for push setup + */ +function PushSetupConfirmation({ + onYes, + onNo, +}: { + onYes: () => void; + onNo: () => void; +}): React.ReactElement { + useInput((input, key) => { + if (input.toLowerCase() === 'y' || key.return) { + onYes(); + } else if (input.toLowerCase() === 'n' || key.escape) { + onNo(); + } + }); + + return ( + + + ✓ iOS setup completed! + + + + Set up APNS key for Firebase push notifications? [Y/n] + + + + ); +} + +/** + * Ask user if they want to set up push notifications + */ +async function askPushSetupConfirmation(): Promise { + return new Promise((resolve) => { + const { unmount } = render( + { + unmount(); + resolve(true); + }} + onNo={() => { + unmount(); + resolve(false); + }} + />, + { incrementalRendering: true }, + ); + }); +} + +/** + * Run the push setup wizard (Phase 3) + */ +async function runPushSetup(directResult: IosSetupResult): Promise { + const projectPath = process.cwd(); + + return new Promise((resolve) => { + const { unmount } = render( + { + unmount(); + resolve(result); + }} + onCancel={() => { + unmount(); + resolve(null); + }} + />, + { incrementalRendering: true }, + ); + }); +} + +export async function runIosSetupCommand(options: IosSetupCommandOptions): Promise { // Phase 1: Direct implementation (Portal sync + Entitlements) const directResult = await runDirectSetup(options); @@ -135,13 +426,104 @@ export async function iosSetupCommand(options: IosSetupCommandOptions): Promise< // Show direct setup completion printFinalOutput(toDirectSetupOutput(directResult)); - // Phase 2: Agent completion (Xcode project modifications, Extension setup) + // Phase 2: Automated project modification (pbxproj + Podfile) + let modificationResult: ProjectModificationResult | undefined; + let guidedResult: GuidedSetupResult | undefined; + if (directResult.agentContext) { - console.log('\n'); // Add spacing before agent phase - const agentResult = await runAgentCompletion(directResult); + console.log('\n'); // Add spacing before modification phase + modificationResult = await runProjectModification(directResult, options.pushEnvironment); + + // Fall back to guided setup if automated modification failed or requires manual steps + if (modificationResult.requiresManualSteps) { + console.log('\n'); // Add spacing before guided setup + console.log('Some steps require manual configuration. Starting guided setup...'); + console.log(''); + guidedResult = await runGuidedSetup(directResult); + } + } + + // Phase 3: Push setup (optional - APNS key + Firebase) + // Only ask if Phase 1 & 2 were successful + const phase2Success = + modificationResult?.success && !modificationResult.requiresManualSteps + ? true + : (guidedResult?.success ?? true); + + if (directResult.success && phase2Success) { + console.log('\n'); // Add spacing before push setup prompt + const shouldSetupPush = await askPushSetupConfirmation(); + + if (shouldSetupPush) { + const pushResult = await runPushSetup(directResult); + printConsolidatedOutputWithModification( + directResult, + modificationResult, + guidedResult, + pushResult, + ); + } else { + printConsolidatedOutputWithModification(directResult, modificationResult, guidedResult, null); + } + } +} - if (agentResult) { - printFinalOutput(agentResult); +/** + * Print consolidated output including modification results. + */ +function printConsolidatedOutputWithModification( + directResult: IosSetupResult, + modificationResult: ProjectModificationResult | undefined, + guidedResult: GuidedSetupResult | undefined, + pushResult: PushSetupResult | null, +): void { + console.log('\n'); + console.log('═══════════════════════════════════════════════'); + console.log(' iOS Push Setup Complete! '); + console.log('═══════════════════════════════════════════════'); + console.log(''); + + // Phase 1 summary + if (directResult.success) { + console.log('✓ Capabilities configured'); + console.log('✓ Entitlements created'); + } + + // Phase 2 summary (modification or guided) + if (modificationResult?.extensionFilesCreated) { + console.log('✓ Extension files created'); + } + if (modificationResult?.pbxprojModified) { + console.log('✓ Xcode project updated'); + } + if (modificationResult?.podfileModified) { + console.log('✓ Podfile updated'); + } + if (guidedResult?.success) { + console.log('✓ Guided setup completed'); + } + + // Phase 3 summary + if (pushResult?.success) { + console.log('✓ APNS key registered with Firebase'); + if (pushResult.context?.pushKey) { + console.log(` Key ID: ${pushResult.context.pushKey.apnsKeyId}`); + console.log(` Team ID: ${pushResult.context.pushKey.teamId}`); } + } else if (pushResult === null) { + console.log('○ APNS key setup skipped'); } + + // Warnings + if (modificationResult?.warnings && modificationResult.warnings.length > 0) { + console.log(''); + console.log('Warnings:'); + for (const warning of modificationResult.warnings) { + console.log(` ⚠ ${warning}`); + } + } + + console.log(''); + console.log('Your iOS app is ready to receive push notifications!'); + console.log(''); } diff --git a/src/lib/commands/__tests__/registry.test.ts b/src/lib/commands/__tests__/registry.test.ts index e823367..46cd257 100644 --- a/src/lib/commands/__tests__/registry.test.ts +++ b/src/lib/commands/__tests__/registry.test.ts @@ -145,6 +145,25 @@ describe('Command Registry', () => { const transfer = getCommand('transfer'); expect(transfer?.aliases).toContain('t'); }); + + test('should have ios-setup command with aliases', () => { + const iosSetup = getCommand('ios-setup'); + expect(iosSetup).toBeDefined(); + expect(iosSetup?.name).toBe('ios-setup'); + expect(iosSetup?.aliases).toContain('capabilities'); + expect(iosSetup?.aliases).toContain('ios-capabilities'); + }); + + test('should find ios-setup by alias', () => { + const iosSetup = getCommand('capabilities'); + expect(iosSetup).toBeDefined(); + expect(iosSetup?.name).toBe('ios-setup'); + }); + + test('ios-setup should be local-jsx type not skill', () => { + const iosSetup = getCommand('ios-setup'); + expect(iosSetup?.type).toBe('local-jsx'); + }); }); describe('skill commands', () => { diff --git a/src/lib/commands/ios-setup.tsx b/src/lib/commands/ios-setup.tsx new file mode 100644 index 0000000..ef72cb4 --- /dev/null +++ b/src/lib/commands/ios-setup.tsx @@ -0,0 +1,40 @@ +/** + * iOS Setup command - Configure iOS capabilities, NSE, and APNS key. + * + * @module commands/ios-setup + */ + +import type { ReactNode } from 'react'; +import { runIosSetupCommand } from '@/commands/ios-setup/index'; +import type { Command, CommandDoneCallback } from './types'; + +/** + * iOS Setup command for chat mode. + * Delegates to the CLI implementation. + */ +export const iosSetupCommand: Command = { + type: 'local-jsx', + name: 'ios-setup', + description: 'Configure iOS capabilities, NSE, and APNS key for push notifications', + aliases: ['capabilities', 'ios-capabilities'], + isEnabled: true, + isHidden: false, + + userFacingName() { + return '/ios-setup'; + }, + + async call(onDone: CommandDoneCallback): Promise { + try { + // Run the iOS setup command (skipPortal=true by default for chat mode) + await runIosSetupCommand({ + skipPortal: true, + }); + onDone?.('iOS setup complete'); + } catch (error) { + const message = error instanceof Error ? error.message : 'iOS setup failed'; + onDone?.(message); + } + return null; + }, +}; diff --git a/src/lib/commands/registry.ts b/src/lib/commands/registry.ts index b48fe83..0f50d11 100644 --- a/src/lib/commands/registry.ts +++ b/src/lib/commands/registry.ts @@ -12,6 +12,7 @@ import { exitCommand } from './exit'; import { firebaseCommand } from './firebase'; import { helpCommand } from './help'; import { installMcpCommand } from './install-mcp'; +import { iosSetupCommand } from './ios-setup'; import { loginCommand } from './login'; import { logoutCommand } from './logout'; import { newCommand } from './new'; @@ -34,6 +35,7 @@ const BUILT_IN_COMMANDS: Command[] = [ agentCommand, debugCommand, firebaseCommand, + iosSetupCommand, transferCommand, resumeCommand, installMcpCommand, diff --git a/src/lib/commands/skills.ts b/src/lib/commands/skills.ts index e239983..da79f0f 100644 --- a/src/lib/commands/skills.ts +++ b/src/lib/commands/skills.ts @@ -81,14 +81,7 @@ function generateSkillCommands(): Command[] { ), ); commands.push(createLocalSkillCommand('doctor', 'Check SDK integration status', 'doctor')); - commands.push( - createLocalSkillCommand( - 'ios-setup', - 'Configure iOS capabilities for push notifications and app groups', - 'ios-setup', - ['capabilities', 'ios-capabilities'], - ), - ); + // NOTE: ios-setup is now a LocalJSXCommand in registry.ts, not a skill return commands; } diff --git a/src/lib/embedded-skills.ts b/src/lib/embedded-skills.ts index f7c8328..8d3286f 100644 --- a/src/lib/embedded-skills.ts +++ b/src/lib/embedded-skills.ts @@ -1264,11 +1264,19 @@ For rich push notifications (images, buttons), create a Notification Service Ext import Clix class NotificationService: ClixNotificationServiceExtension { + override init() { + super.init() + register(projectId: "YOUR_PROJECT_ID") + } + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - register(projectId: "YOUR_PROJECT_ID") super.didReceive(request, withContentHandler: contentHandler) } + + override func serviceExtensionTimeWillExpire() { + super.serviceExtensionTimeWillExpire() + } } \`\`\` 4. Add App Groups capability to both main app and extension (same group ID: \`group.clix.{BUNDLE_ID}\`) @@ -1385,377 +1393,6 @@ Analyze the project and output a diagnostic JSON report: Output the JSON diagnostic, then provide a brief summary with actionable recommendations. Use \`/firebase\` command to interactively check and configure Firebase credentials. -`, - 'local-ios-setup': `# iOS Capabilities Configuration - -You are an AI agent that configures iOS capabilities required for the Clix SDK. - -## Core Directive - -**GUIDE USERS** through iOS capability configuration for push notifications and data sharing. For file modifications, use Edit/Write tools when possible. For Xcode-only steps, provide clear step-by-step instructions. - -## Required Capabilities for Clix iOS SDK - -### 1. Push Notifications - -- **Purpose:** Enable APNs (Apple Push Notification service) communication -- **Entitlement Key:** \`aps-environment\` -- **Values:** \`development\` (debug builds) or \`production\` (release builds) -- **Xcode Capability:** Push Notifications - -### 2. App Groups - -- **Purpose:** Share data between main app and Notification Service Extension using MMKV -- **Entitlement Key:** \`com.apple.security.application-groups\` -- **ID Format:** \`group.clix.{BUNDLE_ID}\` (e.g., \`group.clix.com.example.myapp\`) -- **Xcode Capability:** App Groups -- **Important:** Must be configured for BOTH main app AND Notification Service Extension targets - -## Workflow - -### Phase 1: Project Analysis - -1. **Detect iOS Project** - - Search for \`*.xcodeproj\` or \`*.xcworkspace\` files - - Identify the main app target name - - Check if this is a native iOS, React Native, or Flutter project - -2. **Find Bundle Identifier** - - Check \`Info.plist\` for \`CFBundleIdentifier\` - - Or parse \`project.pbxproj\` for \`PRODUCT_BUNDLE_IDENTIFIER\` - -3. **Check Current Capabilities Status** - - Search for existing \`*.entitlements\` files - - Check for \`aps-environment\` entitlement (Push Notifications configured) - - Check for \`com.apple.security.application-groups\` (App Groups configured) - - Check \`project.pbxproj\` for \`SystemCapabilities\` section - -4. **Report Current State** - Output findings: - \`\`\`text - Project: {project_name} - Bundle ID: {bundle_id} - Push Notifications: {configured/not configured} - App Groups: {configured/not configured} - Existing entitlements files: {list} - \`\`\` - -### Phase 2: Xcode Configuration (Manual Steps) - -Provide clear instructions for adding capabilities in Xcode. These steps CANNOT be automated and require user action in Xcode IDE. - -**Add Push Notifications:** -\`\`\`text -1. Open your project in Xcode -2. Select your main app target in the Navigator (left sidebar) -3. Go to the "Signing & Capabilities" tab -4. Click the "+ Capability" button -5. Search for and select "Push Notifications" -6. Xcode will automatically create an entitlements file if one doesn't exist -\`\`\` - -**Add Background Modes (Recommended):** -\`\`\`text -1. In "Signing & Capabilities", click "+ Capability" -2. Select "Background Modes" -3. Enable "Remote notifications" checkbox - - This allows the app to process push notifications in the background -\`\`\` - -**Add App Groups:** -\`\`\`text -1. Click "+ Capability" -2. Select "App Groups" -3. Click the "+" button under App Groups -4. Enter the App Group ID: group.clix.{BUNDLE_ID} - Example: group.clix.com.example.myapp -5. Click OK to create the group - -IMPORTANT: Repeat steps 1-5 for the Notification Service Extension target: -1. Select the extension target (usually named "{AppName}NotificationServiceExtension") -2. Go to "Signing & Capabilities" -3. Add "App Groups" capability -4. Select the SAME App Group ID you created above -\`\`\` - -### Phase 3: Entitlements Files - -Create or modify entitlements files. Use Write/Edit tools for these operations. - -**Main App Entitlements** (\`{AppName}.entitlements\` or \`{AppName}/{AppName}.entitlements\`): - -\`\`\`xml - - - - - aps-environment - development - com.apple.security.application-groups - - group.clix.{BUNDLE_ID} - - - -\`\`\` - -**Notification Service Extension Entitlements** (\`{ExtensionName}/{ExtensionName}.entitlements\`): - -\`\`\`xml - - - - - com.apple.security.application-groups - - group.clix.{BUNDLE_ID} - - - -\`\`\` - -**Note:** Replace \`{BUNDLE_ID}\` with the actual bundle identifier (e.g., \`com.example.myapp\`). - -### Phase 3.5: Notification Service Extension Setup - -Create a Notification Service Extension for rich push notifications (images, buttons, etc.). - -**Create Extension Target in Xcode:** -\`\`\`text -1. File > New > Target -2. Select "Notification Service Extension" -3. Name it "{AppName}NotificationServiceExtension" (e.g., "MyAppNotificationServiceExtension") -4. Click "Finish" (Cancel the "Activate scheme" dialog) -5. Note: Use this exact name consistently in Podfile, entitlements path, and SPM setup -\`\`\` - -**Implement NotificationService.swift:** - -\`\`\`swift -import UserNotifications -import Clix - -class NotificationService: ClixNotificationServiceExtension { - override func didReceive( - _ request: UNNotificationRequest, - withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void - ) { - register(projectId: "YOUR_PROJECT_ID") - super.didReceive(request, withContentHandler: contentHandler) - } -} -\`\`\` - -**Note:** Replace \`YOUR_PROJECT_ID\` with your actual Clix project ID from - -**Add Clix SDK to Extension Target:** - -For CocoaPods projects, add to Podfile: -\`\`\`ruby -target '{AppName}NotificationServiceExtension' do - pod 'Clix' -end -\`\`\` -Then run: \`cd ios && pod install\` - -For SPM projects in Xcode: -1. Select the extension target -2. Go to General > Frameworks, Libraries, and Embedded Content -3. Click + and add the Clix package - -**Configure Build Settings (Xcode 15+):** - -For the extension target: -- Set \`ENABLE_USER_SCRIPT_SANDBOXING\` to "No" in Build Settings - -For React Native projects with Firebase: -- In Build Phases, move "Embed Foundation Extensions" above "[RNFB] Core Configuration" - -### Phase 4: Apple Developer Portal Configuration - -Guide user through manual portal configuration. These steps CANNOT be automated. - -**Enable Capabilities on App ID:** -\`\`\`text -1. Go to https://developer.apple.com/account -2. Navigate to "Certificates, Identifiers & Profiles" -3. Select "Identifiers" from the sidebar -4. Find and click your App ID (Bundle ID) -5. Scroll down to "Capabilities" section -6. Enable "Push Notifications" - - You may need to configure certificates (Development/Production) -7. Enable "App Groups" -8. Click "Save" -\`\`\` - -**Register App Group ID:** -\`\`\`text -1. In the sidebar, select "Identifiers" -2. Click the "+" button -3. Select "App Groups" and click "Continue" -4. Enter: - - Description: Clix SDK App Group for {App Name} - - Identifier: group.clix.{BUNDLE_ID} -5. Click "Continue" then "Register" -6. Go back to your App ID and associate the App Group: - - Edit your App ID - - Under "App Groups", click "Configure" - - Select the App Group you just created - - Click "Save" -\`\`\` - -**Regenerate Provisioning Profile:** -\`\`\`text -After enabling capabilities, your provisioning profiles become invalid. - -1. Navigate to "Profiles" in the sidebar -2. Find your Development and/or Distribution profile -3. Click on the profile -4. Click "Edit" or delete and recreate the profile -5. Ensure the updated App ID is selected -6. Download the new profile - -In Xcode: -1. Go to Xcode > Settings (or Preferences) > Accounts -2. Select your Apple ID -3. Click "Download Manual Profiles" - Or: Delete old profiles and let Xcode auto-manage -\`\`\` - -### Phase 5: Verification - -After configuration, verify the setup and output a report. - -**Check Entitlements Files:** -- Main app entitlements contains \`aps-environment\` -- Main app entitlements contains \`com.apple.security.application-groups\` -- Extension entitlements contains matching App Group ID - -**Check project.pbxproj (if accessible):** -- Look for \`SystemCapabilities\` dictionary -- Verify \`com.apple.Push\` is enabled -- Verify \`com.apple.ApplicationGroups.iOS\` is enabled - -**Output Verification Report:** - -\`\`\`json -{ - "project": "{project_name}", - "bundleId": "{bundle_id}", - "capabilities": { - "pushNotifications": { - "entitlementFile": true, - "environment": "development", - "xcodeCapability": "verify manually in Xcode", - "developerPortal": "verify manually at developer.apple.com" - }, - "appGroups": { - "groupId": "group.clix.{bundle_id}", - "mainAppEntitlement": true, - "extensionEntitlement": true, - "developerPortal": "verify manually at developer.apple.com" - } - }, - "nextSteps": [ - "Verify capabilities are added in Xcode Signing & Capabilities", - "Confirm App Group ID is registered in Apple Developer Portal", - "Regenerate provisioning profiles if needed", - "Build and run to verify no signing errors" - ] -} -\`\`\` - -## Common Issues and Solutions - -### Missing Entitlements File - -**Symptom:** No \`.entitlements\` file exists in the project. - -**Solution:** -- Xcode automatically creates one when you add your first capability -- Or create manually and link in Build Settings: - 1. Create \`{AppName}.entitlements\` file - 2. In Xcode, select target > Build Settings - 3. Search for "Code Signing Entitlements" - 4. Set the path to your entitlements file - -### App Group ID Mismatch - -**Symptom:** Data not shared between app and extension. - -**Solution:** -- Verify the App Group ID is EXACTLY the same in both targets -- Format must be: \`group.clix.{BUNDLE_ID}\` -- Check both entitlements files have identical values - -### Provisioning Profile Invalid - -**Symptom:** "Provisioning profile doesn't include the X capability" error. - -**Solution:** -1. Go to Apple Developer Portal -2. Delete the old provisioning profile -3. Create a new one with the updated App ID -4. Download and install in Xcode -5. Or enable "Automatically manage signing" in Xcode - -### Push Notifications Not Working - -**Symptom:** Push notifications not received. - -**Checklist:** -- [ ] Push Notifications capability added in Xcode -- [ ] \`aps-environment\` in entitlements (check value matches build config) -- [ ] Push Notifications enabled on App ID in Developer Portal -- [ ] APNs certificate or key configured in Clix console -- [ ] Provisioning profile regenerated after enabling capability -- [ ] Physical device used (simulator doesn't receive push) - -### App Group Data Not Shared - -**Symptom:** MMKV data not accessible from extension. - -**Checklist:** -- [ ] App Groups capability added to BOTH main app AND extension -- [ ] Same App Group ID in both targets' entitlements -- [ ] App Group ID registered in Developer Portal -- [ ] App Group associated with App ID in Developer Portal - -## Automation Rules - -**CAN automate (use Write/Edit tools):** -- Creating entitlements files -- Modifying existing entitlements files -- Reading project configuration files -- Detecting current capabilities status - -**CANNOT automate (provide instructions only):** -- Adding capabilities in Xcode UI (Signing & Capabilities tab) -- Enabling capabilities in Apple Developer Portal -- Registering App Group IDs in Developer Portal -- Generating/downloading provisioning profiles -- Associating App Groups with App IDs - -For manual steps, provide clear instructions and proceed without waiting for confirmation. - -## Output Format - -After completing the workflow, summarize: - -1. **Files Created/Modified** - - List all entitlements files with full paths - - Show what was added or changed - -2. **Manual Steps Required** - - Xcode capability additions - - Developer Portal configurations - -3. **Verification Checklist** - - JSON report with status of each component - - Next steps for user to complete - -4. **Troubleshooting Tips** - - Common issues to watch for based on project state `, }; diff --git a/src/lib/ios/agent-prompt-generator.ts b/src/lib/ios/agent-prompt-generator.ts index 32f2f16..0a6317a 100644 --- a/src/lib/ios/agent-prompt-generator.ts +++ b/src/lib/ios/agent-prompt-generator.ts @@ -12,125 +12,6 @@ export interface AgentContext { iosDir: string; } -/** - * Generate agent prompt for completing iOS setup - * The agent will handle tasks that cannot be done programmatically: - * - Xcode project file modifications (.pbxproj) - * - Notification Service Extension creation - * - Extension code generation - */ -export function generateAgentPrompt(context: AgentContext): string { - return `## iOS Setup - Remaining Tasks - -The following has been completed automatically: -- Apple Developer Portal capabilities synced (Push Notifications, App Groups) -- Entitlements file created: ${context.entitlementsPath} -- App Group ID: ${context.appGroupId} - -### Project Information -- App Name: ${context.appName} -- Bundle ID: ${context.bundleId} -- iOS Directory: ${context.iosDir} -- Project Path: ${context.projectPath} - -### Tasks to Complete - -#### 1. Link Entitlements in Xcode Project [Required] -Modify the Xcode project file (.pbxproj) to link the entitlements file: -- Find the main app target -- Add CODE_SIGN_ENTITLEMENTS build setting pointing to the entitlements file -- Ensure both Debug and Release configurations are updated - -#### 2. Create Notification Service Extension [Required] -Create a new Notification Service Extension target for rich push notifications: -- Target name: \`${context.appName}NotificationServiceExtension\` -- Bundle ID: \`${context.bundleId}.${context.appName}NotificationServiceExtension\` -- Deployment target: Same as main app or iOS 14.0+ -- Create necessary files in the extension directory - -#### 3. Implement NotificationService.swift [Required] -Create the NotificationService.swift file with Clix SDK integration: - -\`\`\`swift -import UserNotifications -import Clix - -class NotificationService: ClixNotificationServiceExtension { - override func didReceive( - _ request: UNNotificationRequest, - withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void - ) { - register(projectId: "YOUR_PROJECT_ID") - super.didReceive(request, withContentHandler: contentHandler) - } -} -\`\`\` - -**Note:** Replace \`YOUR_PROJECT_ID\` with your actual Clix project ID from https://console.clix.so/ - -#### 4. Add Clix SDK to Extension Target [Required] - -**For CocoaPods projects:** -Add to your Podfile: -\`\`\`ruby -target '${context.appName}NotificationServiceExtension' do - pod 'Clix' -end -\`\`\` -Then run: \`cd ios && pod install\` - -**For SPM projects:** -In Xcode: -1. Select the extension target -2. Go to General > Frameworks, Libraries, and Embedded Content -3. Click + and add the Clix package - -#### 5. Create Extension Entitlements [Required] -Create entitlements file for the extension at \`${context.iosDir}/${context.appName}NotificationServiceExtension/${context.appName}NotificationServiceExtension.entitlements\`: - -\`\`\`xml - - - - - com.apple.security.application-groups - - ${context.appGroupId} - - - -\`\`\` - -#### 6. Update Extension Info.plist [Required] -Ensure the extension's Info.plist has the correct NSExtension configuration: - -\`\`\`xml -NSExtension - - NSExtensionPointIdentifier - com.apple.usernotifications.service - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).NotificationService - -\`\`\` - -#### 7. Configure Build Settings [Required - Xcode 15+] - -For the extension target in Xcode: -- Set \`ENABLE_USER_SCRIPT_SANDBOXING\` to "No" in Build Settings - -For React Native projects with Firebase: -- In Build Phases, move "Embed Foundation Extensions" above "[RNFB] Core Configuration" - -### Important Notes -- The extension must share the same App Group as the main app -- The extension's bundle ID must be a child of the main app's bundle ID -- Ensure the extension is added to the app's "Embed App Extensions" build phase -- Replace \`YOUR_PROJECT_ID\` with your actual Clix project ID - -Please complete these tasks by modifying the Xcode project files directly.`; -} - /** * Build agent context from iOS setup results */ diff --git a/src/lib/ios/apple-auth.ts b/src/lib/ios/apple-auth.ts new file mode 100644 index 0000000..cfe3e52 --- /dev/null +++ b/src/lib/ios/apple-auth.ts @@ -0,0 +1,425 @@ +/** + * Apple account authentication for iOS setup. + * Supports both API Key and User (Apple ID/Password) authentication. + * Based on EAS CLI implementation using @expo/apple-utils. + * + * @module ios/apple-auth + */ + +import * as fs from 'node:fs'; +import { + Auth, + InvalidUserCredentialsError, + JsonFileCache, + type RequestContext, + Session, + Teams, + Token, +} from '@expo/apple-utils'; + +import type { ApiKeyAuthConfig } from './apple-portal'; +import { + CLIX_NO_KEYCHAIN, + deletePasswordAsync, + getAppleKeychainServiceName, + getPasswordAsync, + setPasswordAsync, +} from './keychain'; + +/** + * Authentication modes supported by the Apple APIs. + */ +export enum AuthenticationMode { + /** App Store Connect API Key (JWT-based, CI-friendly, no 2FA) */ + API_KEY = 'API_KEY', + /** User credentials (cookie-based, more features, requires 2FA) */ + USER = 'USER', +} + +/** + * Apple team information. + */ +export interface AppleTeam { + id: string; + name?: string; + inHouse: boolean; +} + +// Re-export ApiKeyAuthConfig from apple-portal for backwards compatibility +export type { ApiKeyAuthConfig }; + +/** + * User authentication context (Apple ID/Password). + */ +export interface UserAuthContext { + appleId: string; + appleIdPassword?: string; + team: AppleTeam; + authState: Session.AuthState; + fastlaneSession?: string; +} + +/** + * API Key authentication context. + */ +export interface ApiKeyAuthContext { + team: AppleTeam; + authState: { + context: RequestContext; + }; + ascApiKey: ApiKeyAuthConfig; +} + +/** + * Combined authentication context. + */ +export type AuthContext = UserAuthContext | ApiKeyAuthContext; + +/** + * Authentication options. + */ +export interface AuthOptions { + appleId?: string; + teamId?: string; + teamName?: string; + ascApiKey?: ApiKeyAuthConfig; + cookies?: Session.AuthState['cookies']; + mode?: AuthenticationMode; +} + +/** + * Check if auth context is user-based. + */ +export function isUserAuthContext(authCtx: AuthContext | undefined): authCtx is UserAuthContext { + return !!authCtx && typeof (authCtx as UserAuthContext).appleId === 'string'; +} + +/** + * Get request context from auth context. + */ +export function getRequestContext(authCtx: AuthContext): RequestContext { + if (isUserAuthContext(authCtx)) { + if (!authCtx.authState?.context) { + throw new Error('Apple request context must be defined'); + } + return authCtx.authState.context; + } + return authCtx.authState.context; +} + +/** + * Prompt for Apple ID (username). + */ +export async function promptAppleIdAsync( + promptFn: (message: string, defaultValue?: string) => Promise, +): Promise { + const lastAppleId = await getCachedUsernameAsync(); + + console.log('› Log in to your Apple Developer account to continue'); + + let username = await promptFn('Apple ID:', lastAppleId ?? undefined); + + // Remove any unprintable control characters (ASCII 0-31) + username = removeControlCharacters(username); + + if (username && username !== lastAppleId) { + await cacheUsernameAsync(username); + } + + return username; +} + +/** + * Prompt for Apple ID password. + */ +export async function promptPasswordAsync( + username: string, + promptFn: (message: string) => Promise, +): Promise { + const cachedPassword = await getCachedPasswordAsync(username); + + if (cachedPassword) { + console.log(`› Using password for ${username} from your local Keychain`); + return cachedPassword; + } + + console.log('› The password is only used to authenticate with Apple and never stored on servers'); + + const password = await promptFn(`Password (for ${username}):`); + + await cachePasswordAsync(username, password); + return password; +} + +/** + * Cache username to file. + */ +async function cacheUsernameAsync(username: string): Promise { + if (!CLIX_NO_KEYCHAIN && username) { + const cachedPath = JsonFileCache.usernameCachePath(); + await JsonFileCache.cacheAsync(cachedPath, { username }); + } +} + +/** + * Get cached username from file. + */ +async function getCachedUsernameAsync(): Promise { + if (CLIX_NO_KEYCHAIN) { + try { + await fs.promises.unlink(JsonFileCache.usernameCachePath()); + } catch { + // File may not exist + } + return null; + } + + const cached = await JsonFileCache.getCacheAsync(JsonFileCache.usernameCachePath()); + const lastAppleId = cached?.username ?? null; + return typeof lastAppleId === 'string' ? lastAppleId : null; +} + +/** + * Cache password to Keychain. + */ +async function cachePasswordAsync(username: string, password: string): Promise { + if (CLIX_NO_KEYCHAIN) { + console.log('› Skip storing Apple ID password in the local Keychain.'); + return false; + } + + console.log('› Saving Apple ID password to the local Keychain'); + const serviceName = getAppleKeychainServiceName(username); + return setPasswordAsync({ username, password, serviceName }); +} + +/** + * Get cached password from Keychain. + */ +async function getCachedPasswordAsync(username: string): Promise { + if (CLIX_NO_KEYCHAIN) { + await deletePasswordAsync({ username, serviceName: getAppleKeychainServiceName(username) }); + return null; + } + + const serviceName = getAppleKeychainServiceName(username); + return getPasswordAsync({ username, serviceName }); +} + +/** + * Delete cached password from Keychain. + */ +export async function deleteCachedPasswordAsync(username: string): Promise { + const serviceName = getAppleKeychainServiceName(username); + const success = await deletePasswordAsync({ username, serviceName }); + if (success) { + console.log('› Removed Apple ID password from the native Keychain'); + } + return success; +} + +/** + * Login with Apple ID credentials. + * Handles session restoration, 2FA prompts (via @expo/apple-utils), and password caching. + */ +export async function loginWithUserCredentialsAsync( + promptAppleId: (message: string, defaultValue?: string) => Promise, + promptPassword: (message: string) => Promise, + promptConfirm: (message: string) => Promise, + options: { + cookies?: Session.AuthState['cookies']; + teamId?: string; + providerId?: number; + } = {}, +): Promise { + // Try login with cookies first + if (options.cookies) { + const session = await Auth.loginWithCookiesAsync({ cookies: options.cookies }); + if (session) { + return await buildUserAuthContext(session); + } + } + + // Get username + const username = await promptAppleIdAsync(promptAppleId); + + // Clear in-memory data + Auth.resetInMemoryData(); + + try { + // Try restoring session + const restoredSession = await Auth.tryRestoringAuthStateFromUserCredentialsAsync( + { + username, + providerId: options.providerId, + teamId: options.teamId, + }, + { autoResolveProvider: true }, + ); + + if (restoredSession) { + return await buildUserAuthContext({ ...restoredSession }); + } + + // Full login with password + const password = await promptPasswordAsync(username, promptPassword); + const newSession = await Auth.loginWithUserCredentialsAsync( + { + username, + password, + providerId: options.providerId, + teamId: options.teamId, + }, + { autoResolveProvider: true }, + ); + + if (!newSession) { + throw new Error('An unexpected error occurred while completing authentication'); + } + + return await buildUserAuthContext({ password, ...newSession }); + } catch (error) { + if (error instanceof InvalidUserCredentialsError) { + console.error(error.message); + await deleteCachedPasswordAsync(username); + + const retry = await promptConfirm('Would you like to try again?'); + if (retry) { + return loginWithUserCredentialsAsync(promptAppleId, promptPassword, promptConfirm, { + teamId: options.teamId, + providerId: options.providerId, + }); + } + throw new Error('ABORTED'); + } + throw error; + } +} + +/** + * Build UserAuthContext from session. + */ +async function buildUserAuthContext(authState: Session.AuthState): Promise { + const teamId = authState.context.teamId; + + if (!teamId) { + throw new Error('Team ID not found in authentication state'); + } + + // Get all teams to resolve user data + const teams = await Teams.getTeamsAsync(); + const team = teams.find((t) => t.teamId === teamId); + + if (!team) { + throw new Error(`Your account is not associated with Apple Team with ID: ${teamId}`); + } + + const fastlaneSession = Session.getSessionAsYAML(); + + return { + appleId: authState.username, + appleIdPassword: authState.password, + team: { + id: team.teamId, + name: `${team.name} (${team.type})`, + inHouse: team.type.toLowerCase() === 'in-house', + }, + authState, + fastlaneSession, + }; +} + +/** + * Authenticate with API Key. + */ +export async function authenticateWithApiKeyAsync( + apiKey: ApiKeyAuthConfig, + teamId?: string, +): Promise { + const token = new Token({ + key: apiKey.keyP8, + issuerId: apiKey.issuerId, + keyId: apiKey.keyId, + duration: 1200, // 20 minutes + }); + + return { + team: { + id: teamId || '', + inHouse: false, + }, + authState: { + context: { token }, + }, + ascApiKey: apiKey, + }; +} + +/** + * Check if API Key environment variables are set. + */ +export function hasApiKeyEnvVars(): boolean { + return !!( + process.env.EXPO_ASC_API_KEY_PATH || + process.env.EXPO_ASC_KEY_ID || + process.env.EXPO_ASC_ISSUER_ID || + process.env.CLIX_ASC_API_KEY_PATH || + process.env.CLIX_ASC_KEY_ID || + process.env.CLIX_ASC_ISSUER_ID + ); +} + +/** + * Check if Apple ID environment variables are set. + */ +export function hasAppleIdEnvVars(): boolean { + return !!(process.env.EXPO_APPLE_ID || process.env.CLIX_APPLE_ID); +} + +/** + * Get API Key from environment variables. + */ +export async function getApiKeyFromEnvAsync(): Promise { + const keyPath = process.env.EXPO_ASC_API_KEY_PATH || process.env.CLIX_ASC_API_KEY_PATH; + const keyId = process.env.EXPO_ASC_KEY_ID || process.env.CLIX_ASC_KEY_ID; + const issuerId = process.env.EXPO_ASC_ISSUER_ID || process.env.CLIX_ASC_ISSUER_ID; + + if (!keyPath && !keyId && !issuerId) { + return null; + } + + if (!keyPath || !keyId || !issuerId) { + throw new Error( + 'Incomplete API Key configuration. Please provide all of: API Key Path, Key ID, and Issuer ID.', + ); + } + + if (!fs.existsSync(keyPath)) { + throw new Error(`API Key file not found: ${keyPath}`); + } + + const keyP8 = fs.readFileSync(keyPath, 'utf-8'); + return { keyId, issuerId, keyP8 }; +} + +/** + * Get Apple ID from environment variables. + */ +export function getAppleIdFromEnv(): string | null { + return process.env.EXPO_APPLE_ID || process.env.CLIX_APPLE_ID || null; +} + +/** + * Remove control characters (ASCII 0-31) from a string. + * Used to sanitize user input that may contain unprintable characters. + */ +function removeControlCharacters(str: string): string { + let result = ''; + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code >= 32) { + result += str[i]; + } + } + return result; +} diff --git a/src/lib/ios/entitlements-manager.ts b/src/lib/ios/entitlements-manager.ts index 89b5050..c4864ed 100644 --- a/src/lib/ios/entitlements-manager.ts +++ b/src/lib/ios/entitlements-manager.ts @@ -1,6 +1,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import plist from '@expo/plist'; +import plist, { type PlistValue } from 'plist'; export interface EntitlementsConfig { /** Push notification environment: development or production */ @@ -39,7 +39,7 @@ export async function writeEntitlements( entitlementsPath: string, data: EntitlementsData, ): Promise { - const content = plist.build(data); + const content = plist.build(data as PlistValue); // Ensure directory exists const dir = path.dirname(entitlementsPath); diff --git a/src/lib/ios/extension-generator.ts b/src/lib/ios/extension-generator.ts new file mode 100644 index 0000000..dcd667c --- /dev/null +++ b/src/lib/ios/extension-generator.ts @@ -0,0 +1,140 @@ +/** + * Notification Service Extension file generator + * Creates the necessary files for NSE without requiring AI agent + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { generateExtensionEntitlements, writeEntitlements } from './entitlements-manager'; +import { + EXTENSION_INFO_PLIST_TEMPLATE, + NOTIFICATION_SERVICE_TEMPLATE, +} from './extension-templates'; + +export interface ExtensionContext { + appName: string; + bundleId: string; + iosDir: string; + pushEnvironment?: 'development' | 'production'; +} + +export interface ExtensionGeneratorResult { + success: boolean; + createdFiles: string[]; + extensionDir: string; + extensionName: string; + error?: string; +} + +/** + * Get the extension name from app name + */ +export function getExtensionName(appName: string): string { + return `${appName}NotificationServiceExtension`; +} + +/** + * Get the extension bundle ID from main app bundle ID + */ +export function getExtensionBundleId(bundleId: string, appName: string): string { + return `${bundleId}.${getExtensionName(appName)}`; +} + +/** + * Check if extension files already exist + */ +export function extensionFilesExist(iosDir: string, appName: string): boolean { + const extensionName = getExtensionName(appName); + const extensionDir = path.join(iosDir, extensionName); + const swiftPath = path.join(extensionDir, 'NotificationService.swift'); + + return fs.existsSync(swiftPath); +} + +/** + * Create Notification Service Extension files + */ +export async function createExtensionFiles( + context: ExtensionContext, +): Promise { + const extensionName = getExtensionName(context.appName); + const extensionDir = path.join(context.iosDir, extensionName); + const createdFiles: string[] = []; + const pushEnvironment = context.pushEnvironment || 'development'; + + try { + // 1. Create extension directory + if (!fs.existsSync(extensionDir)) { + fs.mkdirSync(extensionDir, { recursive: true }); + } + + // 2. Create NotificationService.swift + const swiftPath = path.join(extensionDir, 'NotificationService.swift'); + if (!fs.existsSync(swiftPath)) { + fs.writeFileSync(swiftPath, NOTIFICATION_SERVICE_TEMPLATE.trim()); + createdFiles.push(swiftPath); + } + + // 3. Create Info.plist + const plistPath = path.join(extensionDir, 'Info.plist'); + if (!fs.existsSync(plistPath)) { + fs.writeFileSync(plistPath, EXTENSION_INFO_PLIST_TEMPLATE.trim()); + createdFiles.push(plistPath); + } + + // 4. Create extension entitlements + const entitlementsPath = path.join(extensionDir, `${extensionName}.entitlements`); + if (!fs.existsSync(entitlementsPath)) { + const entitlements = generateExtensionEntitlements(context.bundleId, pushEnvironment); + await writeEntitlements(entitlementsPath, entitlements); + createdFiles.push(entitlementsPath); + } + + return { + success: true, + createdFiles, + extensionDir, + extensionName, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + createdFiles, + extensionDir, + extensionName, + error: message, + }; + } +} + +/** + * Verify extension files are complete + */ +export function verifyExtensionFiles( + iosDir: string, + appName: string, +): { complete: boolean; missingFiles: string[] } { + const extensionName = getExtensionName(appName); + const extensionDir = path.join(iosDir, extensionName); + + const requiredFiles = [ + 'NotificationService.swift', + 'Info.plist', + `${extensionName}.entitlements`, + ]; + + const missingFiles: string[] = []; + + for (const file of requiredFiles) { + const filePath = path.join(extensionDir, file); + if (!fs.existsSync(filePath)) { + missingFiles.push(file); + } + } + + return { + complete: missingFiles.length === 0, + missingFiles, + }; +} diff --git a/src/lib/ios/extension-templates.ts b/src/lib/ios/extension-templates.ts new file mode 100644 index 0000000..d3dbea3 --- /dev/null +++ b/src/lib/ios/extension-templates.ts @@ -0,0 +1,59 @@ +/** + * Templates for Notification Service Extension files + */ + +/** + * NotificationService.swift template for Clix SDK integration. + * Based on https://docs.clix.so/sdk-ios-nse + */ +export const NOTIFICATION_SERVICE_TEMPLATE = `import UserNotifications +import Clix + +class NotificationService: ClixNotificationServiceExtension { + override init() { + super.init() + // Register with Clix (replace with your project ID from https://console.clix.so/) + register(projectId: "YOUR_PROJECT_ID") + } + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + super.didReceive(request, withContentHandler: contentHandler) + } + + override func serviceExtensionTimeWillExpire() { + super.serviceExtensionTimeWillExpire() + } +} +`; + +/** + * Extension Info.plist template + */ +export const EXTENSION_INFO_PLIST_TEMPLATE = ` + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + +`; + +/** + * CocoaPods Podfile snippet for extension target + */ +export function generatePodfileSnippet(extensionName: string): string { + // Escape apostrophes for Ruby single-quoted strings + const safeName = extensionName.replace(/'/g, "\\'"); + return `target '${safeName}' do + pod 'Clix' +end`; +} diff --git a/src/lib/ios/index.ts b/src/lib/ios/index.ts index 6e5df3d..b378bd7 100644 --- a/src/lib/ios/index.ts +++ b/src/lib/ios/index.ts @@ -1,12 +1,28 @@ // iOS project analysis -// Agent prompt generation for remaining tasks -export { - type AgentContext, - buildAgentContext, - generateAgentPrompt, -} from './agent-prompt-generator'; +// Agent context (used to pass setup context between phases) +export { type AgentContext, buildAgentContext } from './agent-prompt-generator'; +// Apple account authentication (supports both API Key and User login) +export { + type ApiKeyAuthContext, + type AppleTeam, + type AuthContext, + AuthenticationMode, + type AuthOptions, + authenticateWithApiKeyAsync, + deleteCachedPasswordAsync, + getApiKeyFromEnvAsync, + getAppleIdFromEnv, + getRequestContext, + hasApiKeyEnvVars, + hasAppleIdEnvVars, + isUserAuthContext, + loginWithUserCredentialsAsync, + promptAppleIdAsync, + promptPasswordAsync, + type UserAuthContext, +} from './apple-auth'; // Apple Developer Portal integration export { type ApiKeyAuthConfig, @@ -20,7 +36,6 @@ export { syncCapabilities, validateCredentials, } from './apple-portal'; - // Entitlements management export { type EntitlementsConfig, @@ -34,6 +49,48 @@ export { updateEntitlementsForClix, writeEntitlements, } from './entitlements-manager'; +// Extension file generation (replaces agent-based approach) +export { + createExtensionFiles, + type ExtensionContext, + type ExtensionGeneratorResult, + extensionFilesExist, + getExtensionBundleId, + getExtensionName, + verifyExtensionFiles, +} from './extension-generator'; +export { generatePodfileSnippet } from './extension-templates'; +// Keychain integration +export { + CLIX_NO_KEYCHAIN, + deletePasswordAsync as deleteKeychainPasswordAsync, + getAppleKeychainServiceName, + getPasswordAsync as getKeychainPasswordAsync, + isKeychainAvailable, + type KeychainCredentials, + setPasswordAsync as setKeychainPasswordAsync, +} from './keychain'; +// pbxproj manipulation +export { + addNotificationServiceExtension, + backupProject, + getProjectTargets, + hasNotificationServiceExtension, + type PbxprojModificationResult, + type PbxprojModifierOptions, + restoreProject, +} from './pbxproj-modifier'; +// Podfile manipulation +export { + addClixToExtensionTarget, + backupPodfile, + getPodfileTargets, + hasExtensionTarget, + hasPodfile, + type PodfileModificationResult, + type PodfileModifierOptions, + restorePodfile, +} from './podfile-modifier'; export { analyzeIosProject, findEntitlementsFiles, @@ -41,3 +98,14 @@ export { type IosProjectInfo, type ProjectAnalysisResult, } from './project-analyzer'; +// Push Key management +export { + APPLE_KEYS_TOO_MANY_ERROR, + createPushKeyAsync, + downloadPushKeyAsync, + isPushKeyValid, + listPushKeysAsync, + type PushKey, + type PushKeyStoreInfo, + revokePushKeysAsync, +} from './push-key'; diff --git a/src/lib/ios/keychain.ts b/src/lib/ios/keychain.ts new file mode 100644 index 0000000..3f443fa --- /dev/null +++ b/src/lib/ios/keychain.ts @@ -0,0 +1,123 @@ +/** + * macOS Keychain integration for secure credential storage. + * Based on EAS CLI implementation. + * + * @module ios/keychain + */ + +import keychain from 'keychain'; + +const KEYCHAIN_TYPE = 'internet'; +const NO_PASSWORD_REGEX = /Could not find password/; +const IS_MAC = process.platform === 'darwin'; + +/** + * Environment variable to disable keychain functionality. + * When set, passwords will be skipped and existing ones deleted. + */ +export const CLIX_NO_KEYCHAIN = process.env.CLIX_NO_KEYCHAIN; + +export interface KeychainCredentials { + serviceName: string; + username: string; + password: string; +} + +/** + * Delete a password from the macOS Keychain. + */ +export async function deletePasswordAsync({ + username, + serviceName, +}: Pick): Promise { + if (!IS_MAC) { + return Promise.resolve(false); + } + + return new Promise((resolve, reject) => { + keychain.deletePassword( + { account: username, service: serviceName, type: KEYCHAIN_TYPE }, + (error: Error) => { + if (error) { + if (NO_PASSWORD_REGEX.test(error.message)) { + resolve(false); + return; + } + reject(error); + } else { + resolve(true); + } + }, + ); + }); +} + +/** + * Get a password from the macOS Keychain. + */ +export async function getPasswordAsync({ + username, + serviceName, +}: Pick): Promise { + if (!IS_MAC || CLIX_NO_KEYCHAIN) { + return null; + } + + return new Promise((resolve, reject) => { + keychain.getPassword( + { account: username, service: serviceName, type: KEYCHAIN_TYPE }, + (error: Error, password?: string) => { + if (error) { + if (NO_PASSWORD_REGEX.test(error.message)) { + resolve(null); + return; + } + reject(error); + } else { + resolve(password ?? null); + } + }, + ); + }); +} + +/** + * Store a password in the macOS Keychain. + */ +export async function setPasswordAsync({ + serviceName, + username, + password, +}: KeychainCredentials): Promise { + if (!IS_MAC || CLIX_NO_KEYCHAIN) { + return Promise.resolve(false); + } + + return new Promise((resolve, reject) => { + keychain.setPassword( + { account: username, service: serviceName, password, type: KEYCHAIN_TYPE }, + (error: Error) => { + if (error) { + reject(error); + } else { + resolve(true); + } + }, + ); + }); +} + +/** + * Get the keychain service name for Apple ID credentials. + * Uses the same format as Fastlane for potential interoperability. + */ +export function getAppleKeychainServiceName(appleId: string): string { + return `deliver.${appleId}`; +} + +/** + * Check if keychain functionality is available. + */ +export function isKeychainAvailable(): boolean { + return IS_MAC && !CLIX_NO_KEYCHAIN; +} diff --git a/src/lib/ios/pbxproj-modifier.ts b/src/lib/ios/pbxproj-modifier.ts new file mode 100644 index 0000000..aa6a896 --- /dev/null +++ b/src/lib/ios/pbxproj-modifier.ts @@ -0,0 +1,259 @@ +/** + * Xcode project (.pbxproj) modifier for iOS setup automation. + * Uses the 'xcode' npm package to programmatically modify Xcode projects. + * + * @module ios/pbxproj-modifier + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import xcode from 'xcode'; + +const NSE_PRODUCT_TYPE = 'com.apple.product-type.app-extension'; +const APP_PRODUCT_TYPE = 'com.apple.product-type.application'; + +/** + * Options for pbxproj modification operations. + */ +export interface PbxprojModifierOptions { + /** Path to .xcodeproj directory */ + projectPath: string; + /** Extension target name (e.g., "AppNotificationServiceExtension") */ + extensionName: string; + /** Extension bundle ID (e.g., "com.example.app.AppNotificationServiceExtension") */ + extensionBundleId: string; + /** Path where extension files are located */ + extensionDir: string; + /** App group for shared data */ + appGroupId: string; + /** Development team ID */ + teamId?: string; + /** iOS deployment target (default: "14.0") */ + deploymentTarget?: string; +} + +/** + * Result of pbxproj modification operations. + */ +export interface PbxprojModificationResult { + success: boolean; + targetAdded: boolean; + filesLinked: string[]; + buildSettingsApplied: string[]; + warnings: string[]; + error?: string; +} + +/** + * Check if NSE target already exists in the project. + */ +export function hasNotificationServiceExtension( + projectPath: string, + extensionName: string, +): boolean { + const pbxprojPath = path.join(projectPath, 'project.pbxproj'); + if (!fs.existsSync(pbxprojPath)) { + return false; + } + + try { + const project = xcode.project(pbxprojPath); + project.parseSync(); + + const targets = project.pbxNativeTargetSection(); + if (!targets) return false; + + for (const key of Object.keys(targets)) { + const target = targets[key] as { name?: string } | undefined; + if (target && typeof target === 'object' && target.name === extensionName) { + return true; + } + } + return false; + } catch { + return false; + } +} + +/** + * Create backup of the Xcode project file. + */ +export function backupProject(projectPath: string): string { + const pbxprojPath = path.join(projectPath, 'project.pbxproj'); + const backupPath = `${pbxprojPath}.backup.${Date.now()}`; + fs.copyFileSync(pbxprojPath, backupPath); + return backupPath; +} + +/** + * Restore project from backup. + */ +export function restoreProject(backupPath: string, projectPath: string): void { + const pbxprojPath = path.join(projectPath, 'project.pbxproj'); + fs.copyFileSync(backupPath, pbxprojPath); +} + +/** + * Find the main app target UUID by productType. + */ +function findAppTargetUuid(project: { + pbxNativeTargetSection: () => Record | null; +}): string | null { + const targets = project.pbxNativeTargetSection(); + if (!targets) return null; + + for (const key of Object.keys(targets)) { + const t = targets[key] as { productType?: string } | undefined; + if (t && typeof t === 'object' && t.productType === APP_PRODUCT_TYPE) { + return key; + } + } + return null; +} + +/** + * Add Notification Service Extension target to Xcode project. + */ +export async function addNotificationServiceExtension( + options: PbxprojModifierOptions, +): Promise { + const result: PbxprojModificationResult = { + success: false, + targetAdded: false, + filesLinked: [], + buildSettingsApplied: [], + warnings: [], + }; + + const pbxprojPath = path.join(options.projectPath, 'project.pbxproj'); + + if (!fs.existsSync(pbxprojPath)) { + result.error = `Project file not found: ${pbxprojPath}`; + return result; + } + + try { + // 1. Parse project + const project = xcode.project(pbxprojPath); + project.parseSync(); + + // 2. Check if target already exists + if (hasNotificationServiceExtension(options.projectPath, options.extensionName)) { + result.warnings.push('NSE target already exists, skipping target creation'); + result.success = true; + return result; + } + + // 3. Add NSE target + const target = project.addTarget( + options.extensionName, + NSE_PRODUCT_TYPE, + options.extensionName, + options.extensionBundleId, + ); + + if (!target) { + result.error = 'Failed to add target to project'; + return result; + } + + result.targetAdded = true; + + // 4. Add source file (NotificationService.swift) + const swiftFile = path.join(options.extensionDir, 'NotificationService.swift'); + if (fs.existsSync(swiftFile)) { + const groupKey = project.findPBXGroupKey({ name: options.extensionName }); + if (groupKey) { + project.addSourceFile('NotificationService.swift', { target: target.uuid }, groupKey); + result.filesLinked.push('NotificationService.swift'); + } else { + result.warnings.push('Could not find group for extension, files may need manual linking'); + } + } + + // 5. Set build settings for the extension target + const deploymentTarget = options.deploymentTarget || '14.0'; + // Compute project-relative paths from extensionDir + const projectDir = path.dirname(options.projectPath); + const extensionRelDir = path.relative(projectDir, options.extensionDir); + const entitlementsPath = path.join(extensionRelDir, `${options.extensionName}.entitlements`); + const infoPlistPath = path.join(extensionRelDir, 'Info.plist'); + const buildSettings: Record = { + CODE_SIGN_ENTITLEMENTS: entitlementsPath, + ENABLE_USER_SCRIPT_SANDBOXING: 'NO', + INFOPLIST_FILE: infoPlistPath, + PRODUCT_BUNDLE_IDENTIFIER: options.extensionBundleId, + IPHONEOS_DEPLOYMENT_TARGET: deploymentTarget, + SWIFT_VERSION: '5.0', + TARGETED_DEVICE_FAMILY: '1,2', + GENERATE_INFOPLIST_FILE: 'NO', + }; + + if (options.teamId) { + buildSettings.DEVELOPMENT_TEAM = options.teamId; + } + + // Apply build settings to both Debug and Release configurations + for (const [setting, value] of Object.entries(buildSettings)) { + try { + project.updateBuildProperty(setting, value, null, options.extensionName); + result.buildSettingsApplied.push(setting); + } catch { + result.warnings.push(`Could not set ${setting}, may need manual configuration`); + } + } + + // 6. Add target dependency to main app (embed extension) + // Find the main app target by productType instead of using getFirstTarget() + const appTargetUuid = findAppTargetUuid(project); + if (appTargetUuid) { + try { + project.addTargetDependency(appTargetUuid, [target.uuid]); + } catch { + result.warnings.push( + 'Could not add target dependency, extension may need manual embedding', + ); + } + } else { + result.warnings.push('Could not find main app target, extension may need manual embedding'); + } + + // 7. Write changes to project file + fs.writeFileSync(pbxprojPath, project.writeSync()); + + result.success = true; + } catch (error) { + result.error = error instanceof Error ? error.message : String(error); + } + + return result; +} + +/** + * Get all native targets in the project. + */ +export function getProjectTargets(projectPath: string): string[] { + const pbxprojPath = path.join(projectPath, 'project.pbxproj'); + if (!fs.existsSync(pbxprojPath)) { + return []; + } + + try { + const project = xcode.project(pbxprojPath); + project.parseSync(); + + const targets = project.pbxNativeTargetSection(); + if (!targets) return []; + + const targetNames: string[] = []; + for (const key of Object.keys(targets)) { + const target = targets[key] as { name?: string } | undefined; + if (target && typeof target === 'object' && target.name) { + targetNames.push(target.name); + } + } + return targetNames; + } catch { + return []; + } +} diff --git a/src/lib/ios/podfile-modifier.ts b/src/lib/ios/podfile-modifier.ts new file mode 100644 index 0000000..0dfaf46 --- /dev/null +++ b/src/lib/ios/podfile-modifier.ts @@ -0,0 +1,166 @@ +/** + * Podfile modifier for iOS setup automation. + * Adds Clix SDK to extension targets in CocoaPods projects. + * + * @module ios/podfile-modifier + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * Options for Podfile modification operations. + */ +export interface PodfileModifierOptions { + /** iOS project directory containing Podfile */ + iosDir: string; + /** NSE target name */ + extensionName: string; + /** Custom pod spec (default: 'Clix') */ + clixPodSpec?: string; +} + +/** + * Result of Podfile modification operations. + */ +export interface PodfileModificationResult { + success: boolean; + modified: boolean; + podfileExists: boolean; + targetAdded: boolean; + error?: string; +} + +/** + * Check if Podfile exists in the iOS directory. + */ +export function hasPodfile(iosDir: string): boolean { + return fs.existsSync(path.join(iosDir, 'Podfile')); +} + +/** + * Check if extension target already exists in Podfile. + */ +export function hasExtensionTarget(iosDir: string, extensionName: string): boolean { + const podfilePath = path.join(iosDir, 'Podfile'); + if (!fs.existsSync(podfilePath)) return false; + + const content = fs.readFileSync(podfilePath, 'utf-8'); + const targetRegex = new RegExp(`target\\s+['"]${escapeRegex(extensionName)}['"]\\s+do`, 'i'); + return targetRegex.test(content); +} + +/** + * Create backup of Podfile before modification. + */ +export function backupPodfile(iosDir: string): string { + const podfilePath = path.join(iosDir, 'Podfile'); + const backupPath = `${podfilePath}.backup.${Date.now()}`; + fs.copyFileSync(podfilePath, backupPath); + return backupPath; +} + +/** + * Restore Podfile from backup. + */ +export function restorePodfile(backupPath: string, iosDir: string): void { + const podfilePath = path.join(iosDir, 'Podfile'); + fs.copyFileSync(backupPath, podfilePath); +} + +/** + * Add Clix SDK to extension target in Podfile. + */ +export async function addClixToExtensionTarget( + options: PodfileModifierOptions, +): Promise { + const result: PodfileModificationResult = { + success: false, + modified: false, + podfileExists: false, + targetAdded: false, + }; + + const podfilePath = path.join(options.iosDir, 'Podfile'); + + if (!fs.existsSync(podfilePath)) { + // Not an error - just skip for non-CocoaPods projects + result.success = true; + return result; + } + + result.podfileExists = true; + + try { + let content = fs.readFileSync(podfilePath, 'utf-8'); + + // Check if target already exists + if (hasExtensionTarget(options.iosDir, options.extensionName)) { + result.success = true; + return result; + } + + // Generate target block + const targetBlock = generateTargetBlock(options.extensionName, options.clixPodSpec); + + // Find insertion point + // Strategy: Insert before post_install hook, or at the end of the file + const postInstallMatch = content.match(/^post_install\s+do/m); + + if (postInstallMatch && postInstallMatch.index !== undefined) { + // Insert before post_install + content = + content.slice(0, postInstallMatch.index) + + targetBlock + + '\n\n' + + content.slice(postInstallMatch.index); + } else { + // Append at the end + content = `${content.trimEnd()}\n\n${targetBlock}\n`; + } + + fs.writeFileSync(podfilePath, content); + + result.success = true; + result.modified = true; + result.targetAdded = true; + } catch (error) { + result.error = error instanceof Error ? error.message : String(error); + } + + return result; +} + +/** + * Generate Podfile target block for extension. + */ +function generateTargetBlock(extensionName: string, clixPodSpec?: string): string { + const podSpec = clixPodSpec || 'Clix'; + return `target '${extensionName}' do + pod '${podSpec}' +end`; +} + +/** + * Escape special regex characters in a string. + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Get all target names from Podfile. + */ +export function getPodfileTargets(iosDir: string): string[] { + const podfilePath = path.join(iosDir, 'Podfile'); + if (!fs.existsSync(podfilePath)) return []; + + try { + const content = fs.readFileSync(podfilePath, 'utf-8'); + const targetRegex = /target\s+['"]([^'"]+)['"]\s+do/gi; + const matches = content.matchAll(targetRegex); + return Array.from(matches, (m) => m[1]); + } catch { + return []; + } +} diff --git a/src/lib/ios/project-analyzer.ts b/src/lib/ios/project-analyzer.ts index f5fbc37..e48defc 100644 --- a/src/lib/ios/project-analyzer.ts +++ b/src/lib/ios/project-analyzer.ts @@ -14,6 +14,8 @@ export interface IosProjectInfo { targets: string[]; /** Existing entitlements file paths */ entitlementsFiles: string[]; + /** Apple Team ID from project settings (DEVELOPMENT_TEAM) */ + teamId?: string; } export interface ProjectAnalysisResult { @@ -65,6 +67,9 @@ export async function analyzeIosProject(cwd: string): Promise { + const context = getRequestContext(userAuthCtx); + const keys = await Keys.getKeysAsync(context); + return keys.map((key) => ({ + id: key.id, + name: key.name, + canDownload: key.canDownload, + canRevoke: key.canRevoke, + })); +} + +/** + * Create a new push key on Apple servers. + * + * **Requires USER authentication (Apple ID/Password), not API Key.** + * + * @param userAuthCtx User authentication context + * @param name Optional custom name for the key (defaults to "Clix Push Notifications Key {timestamp}") + */ +export async function createPushKeyAsync( + userAuthCtx: UserAuthContext, + name?: string, +): Promise { + const keyName = name || `Clix Push Notifications Key ${formatDateForKeyName()}`; + + try { + const context = getRequestContext(userAuthCtx); + + // Create the key with APNS capability + const key = await Keys.createKeyAsync(context, { + name: keyName, + isApns: true, + }); + + // Download the key content (.p8) + let apnsKeyP8: string; + try { + apnsKeyP8 = await Keys.downloadKeyAsync(context, { id: key.id }); + } catch (downloadErr) { + // Best-effort cleanup to avoid leaking a limited key slot (max 2 APNS keys per account) + try { + await Keys.revokeKeyAsync(context, { id: key.id }); + } catch { + // Swallow revoke failure; original error is more relevant + } + throw downloadErr; + } + + return { + apnsKeyId: key.id, + apnsKeyP8, + teamId: userAuthCtx.team.id, + teamName: userAuthCtx.team.name, + }; + } catch (err: unknown) { + const error = err as { rawDump?: { resultString?: string } }; + const resultString = error.rawDump?.resultString; + + if ( + err instanceof MaxKeysCreatedError || + (typeof resultString === 'string' && resultString.includes('maximum allowed number of Keys')) + ) { + throw new Error(APPLE_KEYS_TOO_MANY_ERROR); + } + throw err; + } +} + +/** + * Revoke existing push keys on Apple servers. + * + * **Requires USER authentication (Apple ID/Password), not API Key.** + * + * @param userAuthCtx User authentication context + * @param ids Key IDs to revoke + */ +export async function revokePushKeysAsync( + userAuthCtx: UserAuthContext, + ids: string[], +): Promise { + const context = getRequestContext(userAuthCtx); + await Promise.all(ids.map((id) => Keys.revokeKeyAsync(context, { id }))); +} + +/** + * Download an existing push key from Apple servers. + * + * **Requires USER authentication (Apple ID/Password), not API Key.** + * **Note: Keys can only be downloaded once. If canDownload is false, this will fail.** + * + * @param userAuthCtx User authentication context + * @param keyId The key ID to download + */ +export async function downloadPushKeyAsync( + userAuthCtx: UserAuthContext, + keyId: string, +): Promise { + const context = getRequestContext(userAuthCtx); + const apnsKeyP8 = await Keys.downloadKeyAsync(context, { id: keyId }); + + return { + apnsKeyId: keyId, + apnsKeyP8, + teamId: userAuthCtx.team.id, + teamName: userAuthCtx.team.name, + }; +} + +/** + * Check if a push key is available for APNS. + */ +export function isPushKeyValid(key: PushKey): boolean { + return !!( + key.apnsKeyId && + key.apnsKeyP8 && + key.apnsKeyP8.includes('-----BEGIN PRIVATE KEY-----') && + key.teamId + ); +} diff --git a/src/lib/push/__tests__/p8-validator.test.ts b/src/lib/push/__tests__/p8-validator.test.ts new file mode 100644 index 0000000..8d48548 --- /dev/null +++ b/src/lib/push/__tests__/p8-validator.test.ts @@ -0,0 +1,163 @@ +/** + * Tests for P8 file validation utilities. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + extractKeyIdFromFilename, + getKeyIdError, + getTeamIdError, + validateKeyId, + validateP8File, + validateTeamId, +} from '../p8-validator'; + +describe('p8-validator', () => { + let tempDir: string; + + beforeAll(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'p8-test-')); + }); + + afterAll(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('validateP8File', () => { + test('should validate a valid P8 file', () => { + const validP8Content = `-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg... +-----END PRIVATE KEY-----`; + const filePath = path.join(tempDir, 'AuthKey_ABCD123456.p8'); + fs.writeFileSync(filePath, validP8Content); + + const result = validateP8File(filePath); + expect(result.valid).toBe(true); + expect(result.content).toBe(validP8Content); + expect(result.suggestedKeyId).toBe('ABCD123456'); + }); + + test('should return error for non-existent file', () => { + const result = validateP8File('/nonexistent/path/file.p8'); + expect(result.valid).toBe(false); + expect(result.error).toContain('File not found'); + }); + + test('should return error for invalid file format (missing BEGIN)', () => { + const invalidContent = `-----END PRIVATE KEY-----`; + const filePath = path.join(tempDir, 'invalid-begin.p8'); + fs.writeFileSync(filePath, invalidContent); + + const result = validateP8File(filePath); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid P8 file format'); + }); + + test('should return error for invalid file format (missing END)', () => { + const invalidContent = `-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg...`; + const filePath = path.join(tempDir, 'invalid-end.p8'); + fs.writeFileSync(filePath, invalidContent); + + const result = validateP8File(filePath); + expect(result.valid).toBe(false); + expect(result.error).toContain('Private key is incomplete'); + }); + + test('should handle ~ expansion in path', () => { + // This test ensures the ~ is handled, even if it returns an error for the path + const result = validateP8File('~/nonexistent.p8'); + expect(result.valid).toBe(false); + expect(result.error).toContain('File not found'); + }); + }); + + describe('extractKeyIdFromFilename', () => { + test('should extract Key ID from AuthKey_XXXXXXXXXX.p8 format', () => { + expect(extractKeyIdFromFilename('AuthKey_ABCD123456.p8')).toBe('ABCD123456'); + }); + + test('should handle lowercase extension', () => { + expect(extractKeyIdFromFilename('AuthKey_ABCD123456.P8')).toBe('ABCD123456'); + }); + + test('should return null for non-matching filename', () => { + expect(extractKeyIdFromFilename('my-key.p8')).toBeNull(); + }); + + test('should return null for wrong Key ID length', () => { + expect(extractKeyIdFromFilename('AuthKey_ABC.p8')).toBeNull(); + }); + + test('should return null for non-p8 file', () => { + expect(extractKeyIdFromFilename('AuthKey_ABCD123456.txt')).toBeNull(); + }); + }); + + describe('validateKeyId', () => { + test('should validate 10 character alphanumeric Key ID', () => { + expect(validateKeyId('ABCD123456')).toBe(true); + }); + + test('should reject Key ID shorter than 10 characters', () => { + expect(validateKeyId('ABC123')).toBe(false); + }); + + test('should reject Key ID longer than 10 characters', () => { + expect(validateKeyId('ABCD12345678')).toBe(false); + }); + + test('should reject Key ID with special characters', () => { + expect(validateKeyId('ABCD!@#$56')).toBe(false); + }); + + test('should be case-insensitive', () => { + expect(validateKeyId('abcd123456')).toBe(true); + }); + }); + + describe('validateTeamId', () => { + test('should validate 10 character alphanumeric Team ID', () => { + expect(validateTeamId('TEAMID1234')).toBe(true); + }); + + test('should reject Team ID shorter than 10 characters', () => { + expect(validateTeamId('TEAM')).toBe(false); + }); + + test('should reject Team ID longer than 10 characters', () => { + expect(validateTeamId('TEAMID123456')).toBe(false); + }); + }); + + describe('getKeyIdError', () => { + test('should return error for empty Key ID', () => { + expect(getKeyIdError('')).toBe('Key ID is required'); + }); + + test('should return error for invalid format', () => { + expect(getKeyIdError('ABC')).toBe('Key ID must be 10 alphanumeric characters'); + }); + + test('should return null for valid Key ID', () => { + expect(getKeyIdError('ABCD123456')).toBeNull(); + }); + }); + + describe('getTeamIdError', () => { + test('should return error for empty Team ID', () => { + expect(getTeamIdError('')).toBe('Team ID is required'); + }); + + test('should return error for invalid format', () => { + expect(getTeamIdError('TEAM')).toBe('Team ID must be 10 alphanumeric characters'); + }); + + test('should return null for valid Team ID', () => { + expect(getTeamIdError('TEAMID1234')).toBeNull(); + }); + }); +}); diff --git a/src/lib/push/constants.ts b/src/lib/push/constants.ts new file mode 100644 index 0000000..ddbbb9a --- /dev/null +++ b/src/lib/push/constants.ts @@ -0,0 +1,47 @@ +/** + * Constants for iOS Push Notification setup. + * + * @module push/constants + */ + +/** + * URLs for push notification setup. + */ +export const PUSH_SETUP_URLS = { + /** Apple Developer Portal - Keys list */ + appleKeysPortal: 'https://developer.apple.com/account/resources/authkeys/list', + /** Apple Developer Portal - Create new key */ + appleCreateKey: 'https://developer.apple.com/account/resources/authkeys/add', + /** Apple Developer Portal - Team ID (Membership details) */ + appleTeamId: 'https://developer.apple.com/account#MembershipDetailsCard', + /** Firebase Console - Cloud Messaging settings */ + firebaseConsole: (projectId: string) => + `https://console.firebase.google.com/project/${projectId}/settings/cloudmessaging`, + /** Firebase Console - Project settings (fallback if no project ID) */ + firebaseConsoleGeneric: 'https://console.firebase.google.com/', +} as const; + +/** + * APNS Key creation steps for Apple Developer Portal. + */ +export const APNS_KEY_CREATION_STEPS = [ + 'Click "+" to create a new key', + 'Enter a key name (e.g., "Push Notifications Key")', + 'Check "Apple Push Notifications service (APNs)"', + 'Click "Continue" then "Register"', + 'Download the .p8 file (you can only download once!)', + 'Note the Key ID shown on the page', + 'Copy the .p8 file to this project directory', +] as const; + +/** + * Firebase upload steps. + */ +export const FIREBASE_UPLOAD_STEPS = [ + 'Go to "iOS app configuration" section', + 'Click "Upload" under APNs Authentication Key', + 'Select your .p8 file', + 'Enter the Key ID', + 'Enter your Team ID', + 'Click "Upload"', +] as const; diff --git a/src/lib/push/index.ts b/src/lib/push/index.ts new file mode 100644 index 0000000..040d8ae --- /dev/null +++ b/src/lib/push/index.ts @@ -0,0 +1,9 @@ +/** + * iOS Push Notification setup module. + * + * @module push + */ + +export * from './constants'; +export * from './p8-validator'; +export * from './types'; diff --git a/src/lib/push/p8-validator.ts b/src/lib/push/p8-validator.ts new file mode 100644 index 0000000..8b83587 --- /dev/null +++ b/src/lib/push/p8-validator.ts @@ -0,0 +1,153 @@ +/** + * P8 file validation utilities for APNS keys. + * + * @module push/p8-validator + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * P8 file validation result. + */ +export interface P8ValidationResult { + valid: boolean; + content?: string; + suggestedKeyId?: string; + error?: string; +} + +/** + * Validate a P8 file. + * + * @param filePath - Path to the P8 file + * @returns Validation result + */ +export function validateP8File(filePath: string): P8ValidationResult { + // Expand ~ to home directory + const expandedPath = filePath.startsWith('~') + ? path.join(process.env.HOME || '', filePath.slice(1)) + : filePath; + + // Check file exists + if (!fs.existsSync(expandedPath)) { + return { + valid: false, + error: `File not found: ${filePath}`, + }; + } + + // Read file content + let content: string; + try { + content = fs.readFileSync(expandedPath, 'utf-8'); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + // Check for macOS permission error + if (errorMessage.includes('EPERM') || errorMessage.includes('operation not permitted')) { + return { + valid: false, + error: + `Permission denied: Cannot read file from this location.\n` + + ` Try copying the file to your project directory:\n` + + ` cp "${expandedPath}" ./\n` + + ` Then enter: ./${path.basename(expandedPath)}`, + }; + } + return { + valid: false, + error: `Failed to read file: ${errorMessage}`, + }; + } + + // Validate content format + if (!content.includes('-----BEGIN PRIVATE KEY-----')) { + return { + valid: false, + error: 'Invalid P8 file format. File should contain a private key.', + }; + } + + if (!content.includes('-----END PRIVATE KEY-----')) { + return { + valid: false, + error: 'Invalid P8 file format. Private key is incomplete.', + }; + } + + // Try to extract Key ID from filename + const filename = path.basename(expandedPath); + const suggestedKeyId = extractKeyIdFromFilename(filename); + + return { + valid: true, + content, + suggestedKeyId: suggestedKeyId || undefined, + }; +} + +/** + * Extract Key ID from Apple's default filename format. + * Apple names downloaded keys as: AuthKey_XXXXXXXXXX.p8 + * + * @param filename - Filename to extract from + * @returns Key ID or null if not found + */ +export function extractKeyIdFromFilename(filename: string): string | null { + const match = filename.match(/AuthKey_([A-Z0-9]{10})\.p8$/i); + return match ? match[1].toUpperCase() : null; +} + +/** + * Validate Key ID format. + * Key ID should be 10 alphanumeric characters. + * + * @param keyId - Key ID to validate + * @returns True if valid + */ +export function validateKeyId(keyId: string): boolean { + return /^[A-Z0-9]{10}$/i.test(keyId); +} + +/** + * Validate Team ID format. + * Team ID should be 10 alphanumeric characters. + * + * @param teamId - Team ID to validate + * @returns True if valid + */ +export function validateTeamId(teamId: string): boolean { + return /^[A-Z0-9]{10}$/i.test(teamId); +} + +/** + * Get validation error message for Key ID. + * + * @param keyId - Key ID to validate + * @returns Error message or null if valid + */ +export function getKeyIdError(keyId: string): string | null { + if (!keyId) { + return 'Key ID is required'; + } + if (!validateKeyId(keyId)) { + return 'Key ID must be 10 alphanumeric characters'; + } + return null; +} + +/** + * Get validation error message for Team ID. + * + * @param teamId - Team ID to validate + * @returns Error message or null if valid + */ +export function getTeamIdError(teamId: string): string | null { + if (!teamId) { + return 'Team ID is required'; + } + if (!validateTeamId(teamId)) { + return 'Team ID must be 10 alphanumeric characters'; + } + return null; +} diff --git a/src/lib/push/types.ts b/src/lib/push/types.ts new file mode 100644 index 0000000..a90230a --- /dev/null +++ b/src/lib/push/types.ts @@ -0,0 +1,56 @@ +/** + * Types for iOS Push Notification setup. + * + * @module push/types + */ + +/** + * APNS Push Key information. + * Based on EAS CLI's PushKey type. + */ +export interface ApnsPushKey { + /** P8 file content */ + apnsKeyP8: string; + /** 10-character Key ID (e.g., ABCD123456) */ + apnsKeyId: string; + /** Apple Team ID */ + teamId: string; +} + +/** + * Push setup wizard context. + */ +export interface PushSetupContext { + // Project info + bundleId: string | null; + firebaseProjectId: string | null; + // APNS Key info + pushKey: ApnsPushKey | null; + p8FilePath: string | null; +} + +/** + * Push setup wizard phases. + */ +export type PushSetupPhase = + | 'detecting' // Analyzing project + | 'status' // Showing current status + | 'key_source' // Asking if user has existing key + | 'apple_login' // Apple account login for auto key creation + | 'apple_guide' // Apple Portal guide (manual) + | 'p8_input' // P8 + Key ID + Team ID input + | 'validation' // Validating inputs + | 'firebase_auth' // Authenticating with Firebase + | 'firebase_projects' // Selecting Firebase project + | 'firebase_upload' // Firebase upload guide + | 'complete' // Setup complete + | 'error'; // Error state + +/** + * Push setup result. + */ +export interface PushSetupResult { + success: boolean; + message: string; + context?: PushSetupContext; +} diff --git a/src/lib/services/bash-service.ts b/src/lib/services/bash-service.ts index 0c844a0..3f9ab8e 100644 --- a/src/lib/services/bash-service.ts +++ b/src/lib/services/bash-service.ts @@ -97,39 +97,34 @@ export async function executeBash( }); }; - // Handle stdout (use Buffer.length for byte-accurate counting) - child.stdout?.on('data', (data: Buffer) => { + // Create data handler for stdout/stderr with byte-accurate truncation + const createDataHandler = (appendTo: 'stdout' | 'stderr') => (data: Buffer) => { const chunkSize = data.length; totalOutputSize += chunkSize; if (totalOutputSize <= maxOutput) { - stdout += data.toString(); - } else if (!truncated) { - // Truncate at byte boundary - const remaining = maxOutput - (totalOutputSize - chunkSize); - if (remaining > 0) { - stdout += data.subarray(0, remaining).toString(); + if (appendTo === 'stdout') { + stdout += data.toString(); + } else { + stderr += data.toString(); } - truncated = true; - } - }); - - // Handle stderr (use Buffer.length for byte-accurate counting) - child.stderr?.on('data', (data: Buffer) => { - const chunkSize = data.length; - totalOutputSize += chunkSize; - - if (totalOutputSize <= maxOutput) { - stderr += data.toString(); } else if (!truncated) { // Truncate at byte boundary const remaining = maxOutput - (totalOutputSize - chunkSize); if (remaining > 0) { - stderr += data.subarray(0, remaining).toString(); + const truncatedData = data.subarray(0, remaining).toString(); + if (appendTo === 'stdout') { + stdout += truncatedData; + } else { + stderr += truncatedData; + } } truncated = true; } - }); + }; + + child.stdout?.on('data', createDataHandler('stdout')); + child.stderr?.on('data', createDataHandler('stderr')); // Handle process exit child.on('close', (code) => { diff --git a/src/lib/services/firebase/detector.ts b/src/lib/services/firebase/detector.ts index 251925e..c175ff1 100644 --- a/src/lib/services/firebase/detector.ts +++ b/src/lib/services/firebase/detector.ts @@ -181,7 +181,7 @@ export async function detectPlatform(projectPath: string): Promise { /** * Get expected credential file paths based on platform. */ -export function getExpectedPaths(platform: Platform, _projectPath: string): ExpectedPaths { +export function getExpectedPaths(platform: Platform): ExpectedPaths { const androidPaths: string[] = []; const iosPaths: string[] = []; @@ -641,7 +641,7 @@ function generateIssues( */ export async function detectFirebaseConfig(projectPath: string): Promise { const platform = await detectPlatform(projectPath); - const expectedPaths = getExpectedPaths(platform, projectPath); + const expectedPaths = getExpectedPaths(platform); const android = await detectAndroidCredential(projectPath, expectedPaths.android); const ios = await detectIosCredential(projectPath, expectedPaths.ios); diff --git a/src/lib/services/firebase/downloader.ts b/src/lib/services/firebase/downloader.ts index f69fce1..8a34a4a 100644 --- a/src/lib/services/firebase/downloader.ts +++ b/src/lib/services/firebase/downloader.ts @@ -228,7 +228,7 @@ export class FirebaseDownloader { projectPath: string, ): Promise<{ android: string | null; ios: string | null; platform: Platform }> { const platform = await detectPlatform(projectPath); - const paths = getExpectedPaths(platform, projectPath); + const paths = getExpectedPaths(platform); // For unknown platform, assume both platforms are needed const needsAndroid = platformNeedsAndroid(platform) || platform === 'unknown'; diff --git a/src/lib/services/firebase/firebase-service.ts b/src/lib/services/firebase/firebase-service.ts index 15f92bd..3e87b3c 100644 --- a/src/lib/services/firebase/firebase-service.ts +++ b/src/lib/services/firebase/firebase-service.ts @@ -152,7 +152,7 @@ export class FirebaseService { */ async getExpectedPath(platform: 'android' | 'ios'): Promise { const detectedPlatform = await detectPlatform(this.projectPath); - const paths = getExpectedPaths(detectedPlatform, this.projectPath); + const paths = getExpectedPaths(detectedPlatform); const platformPaths = platform === 'android' ? paths.android : paths.ios; return ( platformPaths[0] || diff --git a/src/lib/services/firebase/index.ts b/src/lib/services/firebase/index.ts index 59b8816..20beb15 100644 --- a/src/lib/services/firebase/index.ts +++ b/src/lib/services/firebase/index.ts @@ -30,9 +30,7 @@ export * from './types'; export { extractProjectId, extractProjectIdFromPlist, - validateBundleIdMatch, validateGoogleServiceInfoPlist, validateGoogleServicesJson, - validatePackageNameMatch, validateProjectIdMatch, } from './validator'; diff --git a/src/lib/services/firebase/types.ts b/src/lib/services/firebase/types.ts index 1f7e847..75d1520 100644 --- a/src/lib/services/firebase/types.ts +++ b/src/lib/services/firebase/types.ts @@ -78,6 +78,8 @@ export interface GoogleServiceInfoPlist { IS_APPINVITE_ENABLED?: boolean; IS_GCM_ENABLED?: boolean; IS_SIGNIN_ENABLED?: boolean; + /** Apple Team ID (optional, may be present in some Firebase configs) */ + TEAM_ID?: string; } /** diff --git a/src/lib/services/firebase/validator.ts b/src/lib/services/firebase/validator.ts index 86f284e..70e9596 100644 --- a/src/lib/services/firebase/validator.ts +++ b/src/lib/services/firebase/validator.ts @@ -164,64 +164,6 @@ export function validateGoogleServiceInfoPlist(content: unknown): ValidationResu }; } -/** - * Validate that the package name in google-services.json matches the expected package. - * - * @param googleServices - Validated google-services.json content - * @param expectedPackageName - Expected Android package name - * @returns Validation result - */ -export function validatePackageNameMatch( - googleServices: GoogleServicesJson, - expectedPackageName: string, -): ValidationResult { - const packageNames = googleServices.client.map( - (client) => client.client_info.android_client_info.package_name, - ); - - if (packageNames.includes(expectedPackageName)) { - return { valid: true, errors: [] }; - } - - return { - valid: false, - errors: [ - { - path: 'client.client_info.android_client_info.package_name', - message: `Package name mismatch. Expected "${expectedPackageName}", found: ${packageNames.join(', ')}`, - code: 'PACKAGE_MISMATCH', - }, - ], - }; -} - -/** - * Validate that the bundle ID in GoogleService-Info.plist matches the expected bundle ID. - * - * @param serviceInfo - Validated GoogleService-Info.plist content - * @param expectedBundleId - Expected iOS bundle ID - * @returns Validation result - */ -export function validateBundleIdMatch( - serviceInfo: GoogleServiceInfoPlist, - expectedBundleId: string, -): ValidationResult { - if (serviceInfo.BUNDLE_ID === expectedBundleId) { - return { valid: true, errors: [] }; - } - - return { - valid: false, - errors: [ - { - path: 'BUNDLE_ID', - message: `Bundle ID mismatch. Expected "${expectedBundleId}", found: "${serviceInfo.BUNDLE_ID}"`, - code: 'BUNDLE_MISMATCH', - }, - ], - }; -} - /** * Extract project ID from google-services.json. * diff --git a/src/lib/skills.ts b/src/lib/skills.ts index d6a6c25..d39a0c0 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -58,12 +58,7 @@ const LOCAL_SKILLS: SkillInfo[] = [ description: 'Interactive debugging assistant', isLocal: true, }, - { - type: 'ios-setup', - name: 'iOS Setup', - description: 'Configure iOS capabilities for push notifications and app groups', - isLocal: true, - }, + // NOTE: ios-setup is now a LocalJSXCommand in registry.ts, not a skill ]; /** @@ -321,8 +316,6 @@ async function getLocalSkillPrompt(skillType: SkillType, options?: SkillOptions) projectPath: options?.projectPath ?? process.cwd(), oneShot: options?.oneShot, }); - case 'ios-setup': - return getIosSetupPrompt(options); default: throw new Error(`Unknown local skill: ${skillType}`); } @@ -350,28 +343,6 @@ function getDoctorPrompt(options?: SkillOptions): string { return prompt; } -/** - * Get prompt for the ios-setup skill. - * Configures iOS capabilities for push notifications and app groups. - * Prompt is loaded from src/lib/skills/ios-setup/SKILL.md - */ -function getIosSetupPrompt(options?: SkillOptions): string { - const projectPath = options?.projectPath ?? process.cwd(); - - let prompt = `Project path: ${projectPath}\n\n`; - - // Add one-shot instruction for autonomous execution - if (options?.oneShot) { - prompt += `${ONE_SHOT_INSTRUCTION}\n\n`; - } - - // Load the ios-setup prompt from external file - const iosSetupPrompt = readLocalSkillPrompt('ios-setup'); - prompt += iosSetupPrompt; - - return prompt; -} - export async function* executeSkill( skillType: SkillType, executor: AgentExecutor, diff --git a/src/lib/skills/install/SKILL.md b/src/lib/skills/install/SKILL.md index 810806a..0d718ad 100644 --- a/src/lib/skills/install/SKILL.md +++ b/src/lib/skills/install/SKILL.md @@ -177,11 +177,19 @@ For rich push notifications (images, buttons), create a Notification Service Ext import Clix class NotificationService: ClixNotificationServiceExtension { + override init() { + super.init() + register(projectId: "YOUR_PROJECT_ID") + } + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - register(projectId: "YOUR_PROJECT_ID") super.didReceive(request, withContentHandler: contentHandler) } + + override func serviceExtensionTimeWillExpire() { + super.serviceExtensionTimeWillExpire() + } } ``` 4. Add App Groups capability to both main app and extension (same group ID: `group.clix.{BUNDLE_ID}`) diff --git a/src/lib/skills/ios-setup/SKILL.md b/src/lib/skills/ios-setup/SKILL.md deleted file mode 100644 index f42786f..0000000 --- a/src/lib/skills/ios-setup/SKILL.md +++ /dev/null @@ -1,370 +0,0 @@ -# iOS Capabilities Configuration - -You are an AI agent that configures iOS capabilities required for the Clix SDK. - -## Core Directive - -**GUIDE USERS** through iOS capability configuration for push notifications and data sharing. For file modifications, use Edit/Write tools when possible. For Xcode-only steps, provide clear step-by-step instructions. - -## Required Capabilities for Clix iOS SDK - -### 1. Push Notifications - -- **Purpose:** Enable APNs (Apple Push Notification service) communication -- **Entitlement Key:** `aps-environment` -- **Values:** `development` (debug builds) or `production` (release builds) -- **Xcode Capability:** Push Notifications - -### 2. App Groups - -- **Purpose:** Share data between main app and Notification Service Extension using MMKV -- **Entitlement Key:** `com.apple.security.application-groups` -- **ID Format:** `group.clix.{BUNDLE_ID}` (e.g., `group.clix.com.example.myapp`) -- **Xcode Capability:** App Groups -- **Important:** Must be configured for BOTH main app AND Notification Service Extension targets - -## Workflow - -### Phase 1: Project Analysis - -1. **Detect iOS Project** - - Search for `*.xcodeproj` or `*.xcworkspace` files - - Identify the main app target name - - Check if this is a native iOS, React Native, or Flutter project - -2. **Find Bundle Identifier** - - Check `Info.plist` for `CFBundleIdentifier` - - Or parse `project.pbxproj` for `PRODUCT_BUNDLE_IDENTIFIER` - -3. **Check Current Capabilities Status** - - Search for existing `*.entitlements` files - - Check for `aps-environment` entitlement (Push Notifications configured) - - Check for `com.apple.security.application-groups` (App Groups configured) - - Check `project.pbxproj` for `SystemCapabilities` section - -4. **Report Current State** - Output findings: - ```text - Project: {project_name} - Bundle ID: {bundle_id} - Push Notifications: {configured/not configured} - App Groups: {configured/not configured} - Existing entitlements files: {list} - ``` - -### Phase 2: Xcode Configuration (Manual Steps) - -Provide clear instructions for adding capabilities in Xcode. These steps CANNOT be automated and require user action in Xcode IDE. - -**Add Push Notifications:** -```text -1. Open your project in Xcode -2. Select your main app target in the Navigator (left sidebar) -3. Go to the "Signing & Capabilities" tab -4. Click the "+ Capability" button -5. Search for and select "Push Notifications" -6. Xcode will automatically create an entitlements file if one doesn't exist -``` - -**Add Background Modes (Recommended):** -```text -1. In "Signing & Capabilities", click "+ Capability" -2. Select "Background Modes" -3. Enable "Remote notifications" checkbox - - This allows the app to process push notifications in the background -``` - -**Add App Groups:** -```text -1. Click "+ Capability" -2. Select "App Groups" -3. Click the "+" button under App Groups -4. Enter the App Group ID: group.clix.{BUNDLE_ID} - Example: group.clix.com.example.myapp -5. Click OK to create the group - -IMPORTANT: Repeat steps 1-5 for the Notification Service Extension target: -1. Select the extension target (usually named "{AppName}NotificationServiceExtension") -2. Go to "Signing & Capabilities" -3. Add "App Groups" capability -4. Select the SAME App Group ID you created above -``` - -### Phase 3: Entitlements Files - -Create or modify entitlements files. Use Write/Edit tools for these operations. - -**Main App Entitlements** (`{AppName}.entitlements` or `{AppName}/{AppName}.entitlements`): - -```xml - - - - - aps-environment - development - com.apple.security.application-groups - - group.clix.{BUNDLE_ID} - - - -``` - -**Notification Service Extension Entitlements** (`{ExtensionName}/{ExtensionName}.entitlements`): - -```xml - - - - - com.apple.security.application-groups - - group.clix.{BUNDLE_ID} - - - -``` - -**Note:** Replace `{BUNDLE_ID}` with the actual bundle identifier (e.g., `com.example.myapp`). - -### Phase 3.5: Notification Service Extension Setup - -Create a Notification Service Extension for rich push notifications (images, buttons, etc.). - -**Create Extension Target in Xcode:** -```text -1. File > New > Target -2. Select "Notification Service Extension" -3. Name it "{AppName}NotificationServiceExtension" (e.g., "MyAppNotificationServiceExtension") -4. Click "Finish" (Cancel the "Activate scheme" dialog) -5. Note: Use this exact name consistently in Podfile, entitlements path, and SPM setup -``` - -**Implement NotificationService.swift:** - -```swift -import UserNotifications -import Clix - -class NotificationService: ClixNotificationServiceExtension { - override func didReceive( - _ request: UNNotificationRequest, - withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void - ) { - register(projectId: "YOUR_PROJECT_ID") - super.didReceive(request, withContentHandler: contentHandler) - } -} -``` - -**Note:** Replace `YOUR_PROJECT_ID` with your actual Clix project ID from - -**Add Clix SDK to Extension Target:** - -For CocoaPods projects, add to Podfile: -```ruby -target '{AppName}NotificationServiceExtension' do - pod 'Clix' -end -``` -Then run: `cd ios && pod install` - -For SPM projects in Xcode: -1. Select the extension target -2. Go to General > Frameworks, Libraries, and Embedded Content -3. Click + and add the Clix package - -**Configure Build Settings (Xcode 15+):** - -For the extension target: -- Set `ENABLE_USER_SCRIPT_SANDBOXING` to "No" in Build Settings - -For React Native projects with Firebase: -- In Build Phases, move "Embed Foundation Extensions" above "[RNFB] Core Configuration" - -### Phase 4: Apple Developer Portal Configuration - -Guide user through manual portal configuration. These steps CANNOT be automated. - -**Enable Capabilities on App ID:** -```text -1. Go to https://developer.apple.com/account -2. Navigate to "Certificates, Identifiers & Profiles" -3. Select "Identifiers" from the sidebar -4. Find and click your App ID (Bundle ID) -5. Scroll down to "Capabilities" section -6. Enable "Push Notifications" - - You may need to configure certificates (Development/Production) -7. Enable "App Groups" -8. Click "Save" -``` - -**Register App Group ID:** -```text -1. In the sidebar, select "Identifiers" -2. Click the "+" button -3. Select "App Groups" and click "Continue" -4. Enter: - - Description: Clix SDK App Group for {App Name} - - Identifier: group.clix.{BUNDLE_ID} -5. Click "Continue" then "Register" -6. Go back to your App ID and associate the App Group: - - Edit your App ID - - Under "App Groups", click "Configure" - - Select the App Group you just created - - Click "Save" -``` - -**Regenerate Provisioning Profile:** -```text -After enabling capabilities, your provisioning profiles become invalid. - -1. Navigate to "Profiles" in the sidebar -2. Find your Development and/or Distribution profile -3. Click on the profile -4. Click "Edit" or delete and recreate the profile -5. Ensure the updated App ID is selected -6. Download the new profile - -In Xcode: -1. Go to Xcode > Settings (or Preferences) > Accounts -2. Select your Apple ID -3. Click "Download Manual Profiles" - Or: Delete old profiles and let Xcode auto-manage -``` - -### Phase 5: Verification - -After configuration, verify the setup and output a report. - -**Check Entitlements Files:** -- Main app entitlements contains `aps-environment` -- Main app entitlements contains `com.apple.security.application-groups` -- Extension entitlements contains matching App Group ID - -**Check project.pbxproj (if accessible):** -- Look for `SystemCapabilities` dictionary -- Verify `com.apple.Push` is enabled -- Verify `com.apple.ApplicationGroups.iOS` is enabled - -**Output Verification Report:** - -```json -{ - "project": "{project_name}", - "bundleId": "{bundle_id}", - "capabilities": { - "pushNotifications": { - "entitlementFile": true, - "environment": "development", - "xcodeCapability": "verify manually in Xcode", - "developerPortal": "verify manually at developer.apple.com" - }, - "appGroups": { - "groupId": "group.clix.{bundle_id}", - "mainAppEntitlement": true, - "extensionEntitlement": true, - "developerPortal": "verify manually at developer.apple.com" - } - }, - "nextSteps": [ - "Verify capabilities are added in Xcode Signing & Capabilities", - "Confirm App Group ID is registered in Apple Developer Portal", - "Regenerate provisioning profiles if needed", - "Build and run to verify no signing errors" - ] -} -``` - -## Common Issues and Solutions - -### Missing Entitlements File - -**Symptom:** No `.entitlements` file exists in the project. - -**Solution:** -- Xcode automatically creates one when you add your first capability -- Or create manually and link in Build Settings: - 1. Create `{AppName}.entitlements` file - 2. In Xcode, select target > Build Settings - 3. Search for "Code Signing Entitlements" - 4. Set the path to your entitlements file - -### App Group ID Mismatch - -**Symptom:** Data not shared between app and extension. - -**Solution:** -- Verify the App Group ID is EXACTLY the same in both targets -- Format must be: `group.clix.{BUNDLE_ID}` -- Check both entitlements files have identical values - -### Provisioning Profile Invalid - -**Symptom:** "Provisioning profile doesn't include the X capability" error. - -**Solution:** -1. Go to Apple Developer Portal -2. Delete the old provisioning profile -3. Create a new one with the updated App ID -4. Download and install in Xcode -5. Or enable "Automatically manage signing" in Xcode - -### Push Notifications Not Working - -**Symptom:** Push notifications not received. - -**Checklist:** -- [ ] Push Notifications capability added in Xcode -- [ ] `aps-environment` in entitlements (check value matches build config) -- [ ] Push Notifications enabled on App ID in Developer Portal -- [ ] APNs certificate or key configured in Clix console -- [ ] Provisioning profile regenerated after enabling capability -- [ ] Physical device used (simulator doesn't receive push) - -### App Group Data Not Shared - -**Symptom:** MMKV data not accessible from extension. - -**Checklist:** -- [ ] App Groups capability added to BOTH main app AND extension -- [ ] Same App Group ID in both targets' entitlements -- [ ] App Group ID registered in Developer Portal -- [ ] App Group associated with App ID in Developer Portal - -## Automation Rules - -**CAN automate (use Write/Edit tools):** -- Creating entitlements files -- Modifying existing entitlements files -- Reading project configuration files -- Detecting current capabilities status - -**CANNOT automate (provide instructions only):** -- Adding capabilities in Xcode UI (Signing & Capabilities tab) -- Enabling capabilities in Apple Developer Portal -- Registering App Group IDs in Developer Portal -- Generating/downloading provisioning profiles -- Associating App Groups with App IDs - -For manual steps, provide clear instructions and proceed without waiting for confirmation. - -## Output Format - -After completing the workflow, summarize: - -1. **Files Created/Modified** - - List all entitlements files with full paths - - Show what was added or changed - -2. **Manual Steps Required** - - Xcode capability additions - - Developer Portal configurations - -3. **Verification Checklist** - - JSON report with status of each component - - Next steps for user to complete - -4. **Troubleshooting Tips** - - Common issues to watch for based on project state diff --git a/src/types/keychain.d.ts b/src/types/keychain.d.ts new file mode 100644 index 0000000..51f8a04 --- /dev/null +++ b/src/types/keychain.d.ts @@ -0,0 +1,25 @@ +/** + * Type declarations for the 'keychain' npm package. + * @see https://github.com/nicksrandall/keychain + */ + +declare module 'keychain' { + interface KeychainOptions { + account: string; + service: string; + password?: string; + type?: 'generic' | 'internet'; + } + + type Callback = (error: Error, password?: string) => void; + + function getPassword(options: KeychainOptions, callback: Callback): void; + function setPassword(options: KeychainOptions, callback: (error: Error) => void): void; + function deletePassword(options: KeychainOptions, callback: (error: Error) => void): void; + + export = { + getPassword, + setPassword, + deletePassword, + }; +} diff --git a/src/types/xcode.d.ts b/src/types/xcode.d.ts new file mode 100644 index 0000000..4ec7b56 --- /dev/null +++ b/src/types/xcode.d.ts @@ -0,0 +1,59 @@ +/** + * Type declarations for the 'xcode' npm package. + * @see https://github.com/nicksrandall/xcode + */ + +declare module 'xcode' { + interface PBXTarget { + uuid: string; + pbxNativeTarget: { + name: string; + productType: string; + }; + } + + interface PBXProject { + parseSync(): void; + writeSync(): string; + + // Target operations + addTarget( + name: string, + productType: string, + subfolder: string, + bundleId: string, + ): PBXTarget | null; + getFirstTarget(): PBXTarget | null; + pbxNativeTargetSection(): Record | null; + addTargetDependency(target: string, dependencies: string[]): void; + + // File operations + addSourceFile(path: string, options: { target?: string }, group?: string): void; + addResourceFile(path: string, options?: { target?: string }): void; + + // Group operations + findPBXGroupKey(criteria: { name?: string; path?: string }): string | null; + addPbxGroup(files: string[], name: string, path: string): { uuid: string }; + + // Build settings + updateBuildProperty( + key: string, + value: string, + buildConfig: string | null, + targetName?: string, + ): void; + + // Build phases + addBuildPhase( + files: string[], + buildPhaseType: string, + comment: string, + target: string, + optionAlias?: string, + ): void; + } + + function project(projectPath: string): PBXProject; + + export = { project }; +} diff --git a/src/ui/IosSetupUI.tsx b/src/ui/IosSetupUI.tsx index 681d111..a1f891a 100644 --- a/src/ui/IosSetupUI.tsx +++ b/src/ui/IosSetupUI.tsx @@ -1,6 +1,6 @@ -import { Box, Text, useApp } from 'ink'; +import { Box, Text, useApp, useInput } from 'ink'; import type React from 'react'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { type AgentContext, analyzeIosProject, @@ -17,6 +17,8 @@ import { syncCapabilities, updateEntitlementsForClix, } from '@/lib/ios'; +import { FirebaseService } from '@/lib/services/firebase'; +import type { GoogleServiceInfoPlist, GoogleServicesJson } from '@/lib/services/firebase/types'; import { Header } from '@/ui/components/Header'; import { StatusMessage } from '@/ui/components/StatusMessage'; @@ -51,6 +53,12 @@ export interface IosSetupResult { error?: string; /** Context for agent to complete remaining tasks */ agentContext?: AgentContext; + /** Bundle ID for push setup integration */ + bundleId?: string; + /** Firebase Project ID for push setup integration */ + firebaseProjectId?: string | null; + /** Apple Team ID for push setup integration (from GoogleService-Info.plist) */ + teamId?: string | null; } interface IosSetupUIProps { @@ -204,6 +212,27 @@ async function runSetup( entitlementsResult.iosDir, ); + // Detect Firebase project ID and Team ID for push setup integration + result.bundleId = bundleId; + // Use Team ID from Xcode project settings (DEVELOPMENT_TEAM) if available + result.teamId = project.teamId || null; + try { + const firebaseService = new FirebaseService(process.cwd()); + const firebaseDetection = await firebaseService.detect(); + const iosContent = firebaseDetection.ios?.content as GoogleServiceInfoPlist | undefined; + const androidContent = firebaseDetection.android?.content as GoogleServicesJson | undefined; + result.firebaseProjectId = + iosContent?.PROJECT_ID || androidContent?.project_info?.project_id || null; + // Override Team ID from Firebase config if available (more likely to be correct) + if (iosContent?.TEAM_ID) { + result.teamId = iosContent.TEAM_ID; + } + } catch { + // Firebase detection is optional, don't fail if it errors + result.firebaseProjectId = null; + // Keep the teamId from project settings if Firebase detection fails + } + result.success = true; setState((s) => ({ ...s, phase: 'complete' })); return result; @@ -218,30 +247,40 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = updatedFiles: [], errorMessage: '', }); + const [result, setResult] = useState(null); const { phase, projectInfo, portalResult, updatedFiles, errorMessage } = state; + // Handle user input for complete/error phases + const handleContinue = useCallback(() => { + if (phase === 'complete' && result) { + onComplete?.(result); + if (!onComplete) exit(); + } else if (phase === 'error') { + onComplete?.({ success: false, entitlementsUpdated: [], error: errorMessage }); + if (!onComplete) exit(); + } + }, [phase, result, errorMessage, onComplete, exit]); + + useInput((_input, key) => { + if ((phase === 'complete' || phase === 'error') && key.return) { + handleContinue(); + } + }); + useEffect(() => { const execute = async () => { try { - const result = await runSetup(options, setState); - setTimeout(() => { - onComplete?.(result); - if (!onComplete) exit(); - }, 1500); + const setupResult = await runSetup(options, setState); + setResult(setupResult); } catch (error) { const message = error instanceof Error ? getAppleApiErrorMessage(error) : String(error); setState((s) => ({ ...s, errorMessage: message, phase: 'error' })); - - setTimeout(() => { - onComplete?.({ success: false, entitlementsUpdated: [], error: message }); - if (!onComplete) exit(); - }, 1500); } }; execute(); - }, [options, onComplete, exit]); + }, [options]); return ( @@ -291,6 +330,9 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = + + Press Enter to continue + )} @@ -386,5 +428,9 @@ const CompletePhase: React.FC<{ {!skipPortal && 4. Regenerate provisioning profiles if needed} + + + Press Enter to continue + ); diff --git a/src/ui/chat/ChatApp.tsx b/src/ui/chat/ChatApp.tsx index f1be67d..5177f89 100644 --- a/src/ui/chat/ChatApp.tsx +++ b/src/ui/chat/ChatApp.tsx @@ -6,6 +6,7 @@ import type { InstallationMethod, UpdateCheckResult } from '../../lib/services/u import { AgentSelector } from '../components/AgentSelector'; import { DebugPrompt } from '../components/DebugPrompt'; import { FirebaseWizard } from '../components/FirebaseWizard'; +import { IosSetupFlow } from '../components/IosSetupFlow'; import { MCPInstallSelector } from '../components/MCPInstallSelector'; import { SessionSelector } from '../components/SessionSelector'; import { TransferSelector } from '../components/TransferSelector'; @@ -228,6 +229,13 @@ const ChatAppInner: React.FC onCancel={overlays.handleFirebaseCancel} /> )} + {overlays.activeOverlay === 'ios-setup' && ( + { + overlays.handleIosSetupComplete(result.message); + }} + /> + )} {overlays.activeOverlay === 'login' && ( { diff --git a/src/ui/chat/hooks/useCommandHandler.ts b/src/ui/chat/hooks/useCommandHandler.ts index 0bef416..9dc4c5a 100644 --- a/src/ui/chat/hooks/useCommandHandler.ts +++ b/src/ui/chat/hooks/useCommandHandler.ts @@ -46,6 +46,7 @@ interface UseCommandHandlerOptions { | 'showMCPInstallSelector' | 'showDebugPrompt' | 'showFirebaseWizard' + | 'showIosSetupOverlay' | 'showLoginOverlay' | 'showLogoutOverlay' | 'showWhoamiOverlay' @@ -89,6 +90,7 @@ export function useCommandHandler(options: UseCommandHandlerOptions) { showMCPInstallSelector, showDebugPrompt, showFirebaseWizard, + showIosSetupOverlay, showLoginOverlay, showLogoutOverlay, showWhoamiOverlay, @@ -165,6 +167,10 @@ export function useCommandHandler(options: UseCommandHandlerOptions) { showFirebaseWizard(); return; + case 'ios-setup': + showIosSetupOverlay(); + return; + case 'login': showLoginOverlay(); return; @@ -211,6 +217,7 @@ export function useCommandHandler(options: UseCommandHandlerOptions) { showMCPInstallSelector, showDebugPrompt, showFirebaseWizard, + showIosSetupOverlay, showLoginOverlay, showLogoutOverlay, showWhoamiOverlay, diff --git a/src/ui/chat/hooks/useOverlays.ts b/src/ui/chat/hooks/useOverlays.ts index 967d932..a8af511 100644 --- a/src/ui/chat/hooks/useOverlays.ts +++ b/src/ui/chat/hooks/useOverlays.ts @@ -24,6 +24,7 @@ export type OverlayType = | 'mcp' | 'debug' | 'firebase' + | 'ios-setup' | 'login' | 'logout' | 'whoami' @@ -115,6 +116,7 @@ export function useOverlays(options: UseOverlaysOptions) { const showDebugPrompt = useCallback(() => setActiveOverlay('debug'), []); const showFirebaseWizard = useCallback(() => setActiveOverlay('firebase'), []); + const showIosSetupOverlay = useCallback(() => setActiveOverlay('ios-setup'), []); const showLoginOverlay = useCallback(() => setActiveOverlay('login'), []); const showLogoutOverlay = useCallback(() => setActiveOverlay('logout'), []); const showWhoamiOverlay = useCallback(() => setActiveOverlay('whoami'), []); @@ -253,6 +255,15 @@ export function useOverlays(options: UseOverlaysOptions) { addSystemMessage('Firebase setup cancelled'); }, [addSystemMessage]); + // iOS setup handlers + const handleIosSetupComplete = useCallback( + (message: string) => { + setActiveOverlay(null); + addSystemMessage(message); + }, + [addSystemMessage], + ); + // Login handlers const handleLoginComplete = useCallback( (message: string) => { @@ -320,6 +331,10 @@ export function useOverlays(options: UseOverlaysOptions) { handleFirebaseComplete, handleFirebaseCancel, + // iOS setup + showIosSetupOverlay, + handleIosSetupComplete, + // Auth overlays showLoginOverlay, handleLoginComplete, diff --git a/src/ui/components/AppleLoginUI.tsx b/src/ui/components/AppleLoginUI.tsx new file mode 100644 index 0000000..072b0dc --- /dev/null +++ b/src/ui/components/AppleLoginUI.tsx @@ -0,0 +1,627 @@ +/** + * Apple Account login UI component for Ink. + * + * Supports: + * - Session restoration (skip password if already authenticated) + * - Existing key selection with download option + * - New key creation + * + * @module ui/components/AppleLoginUI + */ + +import { Auth } from '@expo/apple-utils'; +import { Box, Text, useInput } from 'ink'; +import SelectInput from 'ink-select-input'; +import Spinner from 'ink-spinner'; +import TextInput from 'ink-text-input'; +import type React from 'react'; +import { useCallback, useState } from 'react'; +import { + createPushKeyAsync, + downloadPushKeyAsync, + listPushKeysAsync, + loginWithUserCredentialsAsync, + type PushKey, + type PushKeyStoreInfo, + type UserAuthContext, +} from '@/lib/ios'; + +type AppleLoginPhase = + | 'prompt_login' + | 'apple_id_input' + | 'restoring_session' + | 'password_input' + | 'logging_in' + | 'loading_keys' + | 'key_selection' + | 'creating_key' + | 'downloading_key' + | 'success' + | 'error'; + +interface AppleLoginUIProps { + onSuccess: (result: { authContext: UserAuthContext; pushKey: PushKey }) => void; + onCancel: () => void; + onFallback: () => void; +} + +/** + * Interactive Apple login UI that prompts for credentials and creates push key. + */ +export const AppleLoginUI: React.FC = ({ onSuccess, onCancel, onFallback }) => { + const [phase, setPhase] = useState('prompt_login'); + const [appleId, setAppleId] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [statusMessage, setStatusMessage] = useState(''); + + const [authContext, setAuthContext] = useState(null); + const [existingKeys, setExistingKeys] = useState([]); + + useInput((_input, key) => { + if (key.escape) { + onCancel(); + } + }); + + const handleStartLogin = useCallback(() => { + setPhase('apple_id_input'); + }, []); + + const handleManualSetup = useCallback(() => { + onFallback(); + }, [onFallback]); + + // Try to restore session after Apple ID is submitted + const handleAppleIdSubmit = useCallback(async () => { + if (!appleId.trim()) { + setError('Apple ID is required'); + return; + } + setError(null); + setPhase('restoring_session'); + setStatusMessage('Checking existing session...'); + + // Clear in-memory data + Auth.resetInMemoryData(); + + try { + // Try restoring session without password + const restoredSession = await Auth.tryRestoringAuthStateFromUserCredentialsAsync( + { username: appleId }, + { autoResolveProvider: true }, + ); + + if (restoredSession?.context.teamId) { + // Session restored! Build auth context and load keys + const ctx = await buildAuthContextFromSession(restoredSession, appleId); + setAuthContext(ctx); + setPhase('loading_keys'); + setStatusMessage('Loading existing keys...'); + + const keys = await listPushKeysAsync(ctx); + setExistingKeys(keys); + setPhase('key_selection'); + } else { + // No valid session, need password + setPhase('password_input'); + } + } catch { + // Session restoration failed, need password + setPhase('password_input'); + } + }, [appleId]); + + const handlePasswordSubmit = useCallback(async () => { + if (!password) { + setError('Password is required'); + return; + } + setError(null); + setPhase('logging_in'); + setStatusMessage('Authenticating with Apple...'); + + try { + // Create simple prompt functions for the login + const promptAppleIdFn = async () => appleId; + const promptPasswordFn = async () => password; + const promptConfirmFn = async () => false; + + const ctx = await loginWithUserCredentialsAsync( + promptAppleIdFn, + promptPasswordFn, + promptConfirmFn, + {}, + ); + + setAuthContext(ctx); + setPhase('loading_keys'); + setStatusMessage('Loading existing keys...'); + + const keys = await listPushKeysAsync(ctx); + setExistingKeys(keys); + setPhase('key_selection'); + } catch (err) { + const message = err instanceof Error ? err.message : 'Authentication failed'; + if (message === 'ABORTED') { + onCancel(); + return; + } + setError(message); + setPhase('error'); + } + }, [appleId, password, onCancel]); + + const handleCreateNewKey = useCallback(async () => { + if (!authContext) return; + + setPhase('creating_key'); + setStatusMessage('Creating new APNS key...'); + + try { + const pushKey = await createPushKeyAsync(authContext); + setPhase('success'); + onSuccess({ authContext, pushKey }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create key'; + setError(message); + setPhase('error'); + } + }, [authContext, onSuccess]); + + const handleDownloadKey = useCallback( + async (keyId: string) => { + if (!authContext) return; + + setPhase('downloading_key'); + setStatusMessage('Downloading key...'); + + try { + const pushKey = await downloadPushKeyAsync(authContext, keyId); + setPhase('success'); + onSuccess({ authContext, pushKey }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to download key'; + setError(message); + setPhase('error'); + } + }, + [authContext, onSuccess], + ); + + const handleRetry = useCallback(() => { + setError(null); + setPassword(''); + setPhase('apple_id_input'); + }, []); + + if (phase === 'prompt_login') { + return ( + + + Create APNS Key with Apple Account + + + + You can create an APNS key automatically by logging in with your Apple Developer + account. + + + + • Two-factor authentication may be required + + + + • Your password is only used for authentication and stored locally in Keychain + + + + + {'[L]'} + Log in with Apple Account + + + {'[M]'} + Manual setup (create key in browser) + + + {'[Esc]'} + Cancel + + + + + ); + } + + if (phase === 'apple_id_input') { + return ( + + + Apple Developer Account Login + + {error && ( + + ✗ {error} + + )} + + Apple ID (email): + + + {'> '} + + + + Enter to continue · Esc to cancel + + + ); + } + + if (phase === 'restoring_session') { + return ( + + + Apple Developer Account + + + + + + {statusMessage} + + + ); + } + + if (phase === 'password_input') { + return ( + + + Apple Developer Account Login + + + Apple ID: {appleId} + + {error && ( + + ✗ {error} + + )} + + Password: + + + {'> '} + + + + Your password is stored securely in your local Keychain + + + Enter to continue · Esc to cancel + + + ); + } + + if (phase === 'logging_in' || phase === 'loading_keys') { + return ( + + + Apple Developer Account + + + + + + {statusMessage} + + {phase === 'logging_in' && ( + + If prompted, check your device for 2FA code + + )} + + ); + } + + if (phase === 'key_selection' && authContext) { + return ( + + ); + } + + if (phase === 'creating_key' || phase === 'downloading_key') { + return ( + + + Apple Developer Account + + + + + + {statusMessage} + + + ); + } + + if (phase === 'success' && authContext) { + return ( + + + + ✓ APNS Key Ready + + + + Team: + {authContext.team.name || authContext.team.id} + + + ); + } + + if (phase === 'error') { + return ( + + + + Apple Login Error + + + + ✗ {error} + + + + {'[R]'} + Retry login + + + {'[M]'} + Manual setup instead + + + {'[Esc]'} + Cancel + + + + + ); + } + + return null; +}; + +/** + * Key selection phase component. + */ +const KeySelectionPhase: React.FC<{ + authContext: UserAuthContext; + existingKeys: PushKeyStoreInfo[]; + onCreateNew: () => void; + onDownload: (keyId: string) => void; + onManual: () => void; +}> = ({ authContext, existingKeys, onCreateNew, onDownload, onManual }) => { + const downloadableKeys = existingKeys.filter((k) => k.canDownload); + const hasDownloadableKeys = downloadableKeys.length > 0; + + // Build selection items + const items: Array<{ label: string; value: string }> = [ + { label: 'Create new APNS key', value: 'create_new' }, + ]; + + // Add downloadable keys + for (const key of downloadableKeys) { + items.push({ + label: `Download existing: ${key.name} (${key.id})`, + value: `download:${key.id}`, + }); + } + + items.push({ label: 'Manual setup (create key in browser)', value: 'manual' }); + + const handleSelect = (item: { value: string }) => { + if (item.value === 'create_new') { + onCreateNew(); + } else if (item.value === 'manual') { + onManual(); + } else if (item.value.startsWith('download:')) { + const keyId = item.value.replace('download:', ''); + onDownload(keyId); + } + }; + + return ( + + + + ✓ Logged in as {authContext.appleId} + + + + Team: {authContext.team.name || authContext.team.id} + + + {existingKeys.length > 0 && ( + + + Found {existingKeys.length} existing key(s) + {hasDownloadableKeys + ? ` (${downloadableKeys.length} downloadable)` + : ' (none downloadable)'} + + {!hasDownloadableKeys && existingKeys.length > 0 && ( + + Note: P8 keys can only be downloaded once when created + + )} + + )} + + + Select an option: + + + + + + Esc to cancel + + + ); +}; + +/** + * Build UserAuthContext from restored session. + */ +async function buildAuthContextFromSession( + authState: { context: { teamId?: string }; username: string }, + appleId: string, +): Promise { + const { Teams, Session } = await import('@expo/apple-utils'); + + const teamId = authState.context.teamId; + if (!teamId) { + throw new Error('Team ID not found in authentication state'); + } + + const teams = await Teams.getTeamsAsync(); + const team = teams.find((t) => t.teamId === teamId); + + if (!team) { + throw new Error(`Your account is not associated with Apple Team with ID: ${teamId}`); + } + + const fastlaneSession = Session.getSessionAsYAML(); + + return { + appleId: authState.username || appleId, + team: { + id: team.teamId, + name: `${team.name} (${team.type})`, + inHouse: team.type.toLowerCase() === 'in-house', + }, + authState: authState as UserAuthContext['authState'], + fastlaneSession, + }; +} + +/** + * Helper component for prompt_login phase input handling. + */ +const AppleLoginPromptInput: React.FC<{ + onLogin: () => void; + onManual: () => void; +}> = ({ onLogin, onManual }) => { + useInput((input) => { + if (input.toLowerCase() === 'l') { + onLogin(); + } else if (input.toLowerCase() === 'm') { + onManual(); + } + }); + return null; +}; + +/** + * Helper component for error phase input handling. + */ +const AppleLoginErrorInput: React.FC<{ + onRetry: () => void; + onManual: () => void; +}> = ({ onRetry, onManual }) => { + useInput((input) => { + if (input.toLowerCase() === 'r') { + onRetry(); + } else if (input.toLowerCase() === 'm') { + onManual(); + } + }); + return null; +}; diff --git a/src/ui/components/FirebaseWizard.tsx b/src/ui/components/FirebaseWizard.tsx index 1c5943f..742d3cf 100644 --- a/src/ui/components/FirebaseWizard.tsx +++ b/src/ui/components/FirebaseWizard.tsx @@ -1,10 +1,10 @@ -import { spawn } from 'node:child_process'; import { Box, Text, useInput } from 'ink'; import SelectInput from 'ink-select-input'; import Spinner from 'ink-spinner'; import TextInput from 'ink-text-input'; import type React from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { openBrowser } from '@/lib/auth/browser'; import { type AndroidApp, type CredentialAction, @@ -172,31 +172,6 @@ function buildMenuItems(result: FirebaseDetectionResult): MenuAction[] { return items; } -/** - * Open URL in default browser. - * Uses spawn with argument array to prevent shell injection. - */ -function openBrowser(url: string): void { - const platform = process.platform; - - let command: string; - let args: string[]; - - if (platform === 'darwin') { - command = 'open'; - args = [url]; - } else if (platform === 'win32') { - // Windows 'start' requires empty title as first arg for URLs - command = 'cmd'; - args = ['/c', 'start', '""', url]; - } else { - command = 'xdg-open'; - args = [url]; - } - - spawn(command, args, { detached: true, stdio: 'ignore' }).unref(); -} - /** * Authenticating phase component. */ diff --git a/src/ui/components/GuidedSetupWizard.tsx b/src/ui/components/GuidedSetupWizard.tsx new file mode 100644 index 0000000..8acc5f7 --- /dev/null +++ b/src/ui/components/GuidedSetupWizard.tsx @@ -0,0 +1,405 @@ +/** + * Guided Setup Wizard - Replaces AI Agent for Xcode configuration + * Provides step-by-step guidance for manual Xcode tasks + */ + +import { Box, Text, useInput } from 'ink'; +import type React from 'react'; +import { useEffect, useState } from 'react'; +import { + createExtensionFiles, + type ExtensionContext, + type ExtensionGeneratorResult, + getExtensionBundleId, + getExtensionName, + verifyExtensionFiles, +} from '@/lib/ios/extension-generator'; +import { generatePodfileSnippet } from '@/lib/ios/extension-templates'; +import { Header } from './Header'; +import { StatusMessage } from './StatusMessage'; + +export type GuidedSetupPhase = + | 'creating_files' + | 'xcode_target' + | 'build_settings' + | 'dependencies' + | 'verification' + | 'complete'; + +export interface GuidedSetupContext { + bundleId: string; + appGroupId: string; + appName: string; + iosDir: string; + entitlementsPath: string; + pushEnvironment?: 'development' | 'production'; +} + +export interface GuidedSetupResult { + success: boolean; + extensionCreated: boolean; + extensionDir?: string; + createdFiles: string[]; + error?: string; +} + +interface GuidedSetupWizardProps { + context: GuidedSetupContext; + onComplete: (result: GuidedSetupResult) => void; +} + +interface WizardState { + phase: GuidedSetupPhase; + extensionResult: ExtensionGeneratorResult | null; + error: string | null; +} + +export const GuidedSetupWizard: React.FC = ({ context, onComplete }) => { + const [state, setState] = useState({ + phase: 'creating_files', + extensionResult: null, + error: null, + }); + + const { phase, extensionResult, error } = state; + const extensionName = getExtensionName(context.appName); + const extensionBundleId = getExtensionBundleId(context.bundleId, context.appName); + + // Create extension files on mount + useEffect(() => { + if (phase !== 'creating_files') return; + + const create = async () => { + const extContext: ExtensionContext = { + appName: context.appName, + bundleId: context.bundleId, + iosDir: context.iosDir, + pushEnvironment: context.pushEnvironment, + }; + + const result = await createExtensionFiles(extContext); + + if (!result.success) { + setState((s) => ({ + ...s, + phase: 'complete', + extensionResult: result, + error: result.error || 'Failed to create extension files', + })); + return; + } + + setState((s) => ({ + ...s, + phase: 'xcode_target', + extensionResult: result, + })); + }; + + create(); + }, [phase, context]); + + // Handle keyboard input for navigation + useInput((input, key) => { + if (key.return || input === ' ') { + switch (phase) { + case 'xcode_target': + setState((s) => ({ ...s, phase: 'build_settings' })); + break; + case 'build_settings': + setState((s) => ({ ...s, phase: 'dependencies' })); + break; + case 'dependencies': + setState((s) => ({ ...s, phase: 'verification' })); + break; + case 'verification': + setState((s) => ({ ...s, phase: 'complete' })); + break; + } + } + + if (key.escape && phase !== 'creating_files' && phase !== 'complete') { + setState((s) => ({ ...s, phase: 'complete' })); + } + }); + + // Complete handler + useEffect(() => { + if (phase === 'complete') { + const verification = verifyExtensionFiles(context.iosDir, context.appName); + + setTimeout(() => { + onComplete({ + success: !error && verification.complete, + extensionCreated: !!extensionResult?.createdFiles.length, + extensionDir: extensionResult?.extensionDir, + createdFiles: extensionResult?.createdFiles || [], + error: error || undefined, + }); + }, 500); + } + }, [phase, error, extensionResult, context, onComplete]); + + return ( + +
+ + {/* Phase: Creating Files */} + {phase === 'creating_files' && ( + + )} + + {/* Phase: Xcode Target Guide */} + {phase === 'xcode_target' && ( + + )} + + {/* Phase: Build Settings Guide */} + {phase === 'build_settings' && ( + + )} + + {/* Phase: Dependencies Guide */} + {phase === 'dependencies' && } + + {/* Phase: Verification */} + {phase === 'verification' && ( + + )} + + {/* Phase: Complete */} + {phase === 'complete' && } + + ); +}; + +// Sub-components for each phase + +const XcodeTargetGuide: React.FC<{ + extensionName: string; + extensionBundleId: string; + extensionResult: ExtensionGeneratorResult | null; + appGroupId: string; +}> = ({ extensionName, extensionBundleId, extensionResult, appGroupId }) => ( + + {extensionResult && extensionResult.createdFiles.length > 0 && ( + + + {extensionResult.createdFiles.map((file) => ( + + • {file} + + ))} + + )} + + + + Step 1: Create Notification Service Extension Target in Xcode + + + + + 1. Open your project in Xcode + 2. File → New → Target... + 3. Select "Notification Service Extension" + + 4. Product Name: {extensionName} + + + 5. Bundle Identifier: {extensionBundleId} + + 6. Click "Finish" + + + + After creating the target: + • Delete the auto-generated NotificationService.swift + • Copy files from the created extension directory + + • Add App Group capability: {appGroupId} + + + + + Press Enter to continue... + + +); + +const BuildSettingsGuide: React.FC<{ + extensionName: string; + entitlementsPath: string; +}> = ({ extensionName, entitlementsPath }) => ( + + + + + + Step 2: Configure Build Settings + + + + + For the main app target: + 1. Select target → Signing & Capabilities + 2. Verify entitlements file is linked: + + {entitlementsPath} + + + + + For {extensionName}: + 1. Select extension target → Build Settings + + 2. Search for ENABLE_USER_SCRIPT_SANDBOXING + + + 3. Set to No (required for Xcode 15+) + + + + + + For React Native + Firebase projects: + + • Build Phases → Move "Embed Foundation Extensions" + above "[RNFB] Core Configuration" + + + + Press Enter to continue... + + +); + +const DependenciesGuide: React.FC<{ + extensionName: string; +}> = ({ extensionName }) => { + const podfileSnippet = generatePodfileSnippet(extensionName); + + return ( + + + + + + Step 3: Add Clix SDK to Extension Target + + + + + For CocoaPods projects: + Add to your Podfile: + + {podfileSnippet} + + + Then run: cd ios && pod install + + + + + For Swift Package Manager: + 1. Select the extension target in Xcode + 2. General → Frameworks, Libraries, and Embedded Content + 3. Click + and add the Clix package + + + + Press Enter to continue... + + + ); +}; + +const VerificationPhase: React.FC<{ + context: GuidedSetupContext; + extensionResult: ExtensionGeneratorResult | null; +}> = ({ context, extensionResult }) => { + const verification = verifyExtensionFiles(context.iosDir, context.appName); + + return ( + + + + + + Step 4: Verification + + + + + Extension Files: + {verification.complete ? ( + + ) : ( + + + {verification.missingFiles.map((file) => ( + + • {file} + + ))} + + )} + + + + Extension Directory: + {extensionResult?.extensionDir} + + + + Important Reminders: + + • Replace YOUR_PROJECT_ID in NotificationService.swift + + • Extension must share the same App Group as main app + • Regenerate provisioning profiles if needed + + + + Press Enter to finish... + + + ); +}; + +const CompletePhase: React.FC<{ + error: string | null; + extensionResult: ExtensionGeneratorResult | null; +}> = ({ error, extensionResult }) => ( + + {error ? ( + + ) : ( + + + + ✓ Extension setup guide complete! + + + {extensionResult && extensionResult.createdFiles.length > 0 && ( + + Created files: + {extensionResult.createdFiles.map((file) => ( + + • {file} + + ))} + + )} + + )} + +); diff --git a/src/ui/components/IosSetupFlow.tsx b/src/ui/components/IosSetupFlow.tsx new file mode 100644 index 0000000..b60d8e9 --- /dev/null +++ b/src/ui/components/IosSetupFlow.tsx @@ -0,0 +1,292 @@ +/** + * Integrated iOS Setup Flow for Interactive mode. + * Combines Phase 1 (IosSetupUI), Phase 2 (GuidedSetupWizard), and Phase 3 (PushSetupWizard). + */ +import { Box, Text, useInput } from 'ink'; +import type React from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import type { PushSetupResult } from '../../lib/push'; +import { type IosSetupResult, IosSetupUI } from '../IosSetupUI'; +import { + type GuidedSetupContext, + type GuidedSetupResult, + GuidedSetupWizard, +} from './GuidedSetupWizard'; +import { PushSetupWizard } from './PushSetupWizard'; + +type FlowPhase = + | 'intro' // Welcome screen + | 'direct_setup' // Phase 1: IosSetupUI + | 'guided_setup' // Phase 2: GuidedSetupWizard + | 'push_confirm' // Confirm push setup + | 'push_setup' // Phase 3: PushSetupWizard + | 'complete'; // All done + +export interface IosSetupFlowResult { + success: boolean; + message: string; + directResult?: IosSetupResult; + guidedResult?: GuidedSetupResult; + pushResult?: PushSetupResult | null; +} + +interface IosSetupFlowProps { + onComplete: (result: IosSetupFlowResult) => void; +} + +/** + * Intro screen component + */ +const IntroScreen: React.FC<{ + onContinue: () => void; + onCancel: () => void; +}> = ({ onContinue, onCancel }) => { + useInput((_input, key) => { + if (key.return) { + onContinue(); + } else if (key.escape) { + onCancel(); + } + }); + + return ( + + + iOS Push Notification Setup + + + This wizard will configure your iOS app for push notifications: + + + 1. Configure entitlements and capabilities + 2. Create Notification Service Extension files + 3. Set up APNS key for Firebase (optional) + + + Press Enter to continue, Esc to cancel + + + ); +}; + +/** + * Push setup confirmation component + */ +const PushSetupConfirmation: React.FC<{ + onYes: () => void; + onNo: () => void; +}> = ({ onYes, onNo }) => { + useInput((input, key) => { + if (input.toLowerCase() === 'y' || key.return) { + onYes(); + } else if (input.toLowerCase() === 'n' || key.escape) { + onNo(); + } + }); + + return ( + + + ✓ iOS setup completed! + + + + Set up APNS key for Firebase push notifications? [Y/n] + + + + ); +}; + +/** + * Integrated iOS Setup Flow component + */ +export const IosSetupFlow: React.FC = ({ onComplete }) => { + const [phase, setPhase] = useState('intro'); + const [directResult, setDirectResult] = useState(null); + const [guidedResult, setGuidedResult] = useState(null); + + // Intro handlers + const handleIntroContinue = useCallback(() => { + setPhase('direct_setup'); + }, []); + + const handleIntroCancel = useCallback(() => { + onComplete({ + success: false, + message: 'iOS setup cancelled', + }); + }, [onComplete]); + + // Phase 1 complete handler + const handleDirectSetupComplete = useCallback( + (result: IosSetupResult) => { + setDirectResult(result); + + if (!result.success) { + onComplete({ + success: false, + message: result.error || 'iOS setup failed', + directResult: result, + }); + return; + } + + // If there's agent context, proceed to guided setup + if (result.agentContext) { + setPhase('guided_setup'); + } else { + // No extension needed, go to push confirmation + setPhase('push_confirm'); + } + }, + [onComplete], + ); + + // Phase 2 complete handler + const handleGuidedSetupComplete = useCallback((result: GuidedSetupResult) => { + setGuidedResult(result); + // Always proceed to push confirmation (even if guided setup had issues) + setPhase('push_confirm'); + }, []); + + // Push confirmation handlers + const handlePushConfirmYes = useCallback(() => { + setPhase('push_setup'); + }, []); + + const handlePushConfirmNo = useCallback(() => { + // Complete without push setup + const message = buildCompletionMessage(directResult, guidedResult, null); + onComplete({ + success: true, + message, + directResult: directResult ?? undefined, + guidedResult: guidedResult ?? undefined, + pushResult: null, + }); + }, [directResult, guidedResult, onComplete]); + + // Phase 3 complete handler + const handlePushSetupComplete = useCallback( + (result: PushSetupResult) => { + const message = buildCompletionMessage(directResult, guidedResult, result); + onComplete({ + success: true, + message, + directResult: directResult ?? undefined, + guidedResult: guidedResult ?? undefined, + pushResult: result, + }); + }, + [directResult, guidedResult, onComplete], + ); + + // Push setup cancel handler + const handlePushSetupCancel = useCallback(() => { + const message = buildCompletionMessage(directResult, guidedResult, null); + onComplete({ + success: true, + message, + directResult: directResult ?? undefined, + guidedResult: guidedResult ?? undefined, + pushResult: null, + }); + }, [directResult, guidedResult, onComplete]); + + // Build guided setup context from direct result + const getGuidedSetupContext = (): GuidedSetupContext | null => { + if (!directResult?.agentContext) return null; + return { + bundleId: directResult.agentContext.bundleId, + appGroupId: directResult.agentContext.appGroupId, + appName: directResult.agentContext.appName, + iosDir: directResult.agentContext.iosDir, + entitlementsPath: directResult.agentContext.entitlementsPath, + }; + }; + + // Handle edge case where guided_setup has no context (avoid setState during render) + useEffect(() => { + if (phase === 'guided_setup' && !directResult?.agentContext) { + setPhase('push_confirm'); + } + }, [phase, directResult?.agentContext]); + + // Render based on current phase + switch (phase) { + case 'intro': + return ; + + case 'direct_setup': + return ; + + case 'guided_setup': { + const context = getGuidedSetupContext(); + if (!context) { + // Will be handled by useEffect above + return null; + } + return ; + } + + case 'push_confirm': + return ; + + case 'push_setup': + return ( + + ); + + case 'complete': + return null; + + default: + return null; + } +}; + +/** + * Build completion message summarizing all phases + */ +function buildCompletionMessage( + directResult: IosSetupResult | null, + guidedResult: GuidedSetupResult | null, + pushResult: PushSetupResult | null, +): string { + const lines: string[] = ['iOS Push Setup Complete!', '']; + + if (directResult?.success) { + lines.push('✓ Capabilities configured'); + lines.push('✓ Entitlements created'); + } + + if (guidedResult?.success) { + lines.push('✓ Extension files created'); + } + + if (pushResult?.success) { + lines.push('✓ APNS key registered with Firebase'); + } else if (pushResult === null) { + lines.push('○ APNS key setup skipped'); + } + + lines.push(''); + lines.push('Your iOS app is ready to receive push notifications!'); + + return lines.join('\n'); +} diff --git a/src/ui/components/PushSetupWizard.tsx b/src/ui/components/PushSetupWizard.tsx new file mode 100644 index 0000000..fe0248a --- /dev/null +++ b/src/ui/components/PushSetupWizard.tsx @@ -0,0 +1,1257 @@ +/** + * Push Notification setup wizard component. + * + * @module ui/components/PushSetupWizard + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Box, Text, useInput } from 'ink'; +import SelectInput from 'ink-select-input'; +import Spinner from 'ink-spinner'; +import TextInput from 'ink-text-input'; +import type React from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { openBrowser } from '@/lib/auth/browser'; +import { analyzeIosProject } from '@/lib/ios'; +import { + APNS_KEY_CREATION_STEPS, + FIREBASE_UPLOAD_STEPS, + getKeyIdError, + getTeamIdError, + PUSH_SETUP_URLS, + type PushSetupContext, + type PushSetupPhase, + type PushSetupResult, + validateP8File, +} from '@/lib/push'; +import { + FirebaseDownloader, + type FirebaseProject, + FirebaseService, + isOAuthConfigured, +} from '@/lib/services/firebase'; +import type { GoogleServiceInfoPlist, GoogleServicesJson } from '@/lib/services/firebase/types'; +import { AppleLoginUI } from './AppleLoginUI'; + +interface PushSetupWizardProps { + projectPath: string; + onComplete: (result: PushSetupResult) => void; + onCancel?: () => void; + /** Pre-detected Bundle ID from ios-setup */ + preDetectedBundleId?: string; + /** Pre-detected Firebase Project ID from ios-setup */ + preDetectedFirebaseProjectId?: string | null; + /** Pre-detected Team ID from Firebase config */ + preDetectedTeamId?: string | null; +} + +/** + * Detecting phase component. + */ +function DetectingPhase(): React.ReactElement { + return ( + + + Push Notification Setup + + + + + + Detecting project configuration... + + + ); +} + +/** + * Status phase component. + */ +function StatusPhase({ + context, + onContinue, + onCancel, +}: { + context: PushSetupContext; + onContinue: () => void; + onCancel: () => void; +}): React.ReactElement { + useInput((_input, key) => { + if (key.return) { + onContinue(); + } else if (key.escape) { + onCancel(); + } + }); + + return ( + + + Push Notification Setup + + + + Firebase Project: + {context.firebaseProjectId ? ( + {context.firebaseProjectId} + ) : ( + not configured + )} + + + Bundle ID: + {context.bundleId ? ( + {context.bundleId} + ) : ( + not detected + )} + + + + Press Enter to continue, Esc to cancel + + + ); +} + +/** + * Key source selection phase component. + */ +function KeySourcePhase({ + onHasKey, + onNoKey, + onAppleLogin, + onCancel, +}: { + onHasKey: () => void; + onNoKey: () => void; + onAppleLogin: () => void; + onCancel: () => void; +}): React.ReactElement { + const items = [ + { label: 'Yes, I have an APNS key (.p8 file)', value: 'has_key' }, + { label: 'Create with Apple Account (auto)', value: 'apple_login' }, + { label: 'Create manually in browser', value: 'no_key' }, + { label: 'Cancel', value: 'cancel' }, + ]; + + const handleSelect = (item: { value: string }) => { + switch (item.value) { + case 'has_key': + onHasKey(); + break; + case 'apple_login': + onAppleLogin(); + break; + case 'no_key': + onNoKey(); + break; + case 'cancel': + onCancel(); + break; + } + }; + + useInput((_input, key) => { + if (key.escape) { + onCancel(); + } + }); + + return ( + + + Do you have an existing APNS key? + + + + ↑↓ navigate · Enter select · Esc cancel + + + ); +} + +/** + * Apple guide phase component. + */ +function AppleGuidePhase({ + onContinue, + onCancel, +}: { + onContinue: () => void; + onCancel: () => void; +}): React.ReactElement { + const [browserOpened, setBrowserOpened] = useState(false); + + useEffect(() => { + if (!browserOpened) { + openBrowser(PUSH_SETUP_URLS.appleCreateKey); + setBrowserOpened(true); + } + }, [browserOpened]); + + useInput((_input, key) => { + if (key.return) { + onContinue(); + } else if (key.escape) { + onCancel(); + } + }); + + return ( + + + Create APNS Key in Apple Developer Portal + + + Browser opened to Apple Developer Portal + + + Steps: + {APNS_KEY_CREATION_STEPS.map((step) => ( + + {APNS_KEY_CREATION_STEPS.indexOf(step) + 1}. {step} + + ))} + + + Press Enter when you have copied the .p8 file to this directory + + + Esc to cancel + + + ); +} + +/** + * Find .p8 files in the current directory. + */ +function findP8Files(): string[] { + try { + const cwd = process.cwd(); + const files = fs.readdirSync(cwd); + return files + .filter((f) => f.endsWith('.p8')) + .map((f) => `./${f}`) + .sort(); + } catch { + return []; + } +} + +/** + * P8 input phase component. + */ +function P8InputPhase({ + suggestedKeyId, + suggestedTeamId, + onSubmit, + onCancel, +}: { + suggestedKeyId?: string; + suggestedTeamId?: string; + onSubmit: (p8Path: string, keyId: string, teamId: string) => void; + onCancel: () => void; +}): React.ReactElement { + const [stage, setStage] = useState<'p8_select' | 'p8_path' | 'key_id' | 'team_id'>('p8_select'); + const [foundFiles, setFoundFiles] = useState([]); + const [p8Path, setP8Path] = useState(''); + const [keyId, setKeyId] = useState(suggestedKeyId || ''); + const [teamId, setTeamId] = useState(suggestedTeamId || ''); + const [error, setError] = useState(null); + const [extractedKeyId, setExtractedKeyId] = useState(null); + const [prefilledTeamId] = useState(suggestedTeamId || null); + + // Search for .p8 files on mount + useEffect(() => { + const files = findP8Files(); + setFoundFiles(files); + // If no files found, go directly to manual input + if (files.length === 0) { + setStage('p8_path'); + } + }, []); + + useInput((input, key) => { + if (key.escape) { + onCancel(); + } + // Open Team ID page when 't' is pressed in team_id stage + if (stage === 'team_id' && input === 't') { + openBrowser(PUSH_SETUP_URLS.appleTeamId); + } + }); + + const handleFileSelect = useCallback((item: { label: string; value: string }) => { + if (item.value === 'manual') { + setStage('p8_path'); + return; + } + + const result = validateP8File(item.value); + if (!result.valid) { + setError(result.error || 'Invalid P8 file'); + return; + } + + setP8Path(item.value); + setError(null); + if (result.suggestedKeyId) { + setExtractedKeyId(result.suggestedKeyId); + setKeyId(result.suggestedKeyId); + } + setStage('key_id'); + }, []); + + const handleP8PathSubmit = useCallback(() => { + if (!p8Path.trim()) { + setError('P8 file path is required'); + return; + } + + const result = validateP8File(p8Path.trim()); + if (!result.valid) { + setError(result.error || 'Invalid P8 file'); + return; + } + + setError(null); + if (result.suggestedKeyId) { + setExtractedKeyId(result.suggestedKeyId); + setKeyId(result.suggestedKeyId); + } + setStage('key_id'); + }, [p8Path]); + + const handleKeyIdSubmit = useCallback(() => { + const keyIdError = getKeyIdError(keyId.trim()); + if (keyIdError) { + setError(keyIdError); + return; + } + setError(null); + setStage('team_id'); + }, [keyId]); + + const handleTeamIdSubmit = useCallback(() => { + const teamIdError = getTeamIdError(teamId.trim()); + if (teamIdError) { + setError(teamIdError); + return; + } + setError(null); + onSubmit(p8Path.trim(), keyId.trim().toUpperCase(), teamId.trim().toUpperCase()); + }, [p8Path, keyId, teamId, onSubmit]); + + // Build selection items for found files + const fileItems = [ + ...foundFiles.map((f) => ({ + label: `${path.basename(f)}`, + value: f, + })), + { label: 'Enter path manually...', value: 'manual' }, + ]; + + return ( + + + Enter APNS Key Information + + + {error && ( + + ✗ {error} + + )} + + {stage === 'p8_select' && foundFiles.length > 0 && ( + <> + + + Found {foundFiles.length} P8 file(s) in current directory: + + + + + )} + + {stage === 'p8_path' && ( + <> + + + Path to P8 file: (e.g., ./AuthKey_XXXXXXXXXX.p8) + + + + Tip: Copy the file to this directory to avoid permission issues + + + {'> '} + + + + )} + + {stage === 'key_id' && ( + <> + + P8 file: {p8Path} + + + + Key ID: (10 characters, e.g., ABCD123456) + + + {extractedKeyId && ( + + ✓ Extracted from filename: {extractedKeyId} + + )} + + {'> '} + + + + )} + + {stage === 'team_id' && ( + <> + + P8 file: {p8Path} + + + Key ID: {keyId} + + + + Apple Team ID: (10 characters) + + + {prefilledTeamId && ( + + ✓ Detected from Xcode project: {prefilledTeamId} + + )} + {!prefilledTeamId && ( + + + Find your Team ID at:{' '} + + developer.apple.com/account + + + → Look for "Team ID" in the "Membership details" card + + )} + + {'> '} + + + {!prefilledTeamId && ( + + Press 't' to open Team ID page in browser + + )} + + )} + + + + {stage === 'p8_select' + ? '↑↓ navigate · Enter select · Esc cancel' + : 'Enter to continue · Esc to cancel'} + + + + ); +} + +/** + * Firebase authentication phase component. + */ +function FirebaseAuthPhase({ onCancel }: { onCancel: () => void }): React.ReactElement { + useInput((_input, key) => { + const isCtrlC = (_input === 'c' && key.ctrl) || _input === '\x03'; + if (key.escape || isCtrlC) { + onCancel(); + } + }); + + return ( + + + Firebase Authentication + + + + + + Authenticating with Firebase... + + + A browser window will open for authentication + + + Esc to cancel + + + ); +} + +/** + * Firebase project selection phase component. + */ +function FirebaseProjectsPhase({ + projects, + onSelect, + onCancel, +}: { + projects: FirebaseProject[]; + onSelect: (project: FirebaseProject) => void; + onCancel: () => void; +}): React.ReactElement { + const items = projects.map((p) => ({ + label: p.displayName || p.projectId, + value: p.projectId, + })); + + const handleSelect = useCallback( + (item: { label: string; value: string }) => { + const project = projects.find((p) => p.projectId === item.value); + if (project) { + onSelect(project); + } + }, + [onSelect, projects], + ); + + useInput((_input, key) => { + if (key.escape) { + onCancel(); + } + }); + + return ( + + + Select Firebase Project + + + Choose the project to upload APNS key: + + + + Esc to cancel + + + ); +} + +/** + * Firebase upload phase component. + */ +function FirebaseUploadPhase({ + context, + selectedProject, + onComplete, + onCancel, +}: { + context: PushSetupContext; + selectedProject: FirebaseProject | null; + onComplete: () => void; + onCancel: () => void; +}): React.ReactElement { + const [browserOpened, setBrowserOpened] = useState(false); + + // Compute project label with fallback + const projectLabel = + selectedProject?.displayName || + selectedProject?.projectId || + context.firebaseProjectId || + 'Unknown'; + + useEffect(() => { + if (!browserOpened) { + // Use selectedProject if available, otherwise fall back to context.firebaseProjectId + const projectId = selectedProject?.projectId ?? context.firebaseProjectId; + // If no project ID is known, open the generic Firebase console + const url = projectId + ? PUSH_SETUP_URLS.firebaseConsole(projectId) + : PUSH_SETUP_URLS.firebaseConsoleGeneric; + openBrowser(url); + setBrowserOpened(true); + } + }, [browserOpened, selectedProject, context.firebaseProjectId]); + + useInput((_input, key) => { + if (key.return) { + onComplete(); + } else if (key.escape) { + onCancel(); + } + }); + + return ( + + + Upload APNS Key to Firebase + + + + Project: {projectLabel} + + + + Browser opened to Firebase Console → Cloud Messaging + + + + + Information to enter in Firebase: + + + Key ID: + + {context.pushKey?.apnsKeyId || 'N/A'} + + + + Team ID: + + {context.pushKey?.teamId || 'N/A'} + + + + P8 File: + {context.p8FilePath || 'N/A'} + + + + + Steps: + {FIREBASE_UPLOAD_STEPS.map((step) => ( + + {FIREBASE_UPLOAD_STEPS.indexOf(step) + 1}. {step} + + ))} + + + + Press Enter when upload is complete + + + Esc to cancel + + + ); +} + +/** + * Complete phase component. + */ +function CompletePhase({ + context, + cancelled, +}: { + context: PushSetupContext; + cancelled: boolean; +}): React.ReactElement { + if (cancelled) { + return ( + + + + ! Push notification setup cancelled + + + + You can run /ios-setup later to configure push notifications. + + + ); + } + + return ( + + + + ✓ Push Notification Setup Complete + + + + + Key ID: + {context.pushKey?.apnsKeyId || 'N/A'} + + + Team ID: + {context.pushKey?.teamId || 'N/A'} + + + + Your iOS app is now configured to receive push notifications. + + + ); +} + +/** + * Error phase component. + */ +function ErrorPhase({ + error, + onRetry, + onCancel, +}: { + error: string; + onRetry: () => void; + onCancel: () => void; +}): React.ReactElement { + useInput((_input, key) => { + if (key.return) { + onRetry(); + } else if (key.escape) { + onCancel(); + } + }); + + return ( + + + + Push Notification Setup Error + + + + ✗ {error} + + + Press Enter to retry, Esc to cancel + + + ); +} + +/** + * Detection result from project analysis. + */ +interface DetectionResult { + firebaseProjectId: string | null; + bundleId: string | null; + teamId: string | null; +} + +/** + * Detect project configuration from Firebase config. + */ +async function detectFromFirebase(projectPath: string): Promise { + const result: DetectionResult = { + firebaseProjectId: null, + bundleId: null, + teamId: null, + }; + + try { + const firebaseService = new FirebaseService(projectPath); + const detection = await firebaseService.detect(); + + const iosContent = detection.ios?.content as GoogleServiceInfoPlist | undefined; + const androidContent = detection.android?.content as GoogleServicesJson | undefined; + + result.firebaseProjectId = + iosContent?.PROJECT_ID || androidContent?.project_info?.project_id || null; + result.bundleId = iosContent?.BUNDLE_ID || null; + result.teamId = iosContent?.TEAM_ID || null; + } catch { + // Firebase detection failed + } + + return result; +} + +/** + * Detect Team ID and Bundle ID from Xcode project. + */ +async function detectFromXcodeProject( + projectPath: string, +): Promise<{ teamId: string | null; bundleId: string | null }> { + try { + const analysis = await analyzeIosProject(projectPath); + if (analysis.success && analysis.project) { + return { + teamId: analysis.project.teamId || null, + bundleId: analysis.project.bundleId || null, + }; + } + } catch { + // iOS project analysis failed + } + return { teamId: null, bundleId: null }; +} + +/** + * Push notification setup wizard component. + */ +export const PushSetupWizard: React.FC = ({ + projectPath, + onComplete, + onCancel, + preDetectedBundleId, + preDetectedFirebaseProjectId, + preDetectedTeamId, +}) => { + const [phase, setPhase] = useState('detecting'); + const [context, setContext] = useState({ + bundleId: null, + firebaseProjectId: null, + pushKey: null, + p8FilePath: null, + }); + const [error, setError] = useState(null); + const [cancelled, setCancelled] = useState(false); + const [detectedTeamId, setDetectedTeamId] = useState(preDetectedTeamId ?? null); + + // Firebase OAuth state + const downloaderRef = useRef(null); + const [projects, setProjects] = useState([]); + const [selectedProject, setSelectedProject] = useState(null); + + // Helper to apply detection results to state + const applyDetectionResult = useCallback( + (result: { + firebaseProjectId: string | null; + bundleId: string | null; + teamId: string | null; + }) => { + if (result.teamId) { + setDetectedTeamId(result.teamId); + } + setContext((prev) => ({ + ...prev, + firebaseProjectId: result.firebaseProjectId, + bundleId: result.bundleId, + })); + setPhase('status'); + }, + [], + ); + + // Initial detection + useEffect(() => { + let cancelled = false; + + const detect = async () => { + // Use pre-detected values if available (from ios-setup integration) + if (preDetectedBundleId !== undefined || preDetectedFirebaseProjectId !== undefined) { + if (cancelled) return; + applyDetectionResult({ + firebaseProjectId: preDetectedFirebaseProjectId ?? null, + bundleId: preDetectedBundleId ?? null, + teamId: preDetectedTeamId ?? null, + }); + return; + } + + // Detect from Firebase config + const firebaseResult = await detectFromFirebase(projectPath); + if (cancelled) return; + let { firebaseProjectId, bundleId, teamId } = firebaseResult; + + // Try Xcode project if Team ID not found in Firebase config + if (!teamId) { + const xcodeResult = await detectFromXcodeProject(projectPath); + if (cancelled) return; + teamId = xcodeResult.teamId; + bundleId = bundleId || xcodeResult.bundleId; + } + + applyDetectionResult({ firebaseProjectId, bundleId, teamId }); + }; + + if (phase === 'detecting') { + detect(); + } + + return () => { + cancelled = true; + }; + }, [ + phase, + projectPath, + preDetectedBundleId, + preDetectedFirebaseProjectId, + preDetectedTeamId, + applyDetectionResult, + ]); + + // Firebase authentication effect + useEffect(() => { + let cancelled = false; + + const handleProjectSelection = (fetchedProjects: FirebaseProject[]) => { + // If pre-detected project exists, try to find and auto-select it + if (context.firebaseProjectId) { + const matchingProject = fetchedProjects.find( + (p) => p.projectId === context.firebaseProjectId, + ); + if (matchingProject) { + setSelectedProject(matchingProject); + setPhase('firebase_upload'); + return true; + } + } + + // Otherwise, show project selection based on count + if (fetchedProjects.length === 1) { + setSelectedProject(fetchedProjects[0]); + setPhase('firebase_upload'); + } else if (fetchedProjects.length > 1) { + setPhase('firebase_projects'); + } else { + setError('No Firebase projects found'); + setPhase('error'); + } + return false; + }; + + const authenticateAndFetchProjects = async () => { + try { + if (!downloaderRef.current) { + downloaderRef.current = new FirebaseDownloader(); + } + const downloader = downloaderRef.current; + + await downloader.authenticate(openBrowser); + + const fetchedProjects = await downloader.listProjects(); + if (cancelled) return; + + setProjects(fetchedProjects); + handleProjectSelection(fetchedProjects); + } catch (err) { + if (cancelled) return; + const message = err instanceof Error ? err.message : 'Firebase authentication failed'; + setError(message); + setPhase('error'); + } + }; + + if (phase === 'firebase_auth') { + authenticateAndFetchProjects(); + } + + return () => { + cancelled = true; + }; + }, [phase, context.firebaseProjectId]); + + const handleContinue = useCallback(() => { + setPhase('key_source'); + }, []); + + const handleCancel = useCallback(() => { + setCancelled(true); + setPhase('complete'); + if (onCancel) { + onCancel(); + } else { + onComplete({ + success: false, + message: 'Push notification setup cancelled', + }); + } + }, [onCancel, onComplete]); + + const handleHasKey = useCallback(() => { + setPhase('p8_input'); + }, []); + + const handleNoKey = useCallback(() => { + setPhase('apple_guide'); + }, []); + + const handleAppleLogin = useCallback(() => { + setPhase('apple_login'); + }, []); + + const handleAppleLoginSuccess = useCallback( + (result: { + pushKey: { apnsKeyId: string; apnsKeyP8: string; teamId: string; teamName?: string }; + }) => { + // Save P8 content to a file in the project directory + const p8FileName = `AuthKey_${result.pushKey.apnsKeyId}.p8`; + const p8FilePath = path.join(projectPath, p8FileName); + + let savedPath: string | null = null; + try { + // Use mode 0o600 to restrict APNS key file to current user only + fs.writeFileSync(p8FilePath, result.pushKey.apnsKeyP8, { encoding: 'utf-8', mode: 0o600 }); + savedPath = p8FilePath; + } catch { + // If we can't write to project dir, try current dir + try { + const fallbackPath = path.join(process.cwd(), p8FileName); + fs.writeFileSync(fallbackPath, result.pushKey.apnsKeyP8, { + encoding: 'utf-8', + mode: 0o600, + }); + savedPath = fallbackPath; + } catch { + setError('Failed to write APNS key file. Check directory permissions and try again.'); + setPhase('error'); + return; + } + } + + setContext((prev) => ({ + ...prev, + pushKey: { + apnsKeyP8: result.pushKey.apnsKeyP8, + apnsKeyId: result.pushKey.apnsKeyId, + teamId: result.pushKey.teamId, + }, + p8FilePath: savedPath, + })); + // Proceed to Firebase upload + if (isOAuthConfigured()) { + setPhase('firebase_auth'); + } else { + setPhase('firebase_upload'); + } + }, + [projectPath], + ); + + const handleAppleLoginFallback = useCallback(() => { + // Fall back to manual browser-based key creation + setPhase('apple_guide'); + }, []); + + const handleAppleGuideComplete = useCallback(() => { + setPhase('p8_input'); + }, []); + + const handleP8Submit = useCallback((p8Path: string, keyId: string, teamId: string) => { + const result = validateP8File(p8Path); + if (!result.valid || !result.content) { + setError(result.error || 'Invalid P8 file'); + setPhase('error'); + return; + } + + // Store content in a variable to ensure TypeScript type narrowing + const p8Content = result.content; + + setContext((prev) => ({ + ...prev, + pushKey: { + apnsKeyP8: p8Content, + apnsKeyId: keyId, + teamId, + }, + p8FilePath: p8Path, + })); + // Check if Firebase OAuth is configured + if (isOAuthConfigured()) { + setPhase('firebase_auth'); + } else { + // Fallback: open Firebase console directly with detected project + setPhase('firebase_upload'); + } + }, []); + + const handleProjectSelect = useCallback((project: FirebaseProject) => { + setSelectedProject(project); + setPhase('firebase_upload'); + }, []); + + const handleFirebaseComplete = useCallback(() => { + setPhase('complete'); + onComplete({ + success: true, + message: 'Push notification setup complete', + context, + }); + }, [context, onComplete]); + + const handleRetry = useCallback(() => { + setError(null); + setPhase('status'); + }, []); + + switch (phase) { + case 'detecting': + return ; + + case 'status': + return ; + + case 'key_source': + return ( + + ); + + case 'apple_login': + return ( + + ); + + case 'apple_guide': + return ; + + case 'p8_input': + return ( + + ); + + case 'firebase_auth': + return ; + + case 'firebase_projects': + return ( + + ); + + case 'firebase_upload': + return ( + + ); + + case 'complete': + return ; + + case 'error': + return ( + + ); + + default: + return ; + } +};