diff --git a/AGENTS.md b/AGENTS.md index 13dddb9..ed1bca1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,32 @@ Key files: `src/ui/chat/ChatApp.tsx` - Main component with context provider. State split into focused hooks under `src/ui/chat/hooks/`. +### FirebaseWizard State Machine + +`src/ui/components/FirebaseWizard.tsx` - Multi-phase wizard for Firebase configuration. Uses a centralized state machine pattern. + +**Phase transition map**: `src/ui/components/firebase-wizard-transitions.ts` + +- `PHASE_TRANSITIONS` - All valid phase transitions in one map +- `transition(from, event)` - Validated transition function (throws on invalid transitions) +- `ExtendedWizardPhase` - Union type of all 24 phases + +**Rules when modifying FirebaseWizard**: + +1. **Update `PHASE_TRANSITIONS` first** - Add/modify transition rules in the map before changing handlers +2. **Use `transition()` for all `setPhase()` calls** - Never call `setPhase('phase')` directly; use `setPhase(transition('currentPhase', 'event'))` instead +3. **One `setPhase` per handler** - Avoid multiple `setPhase()` calls in a single async handler. If a handler needs multiple steps, split into separate phases with `useEffect` +4. **"Direct set" exceptions** - Only use direct `setPhase()` when the source phase is ambiguous (entry points from multiple phases). Add a `// Direct set:` comment explaining why + +**Flow overview**: +``` +detecting → status → menu → (actions) +menu/download → authenticating → select_project → select_*_app → downloading +downloading → checking_sender_config → service_account_menu → paste → saving → complete +``` + +**Tests**: `src/ui/components/__tests__/firebase-wizard-transitions.test.ts` (34 tests, 100% coverage on transition file) + ### Skills `src/lib/skills.ts` - Manages skill workflows. diff --git a/bun.lock b/bun.lock index b310a85..55e03e5 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,8 @@ "@clix-so/clix-agent-skills": "^0.2.3", "@expo/apple-utils": "^2.1.14", "@expo/plist": "^0.4.8", + "@googleapis/cloudresourcemanager": "^6.0.1", + "@googleapis/firebase": "^12.0.1", "asciify-image": "^0.1.10", "google-auth-library": "^10.5.0", "ink": "^6.6.0", @@ -67,6 +69,10 @@ "@expo/plist": ["@expo/plist@0.4.8", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.2.3", "xmlbuilder": "^15.1.1" } }, "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ=="], + "@googleapis/cloudresourcemanager": ["@googleapis/cloudresourcemanager@6.0.1", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-HO/1fwrqiE0msHxSXreM6ljCHuzBW+zEfKIKdzbcTsu+OBDJtWJxQ0flcD4kS2c4rVZ2W1j+RUQ1+eFquN56Mg=="], + + "@googleapis/firebase": ["@googleapis/firebase@12.0.1", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-ODFU7EbB2G/WFsXiNCXjRgJPRxT5JYh3ydznNdsVng/1SnWmQZK8Bgufvxq6ZWnNDqZJTtNUqAwjbMBoP6N3MA=="], + "@iarna/toml": ["@iarna/toml@2.2.5", "", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="], "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], @@ -211,6 +217,10 @@ "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="], "centra": ["centra@2.7.0", "", { "dependencies": { "follow-redirects": "^1.15.6" } }, "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg=="], @@ -277,6 +287,8 @@ "dom-walk": ["dom-walk@0.1.2", "", {}, "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="], @@ -287,6 +299,12 @@ "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-toolkit": ["es-toolkit@1.43.0", "", {}, "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA=="], "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], @@ -339,6 +357,10 @@ "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng=="], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], @@ -351,6 +373,10 @@ "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "googleapis-common": ["googleapis-common@8.0.1", "", { "dependencies": { "extend": "^3.0.2", "gaxios": "^7.0.0-rc.4", "google-auth-library": "^10.1.0", "qs": "^6.7.0", "url-template": "^2.0.8" } }, "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], @@ -361,6 +387,8 @@ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "http-signature": ["http-signature@1.2.0", "", { "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", "sshpk": "^1.7.0" } }, "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ=="], @@ -471,6 +499,8 @@ "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "meow": ["meow@14.0.0", "", {}, "sha512-JhC3R1f6dbspVtmF3vKjAWz1EVIvwFrGGPLSdU6rK79xBwHWTuHoLnRX/t1/zHS1Ch1Y2UtIrih7DAHuH9JFJA=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], @@ -507,6 +537,8 @@ "oauth-sign": ["oauth-sign@0.9.0", "", {}, "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], @@ -561,7 +593,7 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "qs": ["qs@6.5.3", "", {}, "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA=="], + "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], @@ -599,6 +631,14 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "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=="], @@ -665,6 +705,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "url-template": ["url-template@2.0.8", "", {}, "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="], + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -763,6 +805,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/qs": ["qs@6.5.3", "", {}, "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA=="], + "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=="], diff --git a/package.json b/package.json index 766bf24..f1bf6a3 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,8 @@ "@clix-so/clix-agent-skills": "^0.2.3", "@expo/apple-utils": "^2.1.14", "@expo/plist": "^0.4.8", + "@googleapis/cloudresourcemanager": "^6.0.1", + "@googleapis/firebase": "^12.0.1", "asciify-image": "^0.1.10", "google-auth-library": "^10.5.0", "ink": "^6.6.0", diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..bef473d --- /dev/null +++ b/plan.md @@ -0,0 +1,133 @@ +# feat/refactor-firebase-apis 브랜치 작업 현황 리뷰 + +## Context + +이 브랜치는 Firebase API 클라이언트를 googleapis 라이브러리로 마이그레이션하고, Service Account 관리 방식을 변경하며, Sender Config API 연동과 FirebaseWizard 상태 머신 리팩터링을 포함하는 대규모 작업입니다. + +--- + +## 완료된 작업 (커밋 완료) + +| # | 커밋 | 내용 | +|---|------|------| +| 1 | `acc35b0` | feat(config): 자동 프로젝트 타입 감지 | +| 2 | `5e5ad31` | feat: 프로젝트 로컬 `.clix/` 스토리지 마이그레이션 | +| 3 | `354615f` | feat(firebase): SA 관리 및 프로젝트 생성 | +| 4 | `1c18441` | refactor(firebase): REST API → googleapis 라이브러리 전환 | +| 5 | `4e604c5` | refactor(config): V1/V2 버전 관리 제거로 프로젝트 설정 단순화 | + +--- + +## 완료된 작업 (아직 커밋 안 됨 — working tree) + +### 1. Firebase API googleapis 마이그레이션 (추가 정리) +- **파일**: `firebase-api.ts`, `types.ts`, `index.ts`, `downloader.ts` +- IAM API 클라이언트 완전 삭제 (`iam-api.ts` 파일 삭제) +- `@googleapis/iam` 의존성 제거 +- Service Account 생성/키 발급 메서드 제거 (사용자가 Firebase Console에서 직접 다운로드) +- API 호출마다 `debug.log`에 에러 로깅 추가 + +### 2. OAuth 스코프 단순화 + 디버깅 인프라 +- **파일**: `oauth/config.ts`, `oauth/auth-client.ts`, `logger.ts`, `oauth.ts` +- OAuth 스코프: firebase + iam + cloud-platform → **firebase만** +- `.clix/debug.log` 파일 기반 OAuth 디버그 로깅 추가 +- `invalid_grant` 에러 시 토큰 자동 클리어 + 재인증 시그널 +- OAuth 콜백 HTML 스타일 업데이트 + +### 3. Internal API — Sender Config 연동 +- **파일**: `api/types.ts`, `api/internal-client.ts`, `api/index.ts` +- `AppPushSenderConfig`, `SenderConfig` 타입 추가 +- `getProject()`, `createOrUpdateSenderConfig()` API 메서드 추가 +- 429/5xx/timeout에 대한 지수 백오프 재시도 로직 + +### 4. Firebase 감지 로직 수정 +- **파일**: `preparation.ts`, `preparation.test.ts` +- `checkFirebaseStatus()`가 캐시된 `.clix/config.jsonc`만 보지 않고 항상 실제 파일 탐지 수행 +- 파일에 프로젝트 ID가 없을 때 캐시된 projectId로 폴백 + +### 5. FirebaseWizard 상태 머신 리팩터링 +- **파일**: `firebase-wizard-transitions.ts` (신규), `FirebaseWizard.tsx`, `__tests__/firebase-wizard-transitions.test.ts` (신규) +- `PHASE_TRANSITIONS` 중앙 집중 맵 + `transition()` 검증 함수 추출 +- 모든 `setPhase()` 호출을 `transition(from, event)`로 교체 +- `handleSaveServiceAccountJson` 분리: 핸들러는 1번만 전이, API 호출은 useEffect로 분리 +- 34개 플로우 경로 테스트 (100% 커버리지) + +### 6. FirebaseWizard 새 기능 +- **파일**: `FirebaseWizard.tsx` +- Sender config 확인 3개 phase 추가 (`checking_sender_config`, `sender_config_registered`, `registering_sender_config`) +- `PasteServiceAccountPhase` 개선: 클립보드/JSON 직접입력/파일 드래그 자동 감지 +- Service Account 메뉴 단순화: "Create new" 제거, Console 다운로드 + Paste만 제공 + +### 7. Chat UI 통합 +- **파일**: `ChatApp.tsx`, `useOverlays.ts`, `useCommandHandler.ts`, `useMessageSending.ts` +- `install-preparation` 오버레이 타입 추가 +- `/install` 커맨드 실행 시 preparation UI 먼저 표시 +- `clixProjectId`를 `FirebaseWizard`에 전달 + +### 8. InstallPreparationUI 개선 +- **파일**: `InstallPreparationUI.tsx` +- 초기 진입 phase 판별 로직 추가 +- Ready phase에서 auto-continue 대신 명시적 액션 메뉴 제공 + +### 9. ProjectSelector 검색 기능 +- **파일**: `ProjectSelector.tsx` +- 프로젝트 검색/필터 기능 추가 +- 알파벳 정렬 (`Intl.Collator`) + +### 10. Organization/Projects 서비스 추출 +- **파일**: `organization-projects.ts` (신규), `organization-projects.test.ts` (신규) +- 동시 프로젝트 조회 (concurrency: 4) +- 지수 백오프 재시도 +- LoginUI, SetupUI에서 중복 로직 제거 + +### 11. 인증 관련 +- **파일**: `credentials.ts`, `LoginUI.tsx`, `SetupUI.tsx` +- `clearFirebaseTokens()` 견고성 개선 +- 조직/프로젝트 조회를 새 서비스로 이전 + +### 12. 문서화 +- **파일**: `AGENTS.md` +- FirebaseWizard 상태 머신 섹션 추가 (수정 규칙 4가지, 플로우 개요, 테스트 정보) + +--- + +## 검증 상태 + +| 검증 항목 | 상태 | +|-----------|------| +| `bun run check` (lint + typecheck) | ✅ 통과 | +| `bun test` (623 unit tests) | ✅ 통과 | +| `bun run build` | ✅ 성공 | +| `bun test tests/e2e/` (20 E2E tests) | ✅ 통과 | +| firebase-wizard-transitions 커버리지 | ✅ 100% | + +--- + +## 변경 파일 요약 (32개 파일, ~2,200줄) + +| 카테고리 | 파일 수 | 주요 변경량 | +|----------|---------|-----------| +| Firebase API 마이그레이션 | 5 | IAM 삭제, googleapis 적용 | +| OAuth/디버깅 | 4 | 스코프 단순화, debug.log | +| Internal API (sender config) | 3 | 재시도 로직, 새 API 메서드 | +| FirebaseWizard 리팩터링 | 3 | 전이 맵, 34 테스트 | +| Install preparation | 3 | 파일 탐지 개선 | +| Chat UI 통합 | 6 | preparation 오버레이 | +| ProjectSelector | 1 | 검색/필터 | +| Organization 서비스 | 2 | 동시 조회 추출 | +| 인증/기타 | 3 | credentials 개선 | +| 문서/의존성 | 3 | AGENTS.md, package.json | + +--- + +## 남은 작업 + +### 필수 +1. **E2E 수동 테스트** — 실제 Firebase 프로젝트로 전체 플로우 테스트 (OAuth → 다운로드 → Sender Config → SA 등록) +2. **PR 생성** — main 브랜치로 PR 생성 및 리뷰 요청 + +### 식별된 리스크 +- Sender Config 권한 불일치 → 명시적 에러 매핑으로 완화 +- FirebaseWizard phase 회귀 → transition-map 강제 + path 테스트로 완화 +- 동시 fetch 부분 실패 → UI 폴백으로 완화 +- OAuth 환경 드리프트 → debug 로그로 완화 diff --git a/src/cli.tsx b/src/cli.tsx index 656f9fa..7170720 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -2,9 +2,7 @@ import meow from 'meow'; import { agentCommand } from './commands/agent'; import { chatCommand } from './commands/chat'; import { debugCommand } from './commands/debug'; -import { firebaseCommand } from './commands/firebase'; import { installMCPCommand } from './commands/install-mcp'; -import { runIosSetupCommand } from './commands/ios-setup/index'; import { loginCommand } from './commands/login'; import { logoutCommand } from './commands/logout'; import { resumeCommand } from './commands/resume'; @@ -45,7 +43,6 @@ function generateHelpText(): string { whoami Show current logged-in user agent [name] List or switch AI agents ${localSkillCommands} - firebase Check and configure Firebase credentials debug Interactive debugging assistant install-mcp [agent] Install Clix MCP Server resume Resume a previous session @@ -68,7 +65,6 @@ ${localSkillCommands} $ clix resume $ clix install $ clix doctor - $ clix firebase $ clix debug "Push notifications not working on iOS" $ clix install-mcp $ clix install-mcp claude @@ -104,26 +100,6 @@ const cli = meow(generateHelpText(), { shortFlag: 'f', default: false, }, - // iOS setup flags - apiKey: { - type: 'string', - }, - keyId: { - type: 'string', - }, - issuerId: { - type: 'string', - }, - bundleId: { - type: 'string', - }, - skipPortal: { - type: 'boolean', - default: false, - }, - pushEnv: { - type: 'string', - }, }, }); @@ -203,10 +179,6 @@ async function main() { }); break; - case 'firebase': - await firebaseCommand(); - break; - case 'setup': { const status = await checkFirstRun(); if (status.needsSetup) { @@ -217,27 +189,6 @@ async function main() { break; } - case 'ios-setup': - case 'capabilities': - case 'ios-capabilities': { - const pushEnvRaw = cli.flags.pushEnv; - if (pushEnvRaw && !['development', 'production'].includes(pushEnvRaw)) { - console.error(`Invalid --push-env value: ${pushEnvRaw}`); - console.error('Expected: development | production'); - process.exit(1); - } - const pushEnv = pushEnvRaw as 'development' | 'production' | undefined; - await runIosSetupCommand({ - apiKeyPath: cli.flags.apiKey, - keyId: cli.flags.keyId, - issuerId: cli.flags.issuerId, - bundleId: cli.flags.bundleId, - skipPortal: cli.flags.skipPortal, - pushEnvironment: pushEnv, - }); - break; - } - default: // Check if command is a skill type (dynamically) if (skillTypes.includes(command ?? '')) { diff --git a/src/commands/firebase.tsx b/src/commands/firebase.tsx deleted file mode 100644 index de3b99e..0000000 --- a/src/commands/firebase.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { FirebaseWizard } from '../ui/components/FirebaseWizard'; -import { printFinalOutput } from '../ui/utils/finalOutput'; -import { safeRender } from '../ui/utils/safeRender'; - -/** - * Firebase command - check and configure Firebase credentials - * - * Usage: clix firebase - */ -export async function firebaseCommand(): Promise { - const projectPath = process.cwd(); - - return new Promise((resolve) => { - const { unmount } = safeRender( - { - unmount(); - if (result.skipped) { - printFinalOutput({ - type: 'info', - title: 'Firebase setup', - message: 'Setup skipped', - }); - } else if (result.completed) { - printFinalOutput({ - type: 'success', - title: 'Firebase setup', - message: 'Configuration complete', - }); - } - resolve(); - }} - onCancel={() => { - unmount(); - printFinalOutput({ - type: 'info', - title: 'Firebase setup', - message: 'Setup cancelled', - }); - resolve(); - }} - />, - ); - }); -} diff --git a/src/commands/ios-setup/index.tsx b/src/commands/ios-setup/index.tsx deleted file mode 100644 index af8f5b4..0000000 --- a/src/commands/ios-setup/index.tsx +++ /dev/null @@ -1,525 +0,0 @@ -import { Box, 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'; -import { safeRender } from '../../ui/utils/safeRender'; - -export interface IosSetupCommandOptions { - /** Path to .p8 API Key file */ - apiKeyPath?: string; - /** API Key ID */ - keyId?: string; - /** Issuer ID */ - issuerId?: string; - /** Bundle ID (override auto-detection) */ - bundleId?: string; - /** Skip Apple Developer Portal sync */ - skipPortal?: boolean; - /** Push notification environment */ - 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[] = []; - - if (result.projectInfo) { - details.push(`Project: ${result.projectInfo.appName}`); - details.push(`Bundle ID: ${result.projectInfo.bundleId}`); - } - - if (result.portalSync) { - if (result.portalSync.enabled.length > 0) { - details.push(`Enabled: ${result.portalSync.enabled.join(', ')}`); - } - if (result.portalSync.appGroupCreated && result.portalSync.appGroupId) { - details.push(`Created App Group: ${result.portalSync.appGroupId}`); - } - } - - if (result.entitlementsUpdated.length > 0) { - details.push(`Updated files: ${result.entitlementsUpdated.length}`); - } - - return { - type: 'success', - title: 'Direct setup completed', - message: result.agentContext - ? 'Portal sync and entitlements configured. Proceeding to extension setup...' - : 'Portal sync and entitlements configured.', - details: details.length > 0 ? details : undefined, - }; - } - - return { - type: 'error', - title: 'iOS setup failed', - message: result.error || 'Unknown error occurred', - }; -} - -/** - * Run the direct implementation phase (Portal sync + Entitlements) - */ -async function runDirectSetup(options: IosSetupCommandOptions): Promise { - const uiOptions: IosSetupOptions = { - apiKeyPath: options.apiKeyPath, - keyId: options.keyId, - issuerId: options.issuerId, - bundleId: options.bundleId, - skipPortal: options.skipPortal ?? (!options.apiKeyPath && !options.keyId && !options.issuerId), - pushEnvironment: options.pushEnvironment, - }; - - return new Promise((resolve) => { - const { unmount } = safeRender( - { - unmount(); - resolve(result); - }} - />, - ); - }); -} - -/** - * UI component for showing project modification progress. - */ -function ProjectModificationUI({ - status, - warnings, -}: { - status: string; - warnings: string[]; -}): React.ReactElement { - return ( - - - - - - {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 runProjectModification( - directResult: IosSetupResult, - pushEnvironment?: 'development' | 'production', -): Promise { - const result: ProjectModificationResult = { - success: false, - extensionFilesCreated: false, - pbxprojModified: false, - podfileModified: false, - createdFiles: [], - warnings: [], - requiresManualSteps: false, - }; - - if (!directResult.agentContext) { - result.success = true; - return result; - } - - // Render progress UI - const displayWarnings: string[] = []; - let currentStatus = 'Creating extension files...'; - - const { unmount, rerender } = safeRender( - , - ); - - 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 } = safeRender( - { - unmount(); - resolve(result); - }} - />, - ); - }); -} - -/** - * 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 } = safeRender( - { - unmount(); - resolve(true); - }} - onNo={() => { - unmount(); - resolve(false); - }} - />, - ); - }); -} - -/** - * Run the push setup wizard (Phase 3) - */ -async function runPushSetup(directResult: IosSetupResult): Promise { - const projectPath = process.cwd(); - - return new Promise((resolve) => { - const { unmount } = safeRender( - { - unmount(); - resolve(result); - }} - onCancel={() => { - unmount(); - resolve(null); - }} - />, - ); - }); -} - -export async function runIosSetupCommand(options: IosSetupCommandOptions): Promise { - // Phase 1: Direct implementation (Portal sync + Entitlements) - const directResult = await runDirectSetup(options); - - if (!directResult.success) { - printFinalOutput(toDirectSetupOutput(directResult)); - return; - } - - // Show direct setup completion - printFinalOutput(toDirectSetupOutput(directResult)); - - // Phase 2: Automated project modification (pbxproj + Podfile) - let modificationResult: ProjectModificationResult | undefined; - let guidedResult: GuidedSetupResult | undefined; - - if (directResult.agentContext) { - 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); - } - } -} - -/** - * 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/commands/skill/__tests__/preparation.test.ts b/src/commands/skill/__tests__/preparation.test.ts new file mode 100644 index 0000000..b4162e4 --- /dev/null +++ b/src/commands/skill/__tests__/preparation.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { ProjectType } from '@/lib/config'; +import { checkFirebaseStatus, checkIosStatus } from '../preparation'; + +// Mock the dependencies +const mockFirebaseService = { + detect: mock(() => + Promise.resolve({ + platform: 'react-native' as string, + android: { valid: true, content: { project_info: { project_id: 'test-project' } } } as { + valid: boolean; + content: Record; + } | null, + ios: { valid: true, content: { PROJECT_ID: 'test-project' } } as { + valid: boolean; + content: Record; + } | null, + configured: true, + issues: [] as unknown[], + projectPath: '/test', + }), + ), + getStatus: mock(() => + Promise.resolve({ + status: 'configured', + androidConfigured: true, + iosConfigured: true, + issueCount: 0, + errorCount: 0, + warningCount: 0, + }), + ), +}; + +mock.module('@/lib/services/firebase/firebase-service', () => ({ + FirebaseService: class { + detect = mockFirebaseService.detect; + getStatus = mockFirebaseService.getStatus; + }, +})); + +describe('preparation', () => { + beforeEach(() => { + mockFirebaseService.detect.mockClear(); + mockFirebaseService.getStatus.mockClear(); + }); + + describe('checkIosStatus', () => { + test('should return needed=false for Android-only projects', async () => { + const projectType: ProjectType = { framework: 'native', target: 'android' }; + const status = await checkIosStatus('/test', projectType); + + expect(status.needed).toBe(false); + expect(status.entitlementsConfigured).toBe(true); + expect(status.nseConfigured).toBe(true); + }); + + test('should return needed=true for iOS projects', async () => { + const projectType: ProjectType = { framework: 'native', target: 'ios' }; + const status = await checkIosStatus('/test', projectType); + + expect(status.needed).toBe(true); + expect(status.entitlementsConfigured).toBe(false); + expect(status.nseConfigured).toBe(false); + }); + + test('should return needed=true for cross-platform projects', async () => { + const projectType: ProjectType = { framework: 'react-native', target: 'ios-android' }; + const status = await checkIosStatus('/test', projectType); + + expect(status.needed).toBe(true); + }); + + test('should use existing setup status from config', async () => { + const projectType: ProjectType = { framework: 'native', target: 'ios' }; + const setup = { + ios: { + bundleId: 'com.test.app', + teamId: 'ABC123', + appGroupId: 'group.com.test.app', + entitlementsConfigured: true, + nseConfigured: true, + }, + }; + + const status = await checkIosStatus('/test', projectType, setup); + + expect(status.needed).toBe(true); + expect(status.bundleId).toBe('com.test.app'); + expect(status.teamId).toBe('ABC123'); + expect(status.appGroupId).toBe('group.com.test.app'); + expect(status.entitlementsConfigured).toBe(true); + expect(status.nseConfigured).toBe(true); + }); + }); + + describe('checkFirebaseStatus', () => { + test('should return needed=false for unknown target', async () => { + const projectType: ProjectType = { framework: 'unknown', target: 'unknown' }; + const status = await checkFirebaseStatus('/test', projectType); + + expect(status.needed).toBe(false); + expect(status.configured).toBe(true); + }); + + test('should always detect files even if setup config exists', async () => { + const projectType: ProjectType = { framework: 'react-native', target: 'ios-android' }; + const setup = { + firebase: { + projectId: 'my-project', + androidConfigured: true, + iosConfigured: true, + }, + }; + + const status = await checkFirebaseStatus('/test', projectType, setup); + + // Should always run file detection regardless of cached setup + expect(mockFirebaseService.detect).toHaveBeenCalled(); + expect(mockFirebaseService.getStatus).toHaveBeenCalled(); + expect(status.configured).toBe(true); + // Project ID from detected files takes precedence over cached config + expect(status.projectId).toBe('test-project'); + }); + + test('should fallback to cached projectId when files have no project ID', async () => { + const projectType: ProjectType = { framework: 'react-native', target: 'ios-android' }; + const setup = { + firebase: { + projectId: 'my-project', + androidConfigured: true, + iosConfigured: true, + }, + }; + + mockFirebaseService.detect.mockResolvedValueOnce({ + platform: 'react-native', + android: null, + ios: null, + configured: true, + issues: [], + projectPath: '/test', + }); + + const status = await checkFirebaseStatus('/test', projectType, setup); + + expect(status.projectId).toBe('my-project'); + }); + + test('should detect Firebase config when not in setup', async () => { + const projectType: ProjectType = { framework: 'react-native', target: 'ios-android' }; + + const status = await checkFirebaseStatus('/test', projectType); + + expect(status.needed).toBe(true); + expect(mockFirebaseService.detect).toHaveBeenCalled(); + expect(mockFirebaseService.getStatus).toHaveBeenCalled(); + }); + + test('should check only Android for Android-only projects', async () => { + const projectType: ProjectType = { framework: 'native', target: 'android' }; + + mockFirebaseService.getStatus.mockResolvedValueOnce({ + status: 'configured', + androidConfigured: true, + iosConfigured: false, + issueCount: 0, + errorCount: 0, + warningCount: 0, + }); + + const status = await checkFirebaseStatus('/test', projectType); + + expect(status.configured).toBe(true); + expect(status.androidConfigured).toBe(true); + // iOS not needed, so configured is true + expect(status.iosConfigured).toBe(true); + }); + + test('should check only iOS for iOS-only projects', async () => { + const projectType: ProjectType = { framework: 'native', target: 'ios' }; + + mockFirebaseService.getStatus.mockResolvedValueOnce({ + status: 'configured', + androidConfigured: false, + iosConfigured: true, + issueCount: 0, + errorCount: 0, + warningCount: 0, + }); + + const status = await checkFirebaseStatus('/test', projectType); + + expect(status.configured).toBe(true); + // Android not needed, so configured is true + expect(status.androidConfigured).toBe(true); + expect(status.iosConfigured).toBe(true); + }); + }); +}); diff --git a/src/commands/skill/index.tsx b/src/commands/skill/index.tsx index 5bbd8bd..26d18aa 100644 --- a/src/commands/skill/index.tsx +++ b/src/commands/skill/index.tsx @@ -7,8 +7,10 @@ import { type SkillType, } from '../../lib/skills'; import { AgentExecutionUI } from '../../ui/AgentExecutionUI'; +import { InstallPreparationUI } from '../../ui/components/InstallPreparationUI'; import { printFinalOutput } from '../../ui/utils/finalOutput'; import { safeRender } from '../../ui/utils/safeRender'; +import type { PreparationContext } from './preparation'; interface SkillCommandOptions { action?: string; @@ -54,6 +56,27 @@ Examples: `; } +/** + * Run install preparation UI and return the context. + */ +async function runInstallPreparation(projectPath: string): Promise { + return new Promise((resolve) => { + const { unmount } = safeRender( + { + unmount(); + resolve(context); + }} + onCancel={() => { + unmount(); + resolve(null); + }} + />, + ); + }); +} + export async function skillCommand(options: SkillCommandOptions): Promise { const { action, platform } = options; @@ -83,13 +106,26 @@ export async function skillCommand(options: SkillCommandOptions): Promise } const skillType = action as SkillType; + const projectPath = process.cwd(); + + // For install skill, run preparation first + let preparationContext: PreparationContext | undefined; + if (skillType === 'install') { + const context = await runInstallPreparation(projectPath); + if (!context) { + // User cancelled or config missing + return; + } + preparationContext = context; + } // Create execute function that wraps executeSkill async function* executeCommand(executor: AgentExecutor): AsyncGenerator { yield* executeSkill(skillType, executor, { - projectPath: process.cwd(), + projectPath, platform, oneShot: true, + preparationContext, }); } diff --git a/src/commands/skill/preparation.ts b/src/commands/skill/preparation.ts new file mode 100644 index 0000000..615d3e5 --- /dev/null +++ b/src/commands/skill/preparation.ts @@ -0,0 +1,345 @@ +/** + * Install preparation module. + * + * Handles all preparation steps before the AI agent is invoked for SDK installation. + * Ensures config.jsonc is present and all required setup (Firebase, iOS) is complete. + * + * @module commands/skill/preparation + */ + +import { + getProjectConfigManager, + type ProjectConfig, + type ProjectType, + type SetupStatus, +} from '@/lib/config'; +import { FirebaseService } from '@/lib/services/firebase/firebase-service'; +import { detectProjectType } from '@/lib/services/project-detector'; + +/** + * Status of Firebase configuration. + */ +export interface FirebaseStatus { + /** Whether Firebase is configured */ + configured: boolean; + /** Whether Android config (google-services.json) exists and is valid */ + androidConfigured: boolean; + /** Whether iOS config (GoogleService-Info.plist) exists and is valid */ + iosConfigured: boolean; + /** Firebase project ID if detected */ + projectId?: string; + /** Whether Firebase setup is needed based on project type */ + needed: boolean; +} + +/** + * Status of iOS configuration. + */ +export interface IosStatus { + /** Whether iOS setup is needed based on project type */ + needed: boolean; + /** Bundle ID if detected */ + bundleId?: string; + /** Team ID if detected */ + teamId?: string; + /** App Group ID if configured */ + appGroupId?: string; + /** Whether entitlements are configured */ + entitlementsConfigured: boolean; + /** Whether NSE is configured */ + nseConfigured: boolean; +} + +/** + * Context passed to the install skill after preparation. + */ +export interface PreparationContext { + /** Project root path */ + projectPath: string; + /** Loaded and migrated config */ + config: ProjectConfig; + /** Detected or loaded project type */ + projectType: ProjectType; + /** Firebase configuration status */ + firebase: FirebaseStatus; + /** iOS configuration status */ + ios: IosStatus; + /** Whether all required preparations are complete */ + ready: boolean; + /** List of missing preparations */ + missing: string[]; +} + +/** + * Result of checking if project is linked (config exists). + */ +export interface ProjectLinkStatus { + /** Whether project is linked */ + linked: boolean; + /** Config if linked */ + config?: ProjectConfig; + /** Error message if not linked */ + error?: string; +} + +/** + * Check if the project is linked (config.jsonc exists). + * + * @param projectPath - Path to the project root + * @returns Link status with config if available + */ +export async function checkProjectLinked(projectPath?: string): Promise { + const manager = getProjectConfigManager(projectPath); + + try { + const config = await manager.load(); + + if (!config) { + return { + linked: false, + error: 'Project not linked. Run "clix login" first.', + }; + } + + return { + linked: true, + config, + }; + } catch (error) { + return { + linked: false, + error: error instanceof Error ? error.message : 'Failed to load project config', + }; + } +} + +/** + * Ensure project type is detected. + * If not present in config, auto-detect and save it. + * + * @param config - Current config + * @param projectPath - Path to the project root + * @returns Updated config with project type + */ +export async function ensureProjectType( + config: ProjectConfig, + projectPath: string, +): Promise<{ config: ProjectConfig; projectType: ProjectType }> { + if (config.projectType) { + return { config, projectType: config.projectType }; + } + + // Auto-detect project type + const projectType = await detectProjectType(projectPath); + + // Save to config + const manager = getProjectConfigManager(projectPath); + const updatedConfig: ProjectConfig = { + ...config, + projectType, + }; + await manager.save(updatedConfig); + + return { config: updatedConfig, projectType }; +} + +/** + * Check Firebase configuration status. + * + * @param projectPath - Path to the project root + * @param projectType - Detected project type + * @param setup - Current setup status from config + * @returns Firebase status + */ +export async function checkFirebaseStatus( + projectPath: string, + projectType: ProjectType, + setup?: SetupStatus, +): Promise { + // Determine if Firebase is needed based on project type + const needed = projectType.target !== 'unknown'; + + if (!needed) { + return { + configured: true, + androidConfigured: true, + iosConfigured: true, + needed: false, + }; + } + + // Always detect actual Firebase config files on disk + const firebaseService = new FirebaseService(projectPath, projectType); + const detection = await firebaseService.detect(); + const status = await firebaseService.getStatus(); + + // Determine what's needed based on target platform + const needsAndroid = projectType.target === 'android' || projectType.target === 'ios-android'; + const needsIos = projectType.target === 'ios' || projectType.target === 'ios-android'; + + const androidConfigured = !needsAndroid || status.androidConfigured; + const iosConfigured = !needsIos || status.iosConfigured; + + // Extract project ID from detected files, fallback to cached setup + let projectId: string | undefined; + if (detection.android?.content && 'project_info' in detection.android.content) { + projectId = detection.android.content.project_info?.project_id; + } else if (detection.ios?.content && 'PROJECT_ID' in detection.ios.content) { + projectId = detection.ios.content.PROJECT_ID; + } else if (setup?.firebase?.projectId) { + projectId = setup.firebase.projectId; + } + + return { + configured: androidConfigured && iosConfigured, + androidConfigured, + iosConfigured, + projectId, + needed: true, + }; +} + +/** + * Check iOS configuration status. + * + * @param projectPath - Path to the project root + * @param projectType - Detected project type + * @param setup - Current setup status from config + * @returns iOS status + */ +export async function checkIosStatus( + _projectPath: string, + projectType: ProjectType, + setup?: SetupStatus, +): Promise { + // iOS setup is only needed for iOS or cross-platform targets + const needed = projectType.target === 'ios' || projectType.target === 'ios-android'; + + if (!needed) { + return { + needed: false, + entitlementsConfigured: true, + nseConfigured: true, + }; + } + + // Check setup status from config + if (setup?.ios) { + return { + needed: true, + bundleId: setup.ios.bundleId, + teamId: setup.ios.teamId, + appGroupId: setup.ios.appGroupId, + entitlementsConfigured: setup.ios.entitlementsConfigured, + nseConfigured: setup.ios.nseConfigured, + }; + } + + // No iOS setup recorded + return { + needed: true, + entitlementsConfigured: false, + nseConfigured: false, + }; +} + +/** + * Gather preparation context without running interactive setup. + * This checks what's configured and what's missing. + * + * @param projectPath - Path to the project root (defaults to cwd) + * @returns Preparation context with status information + */ +export async function gatherPreparationContext( + projectPath: string = process.cwd(), +): Promise { + // Step 1: Check if project is linked + const linkStatus = await checkProjectLinked(projectPath); + if (!linkStatus.linked || !linkStatus.config) { + return null; + } + + const config = linkStatus.config; + + // Step 2: Ensure project type is detected + const { config: updatedConfig, projectType } = await ensureProjectType(config, projectPath); + + // Step 3: Check Firebase status + const firebase = await checkFirebaseStatus(projectPath, projectType, updatedConfig.setup); + + // Step 4: Check iOS status + const ios = await checkIosStatus(projectPath, projectType, updatedConfig.setup); + + // Step 5: Determine what's missing + const missing: string[] = []; + + if (firebase.needed && !firebase.configured) { + if (!firebase.androidConfigured) { + missing.push('Firebase Android config (google-services.json)'); + } + if (!firebase.iosConfigured) { + missing.push('Firebase iOS config (GoogleService-Info.plist)'); + } + } + + if (ios.needed) { + if (!ios.entitlementsConfigured) { + missing.push('iOS entitlements'); + } + if (!ios.nseConfigured) { + missing.push('Notification Service Extension'); + } + } + + const ready = missing.length === 0; + + return { + projectPath, + config: updatedConfig, + projectType, + firebase, + ios, + ready, + missing, + }; +} + +/** + * Update config with setup status after preparation is complete. + * + * @param projectPath - Path to the project root + * @param firebase - Firebase status to save + * @param ios - iOS status to save + */ +export async function saveSetupStatus( + projectPath: string, + firebase: FirebaseStatus, + ios: IosStatus, +): Promise { + const manager = getProjectConfigManager(projectPath); + const now = new Date().toISOString(); + + const setupUpdate: SetupStatus = {}; + + if (firebase.needed) { + setupUpdate.firebase = { + projectId: firebase.projectId, + androidConfigured: firebase.androidConfigured, + iosConfigured: firebase.iosConfigured, + completedAt: firebase.configured ? now : undefined, + }; + } + + if (ios.needed) { + setupUpdate.ios = { + bundleId: ios.bundleId, + teamId: ios.teamId, + appGroupId: ios.appGroupId, + entitlementsConfigured: ios.entitlementsConfigured, + nseConfigured: ios.nseConfigured, + completedAt: ios.entitlementsConfigured && ios.nseConfigured ? now : undefined, + }; + } + + await manager.updateSetup(setupUpdate); +} diff --git a/src/lib/__tests__/skills.test.ts b/src/lib/__tests__/skills.test.ts index 28271b8..2d4eea7 100644 --- a/src/lib/__tests__/skills.test.ts +++ b/src/lib/__tests__/skills.test.ts @@ -267,6 +267,176 @@ describe('SkillOptions', () => { }); }); +describe('preparationContext in install skill', () => { + /** + * These tests verify that preparationContext is properly integrated into the install skill prompt. + * This covers the functionality that was previously in standalone firebase and ios-setup commands. + */ + + test('install skill should include pre-configured setup section when preparationContext is provided', async () => { + const { getSkillPrompt } = await import('../skills'); + const preparationContext = { + projectPath: '/test/project', + config: { + version: 2 as const, + member: { id: 'member-1', email: 'test@example.com', name: 'Test User' }, + organization: { id: 'org-1', name: 'Test Org' }, + project: { id: 'project-1', name: 'Test Project', publicKey: 'pk_test_123' }, + linkedAt: '2024-01-01T00:00:00Z', + }, + projectType: { framework: 'react-native' as const, target: 'ios-android' as const }, + firebase: { + needed: true, + configured: true, + androidConfigured: true, + iosConfigured: true, + projectId: 'my-firebase-project', + }, + ios: { + needed: true, + bundleId: 'com.test.app', + teamId: 'TEAM123', + appGroupId: 'group.com.test.app', + entitlementsConfigured: true, + nseConfigured: false, + }, + missing: ['Notification Service Extension'], + ready: false, + }; + + const prompt = await getSkillPrompt('install', { + projectPath: '/test/project', + preparationContext, + }); + + // Verify pre-configured setup section is included + expect(prompt).toContain('Pre-configured Setup'); + expect(prompt).toContain('Test Project'); + + // Verify Clix project info + expect(prompt).toContain('Clix Project'); + expect(prompt).toContain('project-1'); + expect(prompt).toContain('pk_test_123'); + + // Verify Firebase info + expect(prompt).toContain('Firebase'); + expect(prompt).toContain('my-firebase-project'); + expect(prompt).toContain('Android (google-services.json)'); + expect(prompt).toContain('iOS (GoogleService-Info.plist)'); + + // Verify iOS info + expect(prompt).toContain('iOS'); + expect(prompt).toContain('com.test.app'); + expect(prompt).toContain('TEAM123'); + expect(prompt).toContain('group.com.test.app'); + expect(prompt).toContain('Entitlements'); + expect(prompt).toContain('NSE'); + + // Verify missing items + expect(prompt).toContain('Missing Setup'); + expect(prompt).toContain('Notification Service Extension'); + }); + + test('install skill should not include iOS section when iOS is not needed', async () => { + const { getSkillPrompt } = await import('../skills'); + const preparationContext = { + projectPath: '/test/project', + config: { + version: 2 as const, + member: { id: 'member-1', email: 'test@example.com', name: 'Test User' }, + organization: { id: 'org-1', name: 'Test Org' }, + project: { id: 'project-1', name: 'Android Project' }, + linkedAt: '2024-01-01T00:00:00Z', + }, + projectType: { framework: 'native' as const, target: 'android' as const }, + firebase: { + needed: true, + configured: true, + androidConfigured: true, + iosConfigured: true, + projectId: 'android-project', + }, + ios: { + needed: false, + bundleId: undefined, + teamId: undefined, + appGroupId: undefined, + entitlementsConfigured: true, + nseConfigured: true, + }, + missing: [], + ready: true, + }; + + const prompt = await getSkillPrompt('install', { + projectPath: '/test/project', + preparationContext, + }); + + // Should have Firebase section + expect(prompt).toContain('Firebase'); + expect(prompt).toContain('android-project'); + + // Should NOT have iOS section (needed=false) + const iosHeaderMatch = prompt.match(/### iOS\n/); + expect(iosHeaderMatch).toBeNull(); + }); + + test('install skill should not include Firebase section when Firebase is not needed', async () => { + const { getSkillPrompt } = await import('../skills'); + const preparationContext = { + projectPath: '/test/project', + config: { + version: 2 as const, + member: { id: 'member-1', email: 'test@example.com', name: 'Test User' }, + organization: { id: 'org-1', name: 'Test Org' }, + project: { id: 'project-1', name: 'Unknown Project' }, + linkedAt: '2024-01-01T00:00:00Z', + }, + projectType: { framework: 'unknown' as const, target: 'unknown' as const }, + firebase: { + needed: false, + configured: true, + androidConfigured: true, + iosConfigured: true, + projectId: undefined, + }, + ios: { + needed: false, + bundleId: undefined, + teamId: undefined, + appGroupId: undefined, + entitlementsConfigured: true, + nseConfigured: true, + }, + missing: [], + ready: true, + }; + + const prompt = await getSkillPrompt('install', { + projectPath: '/test/project', + preparationContext, + }); + + // Should NOT have Firebase section (needed=false) + const firebaseHeaderMatch = prompt.match(/### Firebase\n/); + expect(firebaseHeaderMatch).toBeNull(); + }); + + test('install skill without preparationContext should work normally', async () => { + const { getSkillPrompt } = await import('../skills'); + + const prompt = await getSkillPrompt('install', { + projectPath: '/test/project', + }); + + // Should have basic info but no pre-configured setup section + expect(prompt).toContain('Project path: /test/project'); + // Pre-configured Setup section should not appear without context + expect(prompt).not.toContain('### Clix Project'); + }); +}); + describe('error handling', () => { describe('getSkillPrompt error messages', () => { test('should return clear error message when skills package is missing', async () => { diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 5ea9482..0bca7b7 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -10,7 +10,9 @@ export type { ApiErrorResponse, ApiListResponse, ApiResponse, + AppPushSenderConfig, Member, Organization, Project, + SenderConfig, } from './types'; diff --git a/src/lib/api/internal-client.ts b/src/lib/api/internal-client.ts index f623033..d3ee91f 100644 --- a/src/lib/api/internal-client.ts +++ b/src/lib/api/internal-client.ts @@ -1,12 +1,19 @@ import { getConsoleUrl, getCredentialsManager } from '../auth'; import { AuthError } from '../auth/errors'; import { NetworkError } from '../errors/types'; -import type { Member, Organization, Project } from './types'; +import type { Member, Organization, Project, SenderConfig } from './types'; /** * Internal API proxy prefix on Console. */ const API_PROXY_PREFIX = '/api/clix/internal'; +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; + +interface InternalApiRequestOptions { + authToken?: string; + timeoutMs?: number; + maxRetries?: number; +} /** * Internal API client that communicates through Console's proxy endpoint. @@ -29,6 +36,24 @@ export class InternalApiClient { this.baseUrl = (consoleUrl ?? getConsoleUrl()) + API_PROXY_PREFIX; } + private async resolveAccessToken(authToken?: string): Promise { + if (authToken) return authToken; + + const credentialsManager = getCredentialsManager(); + const token = await credentialsManager.getValidToken(); + if (!token) { + throw AuthError.notLoggedIn(); + } + return token; + } + + private shouldRetry(error: unknown): boolean { + if (!(error instanceof NetworkError)) return false; + if (error.statusCode === 429) return true; + if (typeof error.statusCode === 'number' && error.statusCode >= 500) return true; + return error.statusCode === undefined; + } + /** * Make an authenticated request to Internal API. * @@ -38,47 +63,64 @@ export class InternalApiClient { * @throws AuthError if not authenticated * @throws NetworkError if request fails */ - private async request(endpoint: string, options: RequestInit = {}): Promise { - const credentialsManager = getCredentialsManager(); - const token = await credentialsManager.getValidToken(); - - if (!token) { - throw AuthError.notLoggedIn(); - } - + private async request( + endpoint: string, + options: RequestInit = {}, + requestOptions: InternalApiRequestOptions = {}, + ): Promise { + const token = await this.resolveAccessToken(requestOptions.authToken); + const timeoutMs = requestOptions.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const maxRetries = requestOptions.maxRetries ?? 0; const url = `${this.baseUrl}${endpoint}`; - const headers = new Headers(options.headers); - headers.set('Authorization', `Bearer ${token}`); - headers.set('Content-Type', 'application/json'); - - try { - const response = await fetch(url, { - ...options, - headers, - }); - - if (!response.ok) { - if (response.status === 401) { - throw AuthError.tokenExpired(); + let attempt = 0; + + while (true) { + const headers = new Headers(options.headers); + headers.set('Authorization', `Bearer ${token}`); + headers.set('Content-Type', 'application/json'); + + try { + const response = await fetch(url, { + ...options, + headers, + signal: AbortSignal.timeout(timeoutMs), + }); + + if (!response.ok) { + if (response.status === 401) { + throw AuthError.tokenExpired(); + } + + const errorText = await response.text().catch(() => ''); + throw new NetworkError( + `API request failed: ${response.status} ${errorText}`, + url, + response.status, + ); } - const errorText = await response.text().catch(() => ''); - throw new NetworkError( - `API request failed: ${response.status} ${errorText}`, - url, - response.status, - ); - } + return response.json() as Promise; + } catch (error) { + if (error instanceof AuthError) { + throw error; + } + + const normalizedError = + error instanceof NetworkError + ? error + : new NetworkError( + `Failed to connect to API: ${error instanceof Error ? error.message : 'Unknown error'}`, + url, + ); + + if (attempt >= maxRetries || !this.shouldRetry(normalizedError)) { + throw normalizedError; + } - return response.json() as Promise; - } catch (error) { - if (error instanceof AuthError || error instanceof NetworkError) { - throw error; + attempt += 1; + const backoffMs = 150 * 2 ** (attempt - 1); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); } - throw new NetworkError( - `Failed to connect to API: ${error instanceof Error ? error.message : 'Unknown error'}`, - url, - ); } } @@ -97,8 +139,12 @@ export class InternalApiClient { * * @returns List of organizations */ - async listOrganizations(): Promise { - const response = await this.request<{ organizations: Organization[] }>('/api/v1/organizations'); + async listOrganizations(options?: InternalApiRequestOptions): Promise { + const response = await this.request<{ organizations: Organization[] }>( + '/api/v1/organizations', + {}, + options, + ); return response.organizations; } @@ -108,12 +154,55 @@ export class InternalApiClient { * @param organizationId - Organization ID * @returns List of projects */ - async listProjects(organizationId: string): Promise { + async listProjects( + organizationId: string, + options?: InternalApiRequestOptions, + ): Promise { const response = await this.request<{ projects: Project[] }>( `/api/v1/organizations/${organizationId}/projects`, + {}, + options, ); return response.projects; } + + /** + * Get project details including sender configs. + * + * @param projectId - Project ID + * @returns Project with sender_configs + */ + async getProject(projectId: string, options?: InternalApiRequestOptions): Promise { + const response = await this.request<{ project: Project }>( + `/api/v1/projects/${projectId}`, + {}, + options, + ); + return response.project; + } + + /** + * Create or update sender config for push notifications. + * + * @param projectId - Project ID + * @param senderConfig - Sender configuration + * @returns Updated sender config + */ + async createOrUpdateSenderConfig( + projectId: string, + senderConfig: SenderConfig, + options?: InternalApiRequestOptions, + ): Promise { + const response = await this.request<{ sender_config: SenderConfig }>( + `/api/v1/projects/${projectId}/sender-configs`, + { + method: 'POST', + body: JSON.stringify({ sender_config: senderConfig }), + }, + options, + ); + return response.sender_config; + } } /** diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 341415a..bb62ecb 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -29,6 +29,25 @@ export interface Project { name: string; organization_id: string; public_key?: string; + sender_configs?: SenderConfig[]; + created_at?: string; + updated_at?: string; +} + +/** + * App push sender configuration for FCM. + */ +export interface AppPushSenderConfig { + ios_config?: { fcm_sa_json_base64_encoded: string }; + android_config?: { fcm_sa_json_base64_encoded: string }; +} + +/** + * Sender configuration for push notifications. + */ +export interface SenderConfig { + channel_type: string; + app_push?: AppPushSenderConfig; created_at?: string; updated_at?: string; } diff --git a/src/lib/auth/credentials.ts b/src/lib/auth/credentials.ts index 1dd842e..c86bedb 100644 --- a/src/lib/auth/credentials.ts +++ b/src/lib/auth/credentials.ts @@ -362,16 +362,13 @@ export class CredentialsManager { /** * Clear only Firebase tokens (keep Clix credentials). + * Does not delete the credentials file, only removes the firebase field. */ async clearFirebaseTokens(): Promise { const current = await this.load(); - if (current) { + if (current?.firebase) { const { firebase: _, ...rest } = current; - if (rest.clix) { - await this.save({ ...rest, version: CREDENTIALS_VERSION }); - } else { - await this.delete(); - } + await this.save({ ...rest, version: CREDENTIALS_VERSION }); } } diff --git a/src/lib/commands/__tests__/registry.test.ts b/src/lib/commands/__tests__/registry.test.ts index 46cd257..e823367 100644 --- a/src/lib/commands/__tests__/registry.test.ts +++ b/src/lib/commands/__tests__/registry.test.ts @@ -145,25 +145,6 @@ 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/firebase.tsx b/src/lib/commands/firebase.tsx deleted file mode 100644 index af2d1f5..0000000 --- a/src/lib/commands/firebase.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Firebase command - check and configure Firebase credentials. - * - * @module commands/firebase - */ - -import type { ReactNode } from 'react'; -import { FirebaseWizard } from '@/ui/components/FirebaseWizard'; -import type { Command, CommandDoneCallback } from './types'; - -/** - * Firebase command implementation. - * Opens the Firebase configuration wizard for detecting and validating credentials. - */ -export const firebaseCommand: Command = { - type: 'local-jsx', - name: 'firebase', - description: 'Check and configure Firebase credentials', - isEnabled: true, - isHidden: false, - - userFacingName() { - return '/firebase'; - }, - - async call(onDone: CommandDoneCallback): Promise { - const projectPath = process.cwd(); - - return ( - { - if (result.skipped) { - onDone('Firebase setup skipped'); - } else if (result.completed) { - onDone('Firebase configuration complete'); - } else { - onDone(); - } - }} - onCancel={() => { - onDone('Firebase setup cancelled'); - }} - /> - ); - }, -}; diff --git a/src/lib/commands/ios-setup.tsx b/src/lib/commands/ios-setup.tsx deleted file mode 100644 index ef72cb4..0000000 --- a/src/lib/commands/ios-setup.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * 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 0f50d11..ee5415a 100644 --- a/src/lib/commands/registry.ts +++ b/src/lib/commands/registry.ts @@ -9,10 +9,8 @@ import { agentCommand } from './agent'; import { compactCommand } from './compact'; import { debugCommand } from './debug'; 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,8 +32,6 @@ const BUILT_IN_COMMANDS: Command[] = [ compactCommand, agentCommand, debugCommand, - firebaseCommand, - iosSetupCommand, transferCommand, resumeCommand, installMcpCommand, diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index b72f115..24840b2 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -8,7 +8,14 @@ export { ConfigManager, getConfigManager, resetConfigManager } from './manager'; // Project-local configuration export { getProjectConfigManager, ProjectConfigManager } from './project-config-manager'; export { + type ApnsSetup, + ApnsSetupSchema, CURRENT_PROJECT_CONFIG_VERSION, + ensureLatestVersion, + type FirebaseSetup, + FirebaseSetupSchema, + type IosSetup, + IosSetupSchema, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME, type ProjectConfig, @@ -23,6 +30,8 @@ export { type ProjectTargetPlatform, type ProjectType, ProjectTypeSchema, + type SetupStatus, + SetupStatusSchema, safeValidateProjectConfig, validateProjectConfig, } from './project-config-schema'; diff --git a/src/lib/config/project-config-manager.ts b/src/lib/config/project-config-manager.ts index 4273c46..e8d350e 100644 --- a/src/lib/config/project-config-manager.ts +++ b/src/lib/config/project-config-manager.ts @@ -2,9 +2,12 @@ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { ConfigError, ERROR_CODES } from '../errors/types'; import { + CURRENT_PROJECT_CONFIG_VERSION, + ensureLatestVersion, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME, type ProjectConfig, + type SetupStatus, safeValidateProjectConfig, } from './project-config-schema'; @@ -99,6 +102,7 @@ export class ProjectConfigManager { /** * Load project configuration from disk. * Returns null if config doesn't exist. + * Automatically migrates older versions to latest. * * @returns ProjectConfig or null if not found */ @@ -126,8 +130,16 @@ export class ProjectConfigManager { ); } - this.cachedConfig = validatedConfig; - return validatedConfig; + // Migrate to latest version if needed + const migratedConfig = ensureLatestVersion(validatedConfig); + + // Save migrated config if version changed + if (validatedConfig.version !== CURRENT_PROJECT_CONFIG_VERSION) { + await this.save(migratedConfig); + } + + this.cachedConfig = migratedConfig; + return migratedConfig; } catch (error) { // File doesn't exist - return null if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { @@ -152,6 +164,33 @@ export class ProjectConfigManager { } } + /** + * Update the setup status in config. + * Creates setup object if it doesn't exist. + * + * @param updates - Partial setup status to merge + */ + async updateSetup(updates: Partial): Promise { + const config = await this.load(); + if (!config) { + throw new ConfigError( + 'Project config not found. Run "clix login" first.', + ERROR_CODES.PROJECT_CONFIG_NOT_FOUND, + this.configFilePath, + ); + } + + const updatedConfig: ProjectConfig = { + ...config, + setup: { + ...config.setup, + ...updates, + }, + }; + + await this.save(updatedConfig); + } + /** * Save project configuration to disk. * Creates the .clix directory if it doesn't exist. diff --git a/src/lib/config/project-config-schema.ts b/src/lib/config/project-config-schema.ts index cec3823..639cf05 100644 --- a/src/lib/config/project-config-schema.ts +++ b/src/lib/config/project-config-schema.ts @@ -56,13 +56,71 @@ export const ProjectInfoSchema = z.object({ publicKey: z.string().optional(), }); +/** + * Schema for iOS setup status. + */ +export const IosSetupSchema = z.object({ + /** iOS Bundle ID */ + bundleId: z.string().optional(), + /** Apple Team ID */ + teamId: z.string().optional(), + /** App Group ID for sharing data between app and extensions */ + appGroupId: z.string().optional(), + /** Whether entitlements have been configured */ + entitlementsConfigured: z.boolean(), + /** Whether Notification Service Extension has been configured */ + nseConfigured: z.boolean(), + /** ISO timestamp when iOS setup was completed */ + completedAt: z.string().datetime().optional(), +}); + +/** + * Schema for Firebase setup status. + */ +export const FirebaseSetupSchema = z.object({ + /** Firebase project ID */ + projectId: z.string().optional(), + /** Whether Android config (google-services.json) is configured */ + androidConfigured: z.boolean(), + /** Whether iOS config (GoogleService-Info.plist) is configured */ + iosConfigured: z.boolean(), + /** ISO timestamp when Firebase setup was completed */ + completedAt: z.string().datetime().optional(), +}); + +/** + * Schema for APNS setup status. + */ +export const ApnsSetupSchema = z.object({ + /** APNS Key ID */ + keyId: z.string().optional(), + /** Apple Team ID for APNS */ + teamId: z.string().optional(), + /** Whether APNS key has been registered with Firebase */ + registeredWithFirebase: z.boolean(), + /** ISO timestamp when APNS setup was completed */ + completedAt: z.string().datetime().optional(), +}); + +/** + * Schema for setup status tracking. + */ +export const SetupStatusSchema = z.object({ + /** iOS setup status */ + ios: IosSetupSchema.optional(), + /** Firebase setup status */ + firebase: FirebaseSetupSchema.optional(), + /** APNS setup status */ + apns: ApnsSetupSchema.optional(), +}); + /** * Main project configuration schema. * Stored in .clix/config.jsonc in the project root. */ export const ProjectConfigSchema = z.object({ /** Configuration schema version */ - version: z.literal(1), + version: z.number(), /** Logged-in member information */ member: ProjectMemberSchema, /** Selected organization */ @@ -73,6 +131,8 @@ export const ProjectConfigSchema = z.object({ projectType: ProjectTypeSchema.optional(), /** ISO timestamp when this config was created/linked */ linkedAt: z.string().datetime(), + /** Setup status tracking for install preparation */ + setup: SetupStatusSchema.optional(), }); /** @@ -81,6 +141,10 @@ export const ProjectConfigSchema = z.object({ export type ProjectMember = z.infer; export type ProjectOrganization = z.infer; export type ProjectInfo = z.infer; +export type IosSetup = z.infer; +export type FirebaseSetup = z.infer; +export type ApnsSetup = z.infer; +export type SetupStatus = z.infer; export type ProjectConfig = z.infer; /** @@ -120,3 +184,16 @@ export function safeValidateProjectConfig(data: unknown): ProjectConfig | null { const result = ProjectConfigSchema.safeParse(data); return result.success ? result.data : null; } + +/** + * Ensure config is at the latest version. + * Migrates older versions if necessary. + * + * @param config - Config of any supported version + * @returns Config at latest version + */ +export function ensureLatestVersion(config: ProjectConfig): ProjectConfig { + // Currently no migration needed + // Add migration logic here when schema changes in future versions + return config; +} diff --git a/src/lib/debug/logger.ts b/src/lib/debug/logger.ts index db0a46a..5920c4c 100644 --- a/src/lib/debug/logger.ts +++ b/src/lib/debug/logger.ts @@ -7,6 +7,8 @@ * - DEBUG=config - enable config-related debug output * - DEBUG=* - enable all debug output * + * Debug logs are also written to `.clix/debug.log` file (npm-style). + * * @example * ```typescript * import { logger } from '../debug/logger'; @@ -18,6 +20,9 @@ * ``` */ +import { appendFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export interface LogContext { @@ -184,6 +189,41 @@ export class Logger { throw error; } } + + /** + * Write debug info to .clix/debug.log file (npm-style). + * Always writes regardless of DEBUG environment variable. + * + * @param message - Log message + * @param data - Additional data to log + * @param projectRoot - Project root directory (optional, uses cwd if not provided) + */ + writeToFile(message: string, data?: unknown, projectRoot?: string): void { + // Try multiple locations to ensure logging works + const locations = [ + projectRoot, + process.cwd(), + process.env.HOME ? join(process.env.HOME, '.clix') : null, + ].filter((loc): loc is string => loc !== null && loc !== undefined); + + for (const root of locations) { + try { + const clixDir = root.endsWith('.clix') ? root : join(root, '.clix'); + const logFile = join(clixDir, 'debug.log'); + + mkdirSync(clixDir, { recursive: true }); + + const timestamp = new Date().toISOString(); + const line = `${timestamp} ${this.namespace} ${message}${data !== undefined ? ` ${JSON.stringify(data)}` : ''}\n`; + appendFileSync(logFile, line); + return; // Success, exit loop + } catch { + // Try next location + } + } + // All locations failed, log to stderr as last resort + console.error(`[${this.namespace}] ${message}`, data !== undefined ? JSON.stringify(data) : ''); + } } /** @@ -197,3 +237,8 @@ export const logger = new Logger('clix'); export function createLogger(namespace: string): Logger { return new Logger(namespace); } + +/** + * OAuth logger for debugging authentication flows. + */ +export const oauthLogger = createLogger('oauth'); diff --git a/src/lib/services/__tests__/organization-projects.test.ts b/src/lib/services/__tests__/organization-projects.test.ts new file mode 100644 index 0000000..049c964 --- /dev/null +++ b/src/lib/services/__tests__/organization-projects.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { Organization, Project } from '@/lib/api'; + +const listOrganizationsMock = mock(async (_options?: unknown): Promise => []); +const listProjectsMock = mock(async (_orgId: string, _options?: unknown): Promise => []); +const getValidTokenMock = mock(async (): Promise => 'token-1'); + +mock.module('@/lib/api', () => ({ + getInternalApiClient: () => ({ + listOrganizations: listOrganizationsMock, + listProjects: listProjectsMock, + }), +})); + +mock.module('@/lib/auth', () => ({ + getCredentialsManager: () => ({ + getValidToken: getValidTokenMock, + }), +})); + +import { fetchOrganizationsWithProjects } from '../organization-projects'; + +function org(id: string, name: string): Organization { + return { id, name }; +} + +function project(id: string, name: string, organizationId: string): Project { + return { id, name, organization_id: organizationId }; +} + +describe('fetchOrganizationsWithProjects', () => { + beforeEach(() => { + listOrganizationsMock.mockReset(); + listProjectsMock.mockReset(); + getValidTokenMock.mockReset(); + getValidTokenMock.mockResolvedValue('token-1'); + }); + + test('returns empty list when token is unavailable', async () => { + getValidTokenMock.mockResolvedValue(null); + + const result = await fetchOrganizationsWithProjects(); + + expect(result).toEqual([]); + expect(listOrganizationsMock).not.toHaveBeenCalled(); + }); + + test('passes timeout/retry/auth options to api client', async () => { + listOrganizationsMock.mockResolvedValue([org('o1', 'Org 1')]); + listProjectsMock.mockResolvedValue([project('p1', 'Project 1', 'o1')]); + + const result = await fetchOrganizationsWithProjects({ + requestTimeoutMs: 4500, + maxRetries: 2, + projectFetchConcurrency: 3, + }); + + expect(result).toHaveLength(1); + expect(listOrganizationsMock).toHaveBeenCalledTimes(1); + expect(listProjectsMock).toHaveBeenCalledTimes(1); + + const orgCallOptions = listOrganizationsMock.mock.calls[0]?.[0] as { + authToken: string; + timeoutMs: number; + maxRetries: number; + }; + expect(orgCallOptions.authToken).toBe('token-1'); + expect(orgCallOptions.timeoutMs).toBe(4500); + expect(orgCallOptions.maxRetries).toBe(2); + + const projectCallOptions = listProjectsMock.mock.calls[0]?.[1] as { + authToken: string; + timeoutMs: number; + maxRetries: number; + }; + expect(projectCallOptions.authToken).toBe('token-1'); + expect(projectCallOptions.timeoutMs).toBe(4500); + expect(projectCallOptions.maxRetries).toBe(2); + }); + + test('limits concurrent project fetches and tolerates per-org failure', async () => { + listOrganizationsMock.mockResolvedValue([ + org('o1', 'Org 1'), + org('o2', 'Org 2'), + org('o3', 'Org 3'), + org('o4', 'Org 4'), + ]); + + let inFlight = 0; + let maxInFlight = 0; + + listProjectsMock.mockImplementation(async (organizationId: string): Promise => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 10)); + inFlight -= 1; + + if (organizationId === 'o3') { + throw new Error('temporary failure'); + } + + return [project(`p-${organizationId}`, `Project ${organizationId}`, organizationId)]; + }); + + const result = await fetchOrganizationsWithProjects({ projectFetchConcurrency: 2 }); + + expect(maxInFlight).toBeLessThanOrEqual(2); + expect(result).toHaveLength(4); + + const failedOrg = result.find((item) => item.org.id === 'o3'); + expect(failedOrg?.projects).toEqual([]); + }); +}); diff --git a/src/lib/services/firebase/api/firebase-api.ts b/src/lib/services/firebase/api/firebase-api.ts index 6e6a51d..ab7c363 100644 --- a/src/lib/services/firebase/api/firebase-api.ts +++ b/src/lib/services/firebase/api/firebase-api.ts @@ -1,261 +1,298 @@ /** - * Firebase Management REST API client. + * Firebase Management API client using @googleapis/firebase. * * @module services/firebase/api/firebase-api */ +import { + cloudresourcemanager, + type cloudresourcemanager_v1, +} from '@googleapis/cloudresourcemanager'; +import { firebase, type firebase_v1beta1 } from '@googleapis/firebase'; +import { OAuth2Client } from 'google-auth-library'; +import { oauthLogger } from '@/lib/debug/logger'; +import { findProjectRoot } from '@/lib/utils/path'; import type { AndroidApp, - AppConfigResponse, CreateAndroidAppRequest, CreateIosAppRequest, FirebaseProject, + GcpProject, IosApp, - ListAndroidAppsResponse, - ListIosAppsResponse, - ListProjectsResponse, - Operation, } from './types'; -const BASE_URL = 'https://firebase.googleapis.com/v1beta1'; +type FirebaseApi = firebase_v1beta1.Firebase; +type ResourceManagerApi = cloudresourcemanager_v1.Cloudresourcemanager; /** - * Firebase Management API client. - * - * Uses the Firebase Management REST API to list projects, apps, and download configs. + * Log API errors to debug.log for troubleshooting. */ -export class FirebaseApiClient { - private getAccessToken: () => Promise; +function logApiError(operation: string, error: unknown): void { + const errorInfo = { + type: 'firebase_api_error', + operation, + message: error instanceof Error ? error.message : String(error), + name: error instanceof Error ? error.name : undefined, + // Extract Google API error details if available + code: (error as { code?: number | string })?.code, + errors: (error as { errors?: unknown[] })?.errors, + response: (error as { response?: { data?: unknown } })?.response?.data, + }; + oauthLogger.writeToFile(`Firebase API error: ${operation}`, errorInfo, findProjectRoot()); +} - /** - * Create a new Firebase API client. - * - * @param getAccessToken - Function to get a valid access token - */ - constructor(getAccessToken: () => Promise) { - this.getAccessToken = getAccessToken; +export interface ApiClientCredentials { + clientId: string; + clientSecret: string; +} + +export class FirebaseApiClient { + private fb: FirebaseApi; + private rm: ResourceManagerApi; + private auth: OAuth2Client; + private getAccessTokenFn: () => Promise; + + constructor(getAccessToken: () => Promise, credentials?: ApiClientCredentials) { + this.getAccessTokenFn = getAccessToken; + + // Create OAuth2Client with credentials if provided + this.auth = credentials + ? new OAuth2Client({ + clientId: credentials.clientId, + clientSecret: credentials.clientSecret, + }) + : new OAuth2Client(); + + this.fb = firebase({ version: 'v1beta1', auth: this.auth }); + this.rm = cloudresourcemanager({ version: 'v1', auth: this.auth }); } /** - * Make an authenticated GET request to the Firebase API. + * Update OAuth2Client credentials with current access token. + * Must be called before each API request. */ - private async request(path: string): Promise { - const token = await this.getAccessToken(); - const url = `${BASE_URL}${path}`; - - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, + private async updateCredentials(): Promise { + const token = await this.getAccessTokenFn(); + this.auth.setCredentials({ + access_token: token, + token_type: 'Bearer', }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Firebase API error (${response.status}): ${error}`); - } - - return response.json() as Promise; } - /** - * Make an authenticated POST request to the Firebase API. - */ - private async postRequest(path: string, body: unknown): Promise { - const token = await this.getAccessToken(); - const url = `${BASE_URL}${path}`; - - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); + async listProjects(): Promise { + try { + await this.updateCredentials(); + const projects: FirebaseProject[] = []; + let pageToken: string | undefined; + + do { + const res = await this.fb.projects.list({ pageToken }); + for (const p of res.data.results ?? []) { + projects.push({ + name: p.name ?? '', + projectId: p.projectId ?? '', + projectNumber: p.projectNumber ?? '', + displayName: p.displayName ?? '', + state: (p.state as 'ACTIVE' | 'DELETED') ?? 'ACTIVE', + }); + } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); - if (!response.ok) { - const error = await response.text(); - throw new Error(`Firebase API error (${response.status}): ${error}`); + return projects; + } catch (error) { + logApiError('listProjects', error); + throw error; } - - return response.json() as Promise; } - /** - * Wait for a long-running operation to complete. - * - * @param operationName - Operation name from the initial response - * @param maxWaitMs - Maximum time to wait in milliseconds (default: 60s) - * @returns The completed operation result - */ - private async waitForOperation(operationName: string, maxWaitMs = 60000): Promise { - const startTime = Date.now(); - const pollIntervalMs = 1000; - - while (Date.now() - startTime < maxWaitMs) { - const operation = await this.request>(`/${operationName}`.replace(/^\/+/, '/')); - - if (operation.done) { - if (operation.error) { - throw new Error(`Operation failed: ${operation.error.message}`); - } - if (!operation.response) { - throw new Error('Operation completed but no response returned'); + async listAndroidApps(projectId: string): Promise { + try { + await this.updateCredentials(); + const apps: AndroidApp[] = []; + let pageToken: string | undefined; + + do { + const res = await this.fb.projects.androidApps.list({ + parent: `projects/${projectId}`, + pageToken, + }); + for (const a of res.data.apps ?? []) { + apps.push({ + name: a.name ?? '', + appId: a.appId ?? '', + displayName: a.displayName ?? undefined, + packageName: a.packageName ?? '', + projectId: a.projectId ?? projectId, + }); } - return operation.response; - } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); - // Wait before polling again - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + return apps; + } catch (error) { + logApiError('listAndroidApps', error); + throw error; } - - throw new Error(`Operation timed out after ${maxWaitMs}ms`); } - /** - * Fetch paginated results from the Firebase API. - * - * @param basePath - Base path for the API endpoint - * @param extractor - Function to extract items from response - * @returns All items across all pages - */ - private async fetchPaginated( - basePath: string, - extractor: (response: R) => T[] | undefined, - ): Promise { - const items: T[] = []; - let pageToken: string | undefined; - - do { - const params = new URLSearchParams(); - if (pageToken) { - params.set('pageToken', pageToken); - } - - const query = params.toString(); - const path = query ? `${basePath}?${query}` : basePath; - const response = await this.request(path); + async listIosApps(projectId: string): Promise { + try { + await this.updateCredentials(); + const apps: IosApp[] = []; + let pageToken: string | undefined; + + do { + const res = await this.fb.projects.iosApps.list({ + parent: `projects/${projectId}`, + pageToken, + }); + for (const a of res.data.apps ?? []) { + apps.push({ + name: a.name ?? '', + appId: a.appId ?? '', + displayName: a.displayName ?? undefined, + bundleId: a.bundleId ?? '', + projectId: a.projectId ?? projectId, + }); + } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); - const extracted = extractor(response); - if (extracted) { - items.push(...extracted); - } - pageToken = response.nextPageToken; - } while (pageToken); + return apps; + } catch (error) { + logApiError('listIosApps', error); + throw error; + } + } - return items; + async getAndroidConfig(projectId: string, appId: string): Promise { + await this.updateCredentials(); + const res = await this.fb.projects.androidApps.getConfig({ + name: `projects/${projectId}/androidApps/${appId}/config`, + }); + return Buffer.from(res.data.configFileContents ?? '', 'base64').toString('utf-8'); } - /** - * Fetch config file contents from the Firebase API. - * - * @param path - API path for the config endpoint - * @returns Config file contents as string - */ - private async fetchConfig(path: string): Promise { - const response = await this.request(path); - return Buffer.from(response.configFileContents, 'base64').toString('utf-8'); + async getIosConfig(projectId: string, appId: string): Promise { + await this.updateCredentials(); + const res = await this.fb.projects.iosApps.getConfig({ + name: `projects/${projectId}/iosApps/${appId}/config`, + }); + return Buffer.from(res.data.configFileContents ?? '', 'base64').toString('utf-8'); } - /** - * Create an app and wait for the operation to complete. - * - * @param path - API path for the app creation endpoint - * @param request - App creation request - * @returns Created app - */ - private async createAppWithOperation(path: string, request: unknown): Promise { - const operation = await this.postRequest>(path, request); + async createAndroidApp(projectId: string, request: CreateAndroidAppRequest): Promise { + await this.updateCredentials(); + const res = await this.fb.projects.androidApps.create({ + parent: `projects/${projectId}`, + requestBody: request, + }); - // If operation is already done, return the result - if (operation.done && operation.response) { - return operation.response; + if (!res.data.name) { + throw new Error('Failed to create Android app: no operation name returned'); } - - // Otherwise, wait for the operation to complete - return this.waitForOperation(operation.name); + const app = await this.waitForOperation(res.data.name); + return { + name: app.name ?? '', + appId: app.appId ?? '', + displayName: app.displayName ?? undefined, + packageName: app.packageName ?? '', + projectId: app.projectId ?? projectId, + }; } - /** - * List all Firebase projects accessible to the user. - * - * @returns List of Firebase projects - */ - async listProjects(): Promise { - return this.fetchPaginated( - '/projects', - (response) => response.results, - ); + async createIosApp(projectId: string, request: CreateIosAppRequest): Promise { + await this.updateCredentials(); + const res = await this.fb.projects.iosApps.create({ + parent: `projects/${projectId}`, + requestBody: request, + }); + + if (!res.data.name) { + throw new Error('Failed to create iOS app: no operation name returned'); + } + const app = await this.waitForOperation(res.data.name); + return { + name: app.name ?? '', + appId: app.appId ?? '', + displayName: app.displayName ?? undefined, + bundleId: app.bundleId ?? '', + projectId: app.projectId ?? projectId, + }; } - /** - * List Android apps in a Firebase project. - * - * @param projectId - Firebase project ID - * @returns List of Android apps - */ - async listAndroidApps(projectId: string): Promise { - return this.fetchPaginated( - `/projects/${projectId}/androidApps`, - (response) => response.apps, - ); + async listGcpProjects(): Promise { + try { + await this.updateCredentials(); + const projects: GcpProject[] = []; + let pageToken: string | undefined; + + do { + const res = await this.rm.projects.list({ pageToken }); + for (const p of res.data.projects ?? []) { + projects.push({ + projectId: p.projectId ?? '', + name: p.name ?? '', + projectNumber: p.projectNumber ?? '', + lifecycleState: (p.lifecycleState as GcpProject['lifecycleState']) ?? 'ACTIVE', + createTime: p.createTime ?? undefined, + }); + } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); + + return projects; + } catch (error) { + logApiError('listGcpProjects', error); + throw error; + } } - /** - * List iOS apps in a Firebase project. - * - * @param projectId - Firebase project ID - * @returns List of iOS apps - */ - async listIosApps(projectId: string): Promise { - return this.fetchPaginated( - `/projects/${projectId}/iosApps`, - (response) => response.apps, + async listAvailableGcpProjects(): Promise { + const [gcpProjects, firebaseProjects] = await Promise.all([ + this.listGcpProjects(), + this.listProjects(), + ]); + const firebaseProjectIds = new Set(firebaseProjects.map((p) => p.projectId)); + return gcpProjects.filter( + (gcp) => gcp.lifecycleState === 'ACTIVE' && !firebaseProjectIds.has(gcp.projectId), ); } - /** - * Get Android app config (google-services.json). - * - * @param projectId - Firebase project ID - * @param appId - Android app ID - * @returns Config file contents as string - */ - async getAndroidConfig(projectId: string, appId: string): Promise { - return this.fetchConfig(`/projects/${projectId}/androidApps/${appId}/config`); + async addFirebaseToProject(projectId: string): Promise { + await this.updateCredentials(); + const res = await this.fb.projects.addFirebase({ project: `projects/${projectId}` }); + if (!res.data.name) { + throw new Error('Failed to add Firebase: no operation name returned'); + } + const project = await this.waitForOperation( + res.data.name, + ); + return { + name: project.name ?? '', + projectId: project.projectId ?? '', + projectNumber: project.projectNumber ?? '', + displayName: project.displayName ?? '', + state: (project.state as 'ACTIVE' | 'DELETED') ?? 'ACTIVE', + }; } - /** - * Get iOS app config (GoogleService-Info.plist). - * - * @param projectId - Firebase project ID - * @param appId - iOS app ID - * @returns Config file contents as string - */ - async getIosConfig(projectId: string, appId: string): Promise { - return this.fetchConfig(`/projects/${projectId}/iosApps/${appId}/config`); - } + private async waitForOperation(operationName: string, maxWaitMs = 60000): Promise { + const startTime = Date.now(); - /** - * Create a new Android app in a Firebase project. - * - * @param projectId - Firebase project ID - * @param request - App creation request with packageName and optional displayName - * @returns Created Android app - */ - async createAndroidApp(projectId: string, request: CreateAndroidAppRequest): Promise { - return this.createAppWithOperation(`/projects/${projectId}/androidApps`, request); - } + while (Date.now() - startTime < maxWaitMs) { + await this.updateCredentials(); + const res = await this.fb.operations.get({ name: operationName }); + if (res.data.done) { + if (res.data.error) { + throw new Error(`Operation failed: ${res.data.error.message}`); + } + return res.data.response as T; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } - /** - * Create a new iOS app in a Firebase project. - * - * @param projectId - Firebase project ID - * @param request - App creation request with bundleId and optional displayName - * @returns Created iOS app - */ - async createIosApp(projectId: string, request: CreateIosAppRequest): Promise { - return this.createAppWithOperation(`/projects/${projectId}/iosApps`, request); + throw new Error(`Operation timed out after ${maxWaitMs}ms`); } } diff --git a/src/lib/services/firebase/api/index.ts b/src/lib/services/firebase/api/index.ts index 70386db..a779e6a 100644 --- a/src/lib/services/firebase/api/index.ts +++ b/src/lib/services/firebase/api/index.ts @@ -4,17 +4,13 @@ * @module services/firebase/api */ -export { FirebaseApiClient } from './firebase-api'; +export { type ApiClientCredentials, FirebaseApiClient } from './firebase-api'; export type { AndroidApp, - AppConfigResponse, CreateAndroidAppRequest, CreateIosAppRequest, - FirebaseApiError, FirebaseProject, + GcpProject, IosApp, - ListAndroidAppsResponse, - ListIosAppsResponse, - ListProjectsResponse, - Operation, + ServiceAccountJson, } from './types'; diff --git a/src/lib/services/firebase/api/types.ts b/src/lib/services/firebase/api/types.ts index 1bc8fbd..eeba5cc 100644 --- a/src/lib/services/firebase/api/types.ts +++ b/src/lib/services/firebase/api/types.ts @@ -4,200 +4,58 @@ * @module services/firebase/api/types */ -/** - * Firebase project information. - */ export interface FirebaseProject { - /** - * Resource name (e.g., "projects/my-project-id") - */ name: string; - - /** - * Project ID (e.g., "my-project-id") - */ projectId: string; - - /** - * Project number. - */ projectNumber: string; - - /** - * Display name (user-friendly name). - */ displayName: string; - - /** - * Project state. - */ state: 'ACTIVE' | 'DELETED'; } -/** - * Firebase Android app information. - */ export interface AndroidApp { - /** - * Resource name. - */ name: string; - - /** - * App ID (e.g., "1:123456789:android:abcdef") - */ appId: string; - - /** - * Display name. - */ displayName?: string; - - /** - * Android package name (e.g., "com.example.app") - */ packageName: string; - - /** - * Project ID. - */ projectId: string; } -/** - * Firebase iOS app information. - */ export interface IosApp { - /** - * Resource name. - */ name: string; - - /** - * App ID (e.g., "1:123456789:ios:abcdef") - */ appId: string; - - /** - * Display name. - */ displayName?: string; - - /** - * iOS bundle ID (e.g., "com.example.app") - */ bundleId: string; - - /** - * Project ID. - */ projectId: string; } -/** - * API response for listing projects. - */ -export interface ListProjectsResponse { - results?: FirebaseProject[]; - nextPageToken?: string; -} - -/** - * API response for listing Android apps. - */ -export interface ListAndroidAppsResponse { - apps?: AndroidApp[]; - nextPageToken?: string; -} - -/** - * API response for listing iOS apps. - */ -export interface ListIosAppsResponse { - apps?: IosApp[]; - nextPageToken?: string; -} - -/** - * API response for app config. - */ -export interface AppConfigResponse { - /** - * Config filename (e.g., "google-services.json") - */ - configFilename: string; - - /** - * Base64-encoded config file contents. - */ - configFileContents: string; -} - -/** - * Firebase API error. - */ -export interface FirebaseApiError { - code: number; - message: string; - status: string; -} - -/** - * Request body for creating an Android app. - */ export interface CreateAndroidAppRequest { - /** - * Android package name (e.g., "com.example.app") - */ packageName: string; - - /** - * Display name for the app. - */ displayName?: string; } -/** - * Request body for creating an iOS app. - */ export interface CreateIosAppRequest { - /** - * iOS bundle ID (e.g., "com.example.app") - */ bundleId: string; - - /** - * Display name for the app. - */ displayName?: string; } -/** - * Long-running operation response. - * Firebase app creation returns an operation that completes asynchronously. - */ -export interface Operation { - /** - * Operation name (e.g., "operations/abc123") - */ +export interface GcpProject { + projectId: string; name: string; - - /** - * Whether the operation is done. - */ - done: boolean; - - /** - * Operation result when done is true. - */ - response?: T; - - /** - * Error details if operation failed. - */ - error?: { - code: number; - message: string; - details?: unknown[]; - }; + projectNumber: string; + lifecycleState: 'ACTIVE' | 'DELETE_REQUESTED' | 'DELETE_IN_PROGRESS'; + createTime?: string; +} + +export interface ServiceAccountJson { + type: 'service_account'; + project_id: string; + private_key_id: string; + private_key: string; + client_email: string; + client_id: string; + auth_uri: string; + token_uri: string; + auth_provider_x509_cert_url: string; + client_x509_cert_url: string; + universe_domain?: string; } diff --git a/src/lib/services/firebase/downloader.ts b/src/lib/services/firebase/downloader.ts index c690145..6f03363 100644 --- a/src/lib/services/firebase/downloader.ts +++ b/src/lib/services/firebase/downloader.ts @@ -14,11 +14,17 @@ import type { CreateAndroidAppRequest, CreateIosAppRequest, FirebaseProject, + GcpProject, IosApp, + ServiceAccountJson, } from './api'; -import { FirebaseApiClient } from './api'; +import { type ApiClientCredentials, FirebaseApiClient } from './api'; import { getExpectedPaths } from './detector'; -import { GoogleAuthClient } from './oauth'; +import { GoogleAuthClient, getOAuthCredentials } from './oauth'; +import { + parseServiceAccountJson, + type ServiceAccountValidationResult, +} from './service-account-validator'; import { type Platform, platformNeedsAndroid, platformNeedsIos } from './types'; /** @@ -62,6 +68,7 @@ export interface DownloadResult { export class FirebaseDownloader { private authClient: GoogleAuthClient; private apiClient: FirebaseApiClient | null = null; + private credentials: ApiClientCredentials | null = null; constructor() { this.authClient = new GoogleAuthClient(); @@ -92,7 +99,15 @@ export class FirebaseDownloader { ): Promise<{ success: boolean; error?: string }> { const result = await this.authClient.authenticate(openBrowser); if (result.success) { - this.apiClient = new FirebaseApiClient(() => this.authClient.getAccessToken()); + // Fetch and store credentials for API clients + const creds = await getOAuthCredentials(); + if (creds) { + this.credentials = { clientId: creds.clientId, clientSecret: creds.clientSecret }; + } + this.apiClient = new FirebaseApiClient( + () => this.authClient.getAccessToken(), + this.credentials ?? undefined, + ); } return result; } @@ -100,9 +115,19 @@ export class FirebaseDownloader { /** * Ensure API client is initialized. */ - private ensureApiClient(): FirebaseApiClient { + private async ensureApiClient(): Promise { if (!this.apiClient) { - this.apiClient = new FirebaseApiClient(() => this.authClient.getAccessToken()); + // Fetch credentials if not already cached + if (!this.credentials) { + const creds = await getOAuthCredentials(); + if (creds) { + this.credentials = { clientId: creds.clientId, clientSecret: creds.clientSecret }; + } + } + this.apiClient = new FirebaseApiClient( + () => this.authClient.getAccessToken(), + this.credentials ?? undefined, + ); } return this.apiClient; } @@ -114,7 +139,7 @@ export class FirebaseDownloader { if (!(await this.isAuthenticated())) { throw new Error('Not authenticated. Run OAuth flow first.'); } - const api = this.ensureApiClient(); + const api = await this.ensureApiClient(); return api.listProjects(); } @@ -125,7 +150,7 @@ export class FirebaseDownloader { if (!(await this.isAuthenticated())) { throw new Error('Not authenticated. Run OAuth flow first.'); } - const api = this.ensureApiClient(); + const api = await this.ensureApiClient(); return api.listAndroidApps(projectId); } @@ -136,7 +161,7 @@ export class FirebaseDownloader { if (!(await this.isAuthenticated())) { throw new Error('Not authenticated. Run OAuth flow first.'); } - const api = this.ensureApiClient(); + const api = await this.ensureApiClient(); return api.listIosApps(projectId); } @@ -165,7 +190,7 @@ export class FirebaseDownloader { if (!(await this.isAuthenticated())) { throw new Error('Not authenticated. Run OAuth flow first.'); } - const api = this.ensureApiClient(); + const api = await this.ensureApiClient(); return api.createAndroidApp(projectId, request); } @@ -180,7 +205,7 @@ export class FirebaseDownloader { if (!(await this.isAuthenticated())) { throw new Error('Not authenticated. Run OAuth flow first.'); } - const api = this.ensureApiClient(); + const api = await this.ensureApiClient(); return api.createIosApp(projectId, request); } @@ -195,7 +220,7 @@ export class FirebaseDownloader { if (!(await this.isAuthenticated())) { throw new Error('Not authenticated. Run OAuth flow first.'); } - const api = this.ensureApiClient(); + const api = await this.ensureApiClient(); const config = await api.getAndroidConfig(projectId, appId); const dir = path.dirname(savePath); @@ -214,7 +239,7 @@ export class FirebaseDownloader { if (!(await this.isAuthenticated())) { throw new Error('Not authenticated. Run OAuth flow first.'); } - const api = this.ensureApiClient(); + const api = await this.ensureApiClient(); const config = await api.getIosConfig(projectId, appId); const dir = path.dirname(savePath); @@ -271,4 +296,87 @@ export class FirebaseDownloader { await this.authClient.logout(); this.apiClient = null; } + + // ============================================================================ + // GCP Project and Firebase Project Management + // ============================================================================ + + /** + * List GCP projects that don't have Firebase yet. + * + * These projects can have Firebase added to them. + */ + async listAvailableGcpProjects(): Promise { + if (!(await this.isAuthenticated())) { + throw new Error('Not authenticated. Run OAuth flow first.'); + } + const api = await this.ensureApiClient(); + return api.listAvailableGcpProjects(); + } + + /** + * Add Firebase to an existing GCP project. + * + * @param projectId - GCP project ID + * @returns Created Firebase project + */ + async addFirebaseToProject(projectId: string): Promise { + if (!(await this.isAuthenticated())) { + throw new Error('Not authenticated. Run OAuth flow first.'); + } + const api = await this.ensureApiClient(); + return api.addFirebaseToProject(projectId); + } + + // ============================================================================ + // Service Account Management + // ============================================================================ + + /** + * Validate a Service Account JSON string. + * + * @param jsonString - JSON string to validate + * @returns Validation result + */ + validateServiceAccountJson(jsonString: string): ServiceAccountValidationResult { + return parseServiceAccountJson(jsonString); + } + + /** + * Save a Service Account JSON to the project's .clix directory. + * + * @param projectPath - Project root path + * @param serviceAccountJson - Service Account JSON data + * @returns Path where the file was saved + */ + async saveServiceAccountJson( + projectPath: string, + serviceAccountJson: ServiceAccountJson, + ): Promise { + const clixDir = path.join(projectPath, '.clix'); + const savePath = path.join(clixDir, 'service-account.json'); + + await fs.mkdir(clixDir, { recursive: true }); + await fs.writeFile(savePath, JSON.stringify(serviceAccountJson, null, 2), 'utf-8'); + + return savePath; + } + + /** + * Load existing Service Account JSON from the project's .clix directory. + * + * @param projectPath - Project root path + * @returns Service Account JSON or null if not found + */ + async loadServiceAccountJson(projectPath: string): Promise { + const savePath = path.join(projectPath, '.clix', 'service-account.json'); + + try { + const content = await fs.readFile(savePath, 'utf-8'); + const result = parseServiceAccountJson(content); + return result.valid && result.data ? result.data : null; + } catch { + return null; + } + } } diff --git a/src/lib/services/firebase/index.ts b/src/lib/services/firebase/index.ts index b34f530..90d7158 100644 --- a/src/lib/services/firebase/index.ts +++ b/src/lib/services/firebase/index.ts @@ -7,7 +7,13 @@ */ // API types (public) -export type { AndroidApp, FirebaseProject, IosApp } from './api'; +export type { + AndroidApp, + FirebaseProject, + GcpProject, + IosApp, + ServiceAccountJson, +} from './api'; // Detection and validation export { detectFirebaseConfig, getExpectedPaths } from './detector'; @@ -23,6 +29,15 @@ export { FirebaseService } from './firebase-service'; export type { AuthResult, OAuthFlowState, OAuthFlowStatus, OAuthTokens } from './oauth'; export { getOAuthConfigurationError, isOAuthConfigured } from './oauth'; +// Service Account validation +export { + parseBase64ServiceAccountJson, + parseServiceAccountJson, + quickValidateServiceAccountJson, + type ServiceAccountValidationResult, + validateServiceAccountJson, +} from './service-account-validator'; + // Types export * from './types'; diff --git a/src/lib/services/firebase/oauth/auth-client.ts b/src/lib/services/firebase/oauth/auth-client.ts index b58b24c..abbac4e 100644 --- a/src/lib/services/firebase/oauth/auth-client.ts +++ b/src/lib/services/firebase/oauth/auth-client.ts @@ -7,6 +7,7 @@ * @module services/firebase/oauth/auth-client */ +import { oauthLogger } from '@/lib/debug/logger'; import { generateCodeChallenge, generateCodeVerifier, @@ -14,6 +15,7 @@ import { OAUTH_CALLBACK_CONFIG, OAuthCallbackServer, } from '@/lib/utils/oauth'; +import { findProjectRoot } from '@/lib/utils/path'; import { GOOGLE_OAUTH_CONFIG, getOAuthCredentials, isOAuthConfigured } from './config'; import { TokenStore } from './token-store'; import type { AuthResult, OAuthCallbackResult, OAuthTokens } from './types'; @@ -90,13 +92,19 @@ export class GoogleAuthClient { const authUrl = `${GOOGLE_OAUTH_CONFIG.authorizationEndpoint}?${params.toString()}`; - // Debug: Log PKCE parameters - if (process.env.DEBUG) { - console.error('[OAuth Debug] Authorization request:'); - console.error(' code_challenge:', codeChallenge); - console.error(' code_challenge_method: S256'); - console.error(' PKCE enabled: true'); - } + // Always log OAuth request parameters to debug.log + oauthLogger.writeToFile( + 'OAuth authorization URL generated', + { + type: 'auth_url_generated', + client_id: `${clientId.substring(0, 20)}...`, + redirect_uri: GOOGLE_OAUTH_CONFIG.redirectUri, + scopes: GOOGLE_OAUTH_CONFIG.scopes, + has_code_challenge: !!codeChallenge, + has_state: !!this.oauthState, + }, + findProjectRoot(), + ); return authUrl; } @@ -179,10 +187,22 @@ export class GoogleAuthClient { if (!response.ok) { const errorData = await response.json().catch(() => ({})); - // Debug: Log error response - if (process.env.DEBUG) { - console.error('[OAuth Debug] Token exchange error:', JSON.stringify(errorData, null, 2)); - } + // Write debug info to .clix/debug.log + oauthLogger.writeToFile( + 'Token exchange failed', + { + type: 'token_exchange_error', + error: errorData, + params: { + client_id: `${clientId.substring(0, 20)}...`, + redirect_uri: GOOGLE_OAUTH_CONFIG.redirectUri, + has_code: !!code, + has_code_verifier: !!this.codeVerifier, + has_client_secret: !!clientSecret, + }, + }, + findProjectRoot(), + ); const errorMessage = (errorData as { error_description?: string; error?: string }).error_description || @@ -246,9 +266,36 @@ export class GoogleAuthClient { if (!response.ok) { const errorData = await response.json().catch(() => ({})); + const errorCode = (errorData as { error?: string }).error; + + // Write debug info to .clix/debug.log + oauthLogger.writeToFile( + 'Token refresh failed', + { + type: 'token_refresh_error', + error: errorData, + params: { + client_id: `${clientId.substring(0, 20)}...`, + has_refresh_token: !!refreshToken, + has_client_secret: !!clientSecret, + }, + }, + findProjectRoot(), + ); + + // If invalid_grant, clear stored tokens so user can re-authenticate + if (errorCode === 'invalid_grant') { + oauthLogger.writeToFile( + 'Clearing stored tokens due to invalid_grant', + { type: 'tokens_cleared' }, + findProjectRoot(), + ); + await this.tokenStore.clear(); + } + const errorMessage = (errorData as { error_description?: string; error?: string }).error_description || - (errorData as { error?: string }).error || + errorCode || `Token refresh failed: ${response.status}`; throw new Error(errorMessage); } @@ -277,6 +324,7 @@ export class GoogleAuthClient { * Automatically refreshes if expired. * * @returns Access token string + * @throws Error with 'invalid_grant' if tokens are invalid and need re-authentication */ async getAccessToken(): Promise { let tokens = await this.tokenStore.load(); @@ -287,7 +335,14 @@ export class GoogleAuthClient { // Check if token is expired and we have a refresh token if (this.tokenStore.isExpired(tokens) && tokens.refresh_token) { - tokens = await this.refreshAccessToken(tokens.refresh_token); + try { + tokens = await this.refreshAccessToken(tokens.refresh_token); + } catch (error) { + // If refresh failed (e.g., invalid_grant), tokens are already cleared + // Re-throw with indication that re-authentication is needed + const message = error instanceof Error ? error.message : 'Token refresh failed'; + throw new Error(`invalid_grant: ${message}`); + } } if (!tokens.access_token) { @@ -305,17 +360,49 @@ export class GoogleAuthClient { */ async authenticate(openBrowser: (url: string) => void): Promise { try { + oauthLogger.writeToFile( + 'Starting OAuth authentication flow', + { type: 'auth_start' }, + findProjectRoot(), + ); + const authUrl = await this.generateAuthUrl(); openBrowser(authUrl); + oauthLogger.writeToFile( + 'Waiting for OAuth callback', + { type: 'waiting_callback' }, + findProjectRoot(), + ); + const { code } = await this.waitForCallback(); + + oauthLogger.writeToFile( + 'OAuth callback received, exchanging code', + { type: 'callback_received', has_code: !!code }, + findProjectRoot(), + ); + await this.exchangeCode(code); + oauthLogger.writeToFile( + 'OAuth authentication successful', + { type: 'auth_success' }, + findProjectRoot(), + ); + return { success: true }; } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + oauthLogger.writeToFile( + 'OAuth authentication failed', + { type: 'auth_failed', error: errorMessage }, + findProjectRoot(), + ); + return { success: false, - error: error instanceof Error ? error.message : 'Unknown error', + error: errorMessage, }; } } diff --git a/src/lib/services/firebase/oauth/config.ts b/src/lib/services/firebase/oauth/config.ts index 5d3a1cf..7618918 100644 --- a/src/lib/services/firebase/oauth/config.ts +++ b/src/lib/services/firebase/oauth/config.ts @@ -156,7 +156,8 @@ export const GOOGLE_OAUTH_CONFIG = { /** * OAuth scopes required for Firebase Management API. - * Using full firebase scope for listing projects/apps, downloading configs, and creating apps. + * + * - firebase: List projects/apps, download configs, create apps */ scopes: ['https://www.googleapis.com/auth/firebase'], diff --git a/src/lib/services/firebase/service-account-validator.ts b/src/lib/services/firebase/service-account-validator.ts new file mode 100644 index 0000000..4ae0d9d --- /dev/null +++ b/src/lib/services/firebase/service-account-validator.ts @@ -0,0 +1,221 @@ +/** + * Service Account JSON validation utilities. + * + * Validates Firebase/Google Cloud Service Account JSON key files. + * + * @module services/firebase/service-account-validator + */ + +import { z } from 'zod'; + +/** + * Firebase/Google Cloud Service Account JSON schema. + * + * This is the structure of the JSON key file downloaded from + * Firebase Console or Google Cloud Console. + */ +export const ServiceAccountJsonSchema = z.object({ + // Type must be exactly "service_account" + type: z.literal('service_account'), + + // Project ID (required) + project_id: z.string().min(1, 'project_id is required'), + + // Private key ID (required) + private_key_id: z.string().min(1, 'private_key_id is required'), + + // Private key in PEM format (required) + private_key: z + .string() + .min(1, 'private_key is required') + .refine( + (key) => key.includes('-----BEGIN') && key.includes('PRIVATE KEY-----'), + 'Invalid private_key format (must be PEM format)', + ), + + // Service account email (required) + client_email: z + .string() + .email('Invalid client_email format') + .refine( + (email) => email.endsWith('.iam.gserviceaccount.com'), + 'client_email must be a service account email (*.iam.gserviceaccount.com)', + ), + + // Client ID (required) + client_id: z.string().min(1, 'client_id is required'), + + // Auth URI (required) + auth_uri: z.string().url('Invalid auth_uri URL'), + + // Token URI (required) + token_uri: z.string().url('Invalid token_uri URL'), + + // Auth provider certificate URL (required) + auth_provider_x509_cert_url: z.string().url('Invalid auth_provider_x509_cert_url'), + + // Client certificate URL (required) + client_x509_cert_url: z.string().url('Invalid client_x509_cert_url'), + + // Universe domain (optional, for specialized environments) + universe_domain: z.string().optional(), +}); + +/** + * Validated Service Account JSON type. + */ +export type ValidatedServiceAccountJson = z.infer; + +/** + * Validation result. + */ +export interface ServiceAccountValidationResult { + /** + * Whether the JSON is valid. + */ + valid: boolean; + + /** + * Validated data if valid. + */ + data?: ValidatedServiceAccountJson; + + /** + * Error messages if invalid. + */ + errors: string[]; +} + +/** + * Parse and validate a Service Account JSON string. + * + * @param jsonString - JSON string to validate + * @returns Validation result with parsed data or errors + */ +export function parseServiceAccountJson(jsonString: string): ServiceAccountValidationResult { + // 1. Try to parse JSON + let parsed: unknown; + try { + parsed = JSON.parse(jsonString); + } catch (e) { + const message = e instanceof Error ? e.message : 'Invalid JSON'; + return { + valid: false, + errors: [`JSON parse error: ${message}`], + }; + } + + // 2. Validate against schema + return validateServiceAccountJson(parsed); +} + +/** + * Validate a parsed object against the Service Account JSON schema. + * + * @param json - Parsed JSON object to validate + * @returns Validation result with parsed data or errors + */ +export function validateServiceAccountJson(json: unknown): ServiceAccountValidationResult { + const result = ServiceAccountJsonSchema.safeParse(json); + + if (result.success) { + return { + valid: true, + data: result.data, + errors: [], + }; + } + + // Extract error messages + const errors = result.error.issues.map((issue) => { + const path = issue.path.join('.'); + return path ? `${path}: ${issue.message}` : issue.message; + }); + + return { + valid: false, + errors, + }; +} + +/** + * Quick validation result for UI feedback. + */ +export interface QuickValidationResult { + /** + * Whether the string is valid JSON. + */ + isJson: boolean; + + /** + * Whether the JSON has type: "service_account". + */ + isServiceAccount: boolean; + + /** + * Whether the JSON has a valid-looking private key. + */ + hasPrivateKey: boolean; + + /** + * Project ID if detected. + */ + projectId?: string; + + /** + * Client email if detected. + */ + clientEmail?: string; +} + +/** + * Perform quick validation for real-time UI feedback. + * + * This is a lightweight check that doesn't validate all fields, + * just enough to show immediate feedback to the user. + * + * @param jsonString - JSON string to check + * @returns Quick validation result + */ +export function quickValidateServiceAccountJson(jsonString: string): QuickValidationResult { + try { + const parsed = JSON.parse(jsonString) as Record; + + return { + isJson: true, + isServiceAccount: parsed?.type === 'service_account', + hasPrivateKey: + typeof parsed?.private_key === 'string' && parsed.private_key.includes('PRIVATE KEY'), + projectId: typeof parsed?.project_id === 'string' ? parsed.project_id : undefined, + clientEmail: typeof parsed?.client_email === 'string' ? parsed.client_email : undefined, + }; + } catch { + return { + isJson: false, + isServiceAccount: false, + hasPrivateKey: false, + }; + } +} + +/** + * Decode a base64-encoded Service Account JSON and validate it. + * + * This is useful for processing keys downloaded from the IAM API, + * which return the key data as base64-encoded JSON. + * + * @param base64Data - Base64-encoded JSON string + * @returns Validation result + */ +export function parseBase64ServiceAccountJson(base64Data: string): ServiceAccountValidationResult { + try { + const jsonString = Buffer.from(base64Data, 'base64').toString('utf-8'); + return parseServiceAccountJson(jsonString); + } catch (e) { + const message = e instanceof Error ? e.message : 'Invalid base64 data'; + return { + valid: false, + errors: [`Base64 decode error: ${message}`], + }; + } +} diff --git a/src/lib/services/firebase/types.ts b/src/lib/services/firebase/types.ts index 75d1520..cb57885 100644 --- a/src/lib/services/firebase/types.ts +++ b/src/lib/services/firebase/types.ts @@ -230,7 +230,8 @@ export type CredentialAction = | { type: 'help'; topic: keyof typeof FIREBASE_HELP_URLS } | { type: 'download' } | { type: 'skip' } - | { type: 'done' }; + | { type: 'done' } + | { type: 'setup_service_account' }; /** * Wizard phase states. diff --git a/src/lib/services/organization-projects.ts b/src/lib/services/organization-projects.ts new file mode 100644 index 0000000..cac19a9 --- /dev/null +++ b/src/lib/services/organization-projects.ts @@ -0,0 +1,125 @@ +import { getInternalApiClient, type Organization, type Project } from '@/lib/api'; +import { getCredentialsManager } from '@/lib/auth'; +import { createLogger } from '@/lib/debug/logger'; + +const projectsLogger = createLogger('projects-fetch'); + +const DEFAULT_PROJECT_FETCH_CONCURRENCY = 4; +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_RETRIES = 1; + +export interface OrgWithProjects { + org: Organization; + projects: Project[]; +} + +export interface FetchOrganizationsWithProjectsOptions { + projectFetchConcurrency?: number; + requestTimeoutMs?: number; + maxRetries?: number; +} + +function normalizeConcurrency(input?: number): number { + if (!input || !Number.isFinite(input) || input <= 0) { + return DEFAULT_PROJECT_FETCH_CONCURRENCY; + } + return Math.floor(input); +} + +async function mapWithConcurrency( + items: TInput[], + concurrency: number, + mapItem: (item: TInput, index: number) => Promise, +): Promise { + if (items.length === 0) return []; + + const normalizedConcurrency = Math.min(concurrency, items.length); + const results = new Array(items.length); + let nextIndex = 0; + + async function worker(): Promise { + while (true) { + const currentIndex = nextIndex; + nextIndex += 1; + if (currentIndex >= items.length) return; + results[currentIndex] = await mapItem(items[currentIndex], currentIndex); + } + } + + await Promise.all(Array.from({ length: normalizedConcurrency }, () => worker())); + return results; +} + +export async function fetchOrganizationsWithProjects( + options: FetchOrganizationsWithProjectsOptions = {}, +): Promise { + const apiClient = getInternalApiClient(); + const credentialsManager = getCredentialsManager(); + const concurrency = normalizeConcurrency(options.projectFetchConcurrency); + const timeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; + const startedAt = performance.now(); + + try { + const authToken = await credentialsManager.getValidToken(); + if (!authToken) { + return []; + } + + const orgFetchStartedAt = performance.now(); + const organizations = await apiClient.listOrganizations({ + authToken, + timeoutMs, + maxRetries, + }); + const orgFetchDurationMs = Math.round(performance.now() - orgFetchStartedAt); + + const projectsFetchStartedAt = performance.now(); + const orgsWithProjects = await mapWithConcurrency( + organizations, + concurrency, + async (org): Promise => { + try { + const projects = await apiClient.listProjects(org.id, { + authToken, + timeoutMs, + maxRetries, + }); + return { org, projects }; + } catch (error) { + if (projectsLogger.isEnabled()) { + projectsLogger.debug('Failed to fetch projects for org', { + orgId: org.id, + error: error instanceof Error ? error.message : String(error), + }); + } + return { org, projects: [] }; + } + }, + ); + const projectsFetchDurationMs = Math.round(performance.now() - projectsFetchStartedAt); + const totalDurationMs = Math.round(performance.now() - startedAt); + + if (projectsLogger.isEnabled()) { + projectsLogger.debug('Fetched organizations and projects', { + organizationCount: organizations.length, + projectCount: orgsWithProjects.reduce((sum, orgData) => sum + orgData.projects.length, 0), + concurrency, + timeoutMs, + maxRetries, + orgFetchDurationMs, + projectsFetchDurationMs, + totalDurationMs, + }); + } + + return orgsWithProjects; + } catch (error) { + if (projectsLogger.isEnabled()) { + projectsLogger.debug('Failed to fetch organizations and projects', { + error: error instanceof Error ? error.message : String(error), + }); + } + return []; + } +} diff --git a/src/lib/skills.ts b/src/lib/skills.ts index d39a0c0..2c23d1f 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; +import type { PreparationContext } from '@/commands/skill/preparation'; import { EMBEDDED_SKILL_METADATA, getEmbeddedSkill, @@ -10,6 +11,7 @@ import { } from './embedded-skills'; import type { AgentExecutor, AgentMessage } from './executor'; import { getDebugPrompt } from './services/debug-service'; +import { formatProjectType } from './services/project-detector'; /** * Skill type - dynamically generated from embedded skills + local skills. @@ -23,6 +25,8 @@ export interface SkillOptions { signal?: AbortSignal; /** One-shot mode: disable session persistence (for command-line execution) */ oneShot?: boolean; + /** Preparation context from install preparation phase */ + preparationContext?: PreparationContext; } export interface SkillInfo { @@ -228,6 +232,66 @@ function readLocalSkillPrompt(skillName: string): string { } } +/** + * Build pre-configured setup section from preparation context. + */ +function buildPreparationSection(context: PreparationContext): string { + const lines: string[] = ['## Pre-configured Setup', '']; + lines.push(`Project: ${context.config.project.name}`); + lines.push(`Type: ${formatProjectType(context.projectType)}`); + lines.push(''); + + // Clix project info + lines.push('### Clix Project'); + lines.push(`- Project ID: ${context.config.project.id}`); + if (context.config.project.publicKey) { + lines.push(`- Public Key: ${context.config.project.publicKey}`); + } + lines.push(''); + + // Firebase status + if (context.firebase.needed) { + lines.push('### Firebase'); + lines.push(`- Project ID: ${context.firebase.projectId || 'not configured'}`); + lines.push( + `- Android (google-services.json): ${context.firebase.androidConfigured ? '✓ configured' : '✗ missing'}`, + ); + lines.push( + `- iOS (GoogleService-Info.plist): ${context.firebase.iosConfigured ? '✓ configured' : '✗ missing'}`, + ); + lines.push(''); + } + + // iOS status + if (context.ios.needed) { + lines.push('### iOS'); + lines.push(`- Bundle ID: ${context.ios.bundleId || 'not detected'}`); + lines.push(`- Team ID: ${context.ios.teamId || 'not detected'}`); + lines.push(`- App Group: ${context.ios.appGroupId || 'not configured'}`); + lines.push( + `- Entitlements: ${context.ios.entitlementsConfigured ? '✓ configured' : '✗ not configured'}`, + ); + lines.push( + `- NSE (Notification Service Extension): ${context.ios.nseConfigured ? '✓ configured' : '✗ not configured'}`, + ); + lines.push(''); + } + + // Missing items + if (context.missing.length > 0) { + lines.push('### Missing Setup (handle during installation)'); + for (const item of context.missing) { + lines.push(`- ${item}`); + } + lines.push(''); + } + + lines.push('Use these pre-configured values when integrating the SDK.'); + lines.push(''); + + return lines.join('\n'); +} + /** * Get prompt for the install skill. * Uses the autonomous installation prompt optimized for one-shot execution. @@ -236,9 +300,16 @@ function readLocalSkillPrompt(skillName: string): string { function getInstallPrompt(options?: SkillOptions): string { const projectPath = options?.projectPath ?? process.cwd(); const platform = options?.platform ?? 'auto-detect'; + const context = options?.preparationContext; let prompt = `Project path: ${projectPath}\nTarget platform: ${platform}\n\n`; + // Add preparation context if available + if (context) { + prompt += buildPreparationSection(context); + prompt += '\n'; + } + // Add one-shot instruction for autonomous execution if (options?.oneShot) { prompt += `${ONE_SHOT_INSTRUCTION}\n\n`; diff --git a/src/lib/utils/oauth.ts b/src/lib/utils/oauth.ts index 91f7756..6d8bdb7 100644 --- a/src/lib/utils/oauth.ts +++ b/src/lib/utils/oauth.ts @@ -6,6 +6,8 @@ import { createHash, randomBytes } from 'node:crypto'; import { createServer, type Server } from 'node:http'; +import { oauthLogger } from '@/lib/debug/logger'; +import { findProjectRoot } from './path'; // ============================================================================ // Shared OAuth Callback Configuration @@ -101,14 +103,14 @@ const DEFAULT_SUCCESS_HTML = ` align-items: center; min-height: 100vh; margin: 0; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: #000000; } .container { text-align: center; padding: 40px; background: white; border-radius: 16px; - box-shadow: 0 10px 40px rgba(0,0,0,0.2); + box-shadow: 0 10px 40px rgba(255,255,255,0.1); } .icon { font-size: 64px; margin-bottom: 20px; } h1 { color: #333; margin: 0 0 10px; } @@ -138,16 +140,16 @@ function defaultErrorHtml(message: string): string { align-items: center; min-height: 100vh; margin: 0; - background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + background: #000000; } .container { text-align: center; padding: 40px; background: white; border-radius: 16px; - box-shadow: 0 10px 40px rgba(0,0,0,0.2); + box-shadow: 0 10px 40px rgba(255,255,255,0.1); } - .icon { font-size: 64px; margin-bottom: 20px; } + .icon { font-size: 64px; margin-bottom: 20px; color: #f5576c; } h1 { color: #333; margin: 0 0 10px; } p { color: #666; margin: 0; } @@ -258,12 +260,26 @@ export class OAuthCallbackServer { // Handle OAuth error if (error) { - const errorMsg = errorDescription || error; + const errorMsg = errorDescription ? `${error}: ${errorDescription}` : error; + + // Write debug info to .clix/debug.log + oauthLogger.writeToFile( + 'OAuth callback error', + { + type: 'oauth_callback_error', + error, + error_description: errorDescription, + full_url: req.url, + all_params: Object.fromEntries(url.searchParams.entries()), + }, + findProjectRoot(), + ); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(this.options.errorHtml(errorMsg)); clearTimeout(timeout); this.stop(); - reject(new Error(`OAuth error: ${errorMsg}`)); + reject(new Error(errorMsg)); return; } diff --git a/src/ui/LoginUI.tsx b/src/ui/LoginUI.tsx index f30b1d8..e9ea355 100644 --- a/src/ui/LoginUI.tsx +++ b/src/ui/LoginUI.tsx @@ -18,6 +18,10 @@ import { getProjectConfigManager, type ProjectConfig, } from '@/lib/config'; +import { + fetchOrganizationsWithProjects, + type OrgWithProjects, +} from '@/lib/services/organization-projects'; import { detectProjectType, formatProjectType } from '@/lib/services/project-detector'; import { Header } from '@/ui/components/Header'; import { ProjectSelector } from '@/ui/components/ProjectSelector'; @@ -33,11 +37,6 @@ type LoginPhase = | 'complete' | 'error'; -interface OrgWithProjects { - org: Organization; - projects: Project[]; -} - interface LoginUIProps { /** Called when login completes successfully */ onComplete?: (credentials: ClixCredentials) => void; @@ -71,22 +70,6 @@ async function fetchUserName( } } -/** Fetch organizations and their projects */ -async function fetchOrganizationsWithProjects(): Promise { - const orgsWithProjects: OrgWithProjects[] = []; - try { - const apiClient = getInternalApiClient(); - const orgs = await apiClient.listOrganizations(); - for (const org of orgs) { - const projects = await apiClient.listProjects(org.id); - orgsWithProjects.push({ org, projects }); - } - } catch { - // Silently ignore org/project fetch errors - } - return orgsWithProjects; -} - /** Check if user is already logged in with valid credentials */ async function checkExistingLogin(): Promise<{ isLoggedIn: boolean; userName: string }> { const credentialsManager = getCredentialsManager(); @@ -241,14 +224,23 @@ export const LoginUI: React.FC = ({ onComplete, onError }) => { const credentials = createClixCredentials(tokenResponse, issuer, config.audience); await credentialsManager.saveClixCredentials(credentials); - // Verify login + // Verify login and fetch data in parallel setPhase('verifying'); - const name = await fetchUserName(pkceService, tokenResponse); - setUserName(name); credentialsRef.current = credentials; - - // Fetch organizations and projects - const orgsData = await fetchOrganizationsWithProjects(); + const [memberResult, orgsData] = await Promise.all([ + fetchMember().catch(() => null), + fetchOrganizationsWithProjects(), + ]); + let name: string; + if (memberResult) { + name = memberResult.name || memberResult.email; + } else if (tokenResponse?.id_token) { + const userInfo = pkceService.parseIdToken(tokenResponse.id_token); + name = userInfo?.name ?? userInfo?.email ?? ''; + } else { + name = ''; + } + setUserName(name); setOrganizations(orgsData); // Check if there are projects to select from diff --git a/src/ui/SetupUI.tsx b/src/ui/SetupUI.tsx index 005b773..c3fb57a 100644 --- a/src/ui/SetupUI.tsx +++ b/src/ui/SetupUI.tsx @@ -17,6 +17,10 @@ import { getProjectConfigManager, type ProjectConfig, } from '@/lib/config'; +import { + fetchOrganizationsWithProjects, + type OrgWithProjects, +} from '@/lib/services/organization-projects'; import { detectProjectType } from '@/lib/services/project-detector'; import { Header } from '@/ui/components/Header'; import { ProjectSelector } from '@/ui/components/ProjectSelector'; @@ -33,11 +37,6 @@ type SetupPhase = | 'complete' | 'error'; -interface OrgWithProjects { - org: Organization; - projects: Project[]; -} - interface SetupUIProps { /** Called when setup completes successfully */ onComplete?: () => void; @@ -73,22 +72,6 @@ async function fetchUserName( } } -/** Fetch organizations and their projects */ -async function fetchOrganizationsWithProjects(): Promise { - const orgsWithProjects: OrgWithProjects[] = []; - try { - const apiClient = getInternalApiClient(); - const orgs = await apiClient.listOrganizations(); - for (const org of orgs) { - const projects = await apiClient.listProjects(org.id); - orgsWithProjects.push({ org, projects }); - } - } catch { - // Silently ignore org/project fetch errors - } - return orgsWithProjects; -} - export const SetupUI: React.FC = ({ onComplete, onError, projectPath }) => { const { exit } = useApp(); const [phase, setPhase] = useState('checking_auth'); @@ -185,15 +168,15 @@ export const SetupUI: React.FC = ({ onComplete, onError, projectPa const isAuthenticated = await credentialsManager.isAuthenticated(); if (isAuthenticated) { - // Already logged in, fetch data + // Already logged in, fetch data in parallel setPhase('fetching_data'); - const name = await fetchUserName(pkceService); + const [member, orgsData] = await Promise.all([ + fetchMember(), + fetchOrganizationsWithProjects(), + ]); + const name = member.name || member.email; setUserName(name); - - const member = await fetchMember(); memberRef.current = member; - - const orgsData = await fetchOrganizationsWithProjects(); setOrganizations(orgsData); // Check if there are projects to select from diff --git a/src/ui/chat/ChatApp.tsx b/src/ui/chat/ChatApp.tsx index 47900ba..ac37888 100644 --- a/src/ui/chat/ChatApp.tsx +++ b/src/ui/chat/ChatApp.tsx @@ -7,6 +7,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 { InstallPreparationUI } from '../components/InstallPreparationUI'; import { IosSetupFlow } from '../components/IosSetupFlow'; import { MCPInstallSelector } from '../components/MCPInstallSelector'; import { SessionSelector } from '../components/SessionSelector'; @@ -118,6 +119,14 @@ const ChatAppInner: React.FC [onExit, exit], ); + // Execute install skill with preparation context + const executeInstallWithContext = useCallback( + (context: Parameters[1]) => { + chatActions.executeSkill('install', context); + }, + [chatActions], + ); + // Overlay management const overlays = useOverlays({ currentAgent, @@ -127,6 +136,7 @@ const ChatAppInner: React.FC switchAgent: chatActions.switchAgent, resumeSession: chatActions.resumeSession, executeDebugSession: chatActions.executeDebugSession, + executeInstallWithContext, }); // Handle direct transfer command with agent name @@ -231,6 +241,7 @@ const ChatAppInner: React.FC {overlays.activeOverlay === 'firebase' && ( @@ -242,6 +253,13 @@ const ChatAppInner: React.FC }} /> )} + {overlays.activeOverlay === 'install-preparation' && ( + + )} {overlays.activeOverlay === 'login' && ( { diff --git a/src/ui/chat/hooks/useCommandHandler.ts b/src/ui/chat/hooks/useCommandHandler.ts index 9dc4c5a..47e22b2 100644 --- a/src/ui/chat/hooks/useCommandHandler.ts +++ b/src/ui/chat/hooks/useCommandHandler.ts @@ -47,6 +47,7 @@ interface UseCommandHandlerOptions { | 'showDebugPrompt' | 'showFirebaseWizard' | 'showIosSetupOverlay' + | 'showInstallPreparation' | 'showLoginOverlay' | 'showLogoutOverlay' | 'showWhoamiOverlay' @@ -91,6 +92,7 @@ export function useCommandHandler(options: UseCommandHandlerOptions) { showDebugPrompt, showFirebaseWizard, showIosSetupOverlay, + showInstallPreparation, showLoginOverlay, showLogoutOverlay, showWhoamiOverlay, @@ -192,8 +194,13 @@ export function useCommandHandler(options: UseCommandHandlerOptions) { exit(); return; + case 'install': + // Install requires preparation step before agent execution + showInstallPreparation(); + return; + default: - // Handle skill commands + // Handle skill commands (except install which is handled above) if (getSkillCommands().has(command.name)) { executeSkill(command.name as SkillType); return; @@ -218,6 +225,7 @@ export function useCommandHandler(options: UseCommandHandlerOptions) { showDebugPrompt, showFirebaseWizard, showIosSetupOverlay, + showInstallPreparation, showLoginOverlay, showLogoutOverlay, showWhoamiOverlay, diff --git a/src/ui/chat/hooks/useMessageSending.ts b/src/ui/chat/hooks/useMessageSending.ts index 6e67118..181415d 100644 --- a/src/ui/chat/hooks/useMessageSending.ts +++ b/src/ui/chat/hooks/useMessageSending.ts @@ -2,6 +2,7 @@ * Message sending hook for user message submission. */ import { useCallback } from 'react'; +import type { PreparationContext } from '../../../commands/skill/preparation'; import { getDebugPrompt } from '../../../lib/services/debug-service'; import { executeSkill as executeSkillLib, getSkillInfo, type SkillType } from '../../../lib/skills'; import { generateMessageId, useChatContext } from '../context/ChatContext'; @@ -90,7 +91,7 @@ export function useMessageSending(refs: ChatRefs, session: SessionPersistenceAPI ); const executeSkill = useCallback( - async (skillType: SkillType) => { + async (skillType: SkillType, preparationContext?: PreparationContext) => { if (!executorRef.current) { addSystemMessage('No agent configured. Please run "clix config" to select an agent.'); return; @@ -119,6 +120,7 @@ export function useMessageSending(refs: ChatRefs, session: SessionPersistenceAPI projectPath: process.cwd(), signal, oneShot: false, // Chat mode: enable session persistence + preparationContext, }); await processStreamingMessages(messageGenerator, agentMessageId, { signal }); diff --git a/src/ui/chat/hooks/useOverlays.ts b/src/ui/chat/hooks/useOverlays.ts index a8af511..88fae27 100644 --- a/src/ui/chat/hooks/useOverlays.ts +++ b/src/ui/chat/hooks/useOverlays.ts @@ -7,6 +7,7 @@ import { listChatSessions, ONE_WEEK_MS, } from '@/lib/services/session-store'; +import type { PreparationContext } from '../../../commands/skill/preparation'; import type { AgentInfo } from '../../../lib/agents'; import { detectAvailableAgents } from '../../../lib/agents'; import { @@ -25,6 +26,7 @@ export type OverlayType = | 'debug' | 'firebase' | 'ios-setup' + | 'install-preparation' | 'login' | 'logout' | 'whoami' @@ -38,6 +40,7 @@ interface UseOverlaysOptions { switchAgent: ReturnType['switchAgent']; resumeSession: ReturnType['resumeSession']; executeDebugSession: ReturnType['executeDebugSession']; + executeInstallWithContext?: (context: PreparationContext) => void; } /** @@ -52,6 +55,7 @@ export function useOverlays(options: UseOverlaysOptions) { switchAgent, resumeSession, executeDebugSession, + executeInstallWithContext, } = options; // Active overlay @@ -117,6 +121,7 @@ export function useOverlays(options: UseOverlaysOptions) { const showDebugPrompt = useCallback(() => setActiveOverlay('debug'), []); const showFirebaseWizard = useCallback(() => setActiveOverlay('firebase'), []); const showIosSetupOverlay = useCallback(() => setActiveOverlay('ios-setup'), []); + const showInstallPreparation = useCallback(() => setActiveOverlay('install-preparation'), []); const showLoginOverlay = useCallback(() => setActiveOverlay('login'), []); const showLogoutOverlay = useCallback(() => setActiveOverlay('logout'), []); const showWhoamiOverlay = useCallback(() => setActiveOverlay('whoami'), []); @@ -264,6 +269,20 @@ export function useOverlays(options: UseOverlaysOptions) { [addSystemMessage], ); + // Install preparation handlers + const handleInstallPreparationComplete = useCallback( + (context: PreparationContext) => { + setActiveOverlay(null); + executeInstallWithContext?.(context); + }, + [executeInstallWithContext], + ); + + const handleInstallPreparationCancel = useCallback(() => { + setActiveOverlay(null); + addSystemMessage('Install preparation cancelled'); + }, [addSystemMessage]); + // Login handlers const handleLoginComplete = useCallback( (message: string) => { @@ -335,6 +354,11 @@ export function useOverlays(options: UseOverlaysOptions) { showIosSetupOverlay, handleIosSetupComplete, + // Install preparation + showInstallPreparation, + handleInstallPreparationComplete, + handleInstallPreparationCancel, + // Auth overlays showLoginOverlay, handleLoginComplete, diff --git a/src/ui/components/FirebaseWizard.tsx b/src/ui/components/FirebaseWizard.tsx index a17f645..b8657fb 100644 --- a/src/ui/components/FirebaseWizard.tsx +++ b/src/ui/components/FirebaseWizard.tsx @@ -15,21 +15,26 @@ import { type FirebaseProject, FirebaseService, type FirebaseSetupResult, + type GcpProject, type IosApp, isOAuthConfigured, + parseServiceAccountJson, platformNeedsAndroid, platformNeedsIos, - type WizardPhase, + type ServiceAccountJson, + type ServiceAccountValidationResult, } from '@/lib/services/firebase'; import { detectProjectType } from '@/lib/services/project-detector'; import { OAUTH_CALLBACK_CONFIG } from '@/lib/utils/oauth'; import { useCancelInput } from '@/ui/hooks'; import { FirebaseStatusDisplay } from './FirebaseStatusDisplay'; +import { type ExtendedWizardPhase, transition } from './firebase-wizard-transitions'; import { GenericSelector, type SelectorItem } from './GenericSelector'; interface FirebaseWizardProps { projectPath: string; projectType?: ProjectType; + clixProjectId?: string; onComplete: (result: FirebaseSetupResult) => void; onCancel?: () => void; } @@ -38,21 +43,6 @@ interface MenuAction extends SelectorItem { action: CredentialAction; } -/** - * Extended wizard phase for download flow. - */ -type ExtendedWizardPhase = - | WizardPhase - | 'authenticating' - | 'select_project' - | 'select_android_app' - | 'select_ios_app' - | 'downloading' - | 'no_apps_found' - | 'create_android_app' - | 'create_ios_app' - | 'creating_app'; - /** * No apps found context (which platform apps are missing). */ @@ -77,6 +67,89 @@ function platformNeedsIosWithUnknown(platform: FirebaseDetectionResult['platform return platformNeedsIos(platform) || platform === 'unknown'; } +/** + * Get Firebase project ID from detection result. + * Tries Android config first, then iOS config. + */ +function getProjectIdFromResult(result: FirebaseDetectionResult | null): string | undefined { + // Android config (GoogleServicesJson) has project_info.project_id + if (result?.android?.content && 'project_info' in result.android.content) { + return result.android.content.project_info?.project_id; + } + // iOS config (GoogleServiceInfoPlist) has PROJECT_ID + if (result?.ios?.content && 'PROJECT_ID' in result.ios.content) { + return result.ios.content.PROJECT_ID; + } + return undefined; +} + +/** + * Build Android-specific menu items. + */ +function buildAndroidMenuItems( + result: FirebaseDetectionResult, + needsAndroid: boolean, +): MenuAction[] { + if (!needsAndroid) return []; + + const items: MenuAction[] = []; + + if (!result.android?.valid) { + items.push({ + id: 'redetect-android', + label: 'Re-detect google-services.json', + description: result.android ? 'File found but invalid' : 'File not found', + action: { type: 'redetect_platform', platform: 'android' }, + }); + } + if (result.android) { + items.push({ + id: 'validate-android', + label: 'Validate google-services.json', + action: { type: 'validate', platform: 'android' }, + }); + } + items.push({ + id: 'help-android', + label: 'Help: Download google-services.json', + action: { type: 'help', topic: 'downloadConfig' }, + }); + + return items; +} + +/** + * Build iOS-specific menu items. + */ +function buildIosMenuItems(result: FirebaseDetectionResult, needsIos: boolean): MenuAction[] { + if (!needsIos) return []; + + const items: MenuAction[] = []; + + if (!result.ios?.valid) { + items.push({ + id: 'redetect-ios', + label: 'Re-detect GoogleService-Info.plist', + description: result.ios ? 'File found but invalid' : 'File not found', + action: { type: 'redetect_platform', platform: 'ios' }, + }); + } + if (result.ios) { + items.push({ + id: 'validate-ios', + label: 'Validate GoogleService-Info.plist', + action: { type: 'validate', platform: 'ios' }, + }); + } + items.push({ + id: 'help-ios', + label: 'Help: Download GoogleService-Info.plist', + action: { type: 'help', topic: 'downloadConfig' }, + }); + + return items; +} + /** * Build menu items based on detection result. */ @@ -102,51 +175,18 @@ function buildMenuItems(result: FirebaseDetectionResult): MenuAction[] { }); } - // Android actions - if (needsAndroid) { - if (!result.android?.valid) { - items.push({ - id: 'redetect-android', - label: 'Re-detect google-services.json', - description: result.android ? 'File found but invalid' : 'File not found', - action: { type: 'redetect_platform', platform: 'android' }, - }); - } - if (result.android) { - items.push({ - id: 'validate-android', - label: 'Validate google-services.json', - action: { type: 'validate', platform: 'android' }, - }); - } - items.push({ - id: 'help-android', - label: 'Help: Download google-services.json', - action: { type: 'help', topic: 'downloadConfig' }, - }); - } + // Platform-specific actions (extracted to reduce complexity) + items.push(...buildAndroidMenuItems(result, needsAndroid)); + items.push(...buildIosMenuItems(result, needsIos)); - // iOS actions - if (needsIos) { - if (!result.ios?.valid) { - items.push({ - id: 'redetect-ios', - label: 'Re-detect GoogleService-Info.plist', - description: result.ios ? 'File found but invalid' : 'File not found', - action: { type: 'redetect_platform', platform: 'ios' }, - }); - } - if (result.ios) { - items.push({ - id: 'validate-ios', - label: 'Validate GoogleService-Info.plist', - action: { type: 'validate', platform: 'ios' }, - }); - } + // Service Account setup (if OAuth is configured and any platform config is valid) + const hasValidConfig = result.android?.valid || result.ios?.valid; + if (isOAuthConfigured() && hasValidConfig) { items.push({ - id: 'help-ios', - label: 'Help: Download GoogleService-Info.plist', - action: { type: 'help', topic: 'downloadConfig' }, + id: 'setup-service-account', + label: '🔑 Setup Service Account', + description: 'Create or configure Firebase Admin SDK credentials', + action: { type: 'setup_service_account' }, }); } @@ -537,6 +577,541 @@ function CreatingAppPhase({ platform }: { platform: 'android' | 'ios' }): React. ); } +/** + * No projects found phase component - offers to create or link Firebase project. + */ +function NoProjectsPhase({ + onOpenConsole, + onSelectGcp, + onCancel, +}: { + onOpenConsole: () => void; + onSelectGcp: () => void; + onCancel: () => void; +}): React.ReactElement { + const items = [ + { + label: '🌐 Open Firebase Console', + value: 'console', + }, + { + label: '📦 Add Firebase to existing GCP project', + value: 'gcp', + }, + { + label: '← Back', + value: 'cancel', + }, + ]; + + useCancelInput(onCancel); + + const handleSelect = (item: { value: string }) => { + switch (item.value) { + case 'console': + onOpenConsole(); + break; + case 'gcp': + onSelectGcp(); + break; + case 'cancel': + onCancel(); + break; + } + }; + + return ( + + + + No Firebase Projects Found + + + + No Firebase projects are associated with this account. + + + Create a new project or add Firebase to an existing GCP project: + + + + ↑↓ navigate · Enter select · Esc/Ctrl+C cancel + + + ); +} + +/** + * GCP project selector component. + */ +function GcpProjectSelector({ + projects, + onSelect, + onCancel, +}: { + projects: GcpProject[]; + onSelect: (project: GcpProject) => void; + onCancel: () => void; +}): React.ReactElement { + const items = projects.map((p) => ({ + label: p.name || p.projectId, + value: p, + })); + + useCancelInput(onCancel); + + return ( + + + Select GCP Project + + + Select a project to add Firebase: + + onSelect(item.value)} /> + + ↑↓ navigate · Enter select · Esc/Ctrl+C cancel + + + ); +} + +/** + * Adding Firebase to GCP project phase component. + */ +function AddingFirebasePhase({ projectId }: { projectId: string }): React.ReactElement { + return ( + + + Adding Firebase + + + + + + Adding Firebase to {projectId}... + + + This may take a moment... + + + ); +} + +/** + * Service Account menu action type. + */ +type ServiceAccountMenuAction = + | { type: 'open_console' } + | { type: 'paste_json' } + | { type: 'skip' }; + +/** + * Service Account menu phase component. + */ +function ServiceAccountMenuPhase({ + projectId, + onAction, + onCancel, +}: { + projectId: string; + onAction: (action: ServiceAccountMenuAction) => void; + onCancel: () => void; +}): React.ReactElement { + const items: Array<{ label: string; value: ServiceAccountMenuAction }> = [ + { + label: '🌐 Download from Firebase Console', + value: { type: 'open_console' }, + }, + { + label: '📄 Paste Service Account JSON', + value: { type: 'paste_json' }, + }, + { + label: '⏭ Skip (configure later)', + value: { type: 'skip' }, + }, + ]; + + useCancelInput(onCancel); + + return ( + + + Service Account Setup + + + Project: {projectId} + + + Service Account is required for server-side Firebase Admin SDK. + + + + Download the private key JSON from Firebase Console (Project Settings → Service Accounts → + Generate new private key), then paste it here. + + + onAction(item.value)} /> + + ↑↓ navigate · Enter select · Esc/Ctrl+C cancel + + + ); +} + +/** + * Import Service Account JSON phase component. + * Accepts a file path to a downloaded JSON file. + */ +/** + * Read text content from system clipboard. + * Uses pbpaste on macOS, xclip or xsel on Linux. + */ +async function readClipboard(): Promise { + const { execFile } = await import('node:child_process'); + const { promisify } = await import('node:util'); + const execFileAsync = promisify(execFile); + + const platform = process.platform; + try { + if (platform === 'darwin') { + const { stdout } = await execFileAsync('pbpaste', []); + return stdout; + } + if (platform === 'linux') { + try { + const { stdout } = await execFileAsync('xclip', ['-selection', 'clipboard', '-o']); + return stdout; + } catch { + const { stdout } = await execFileAsync('xsel', ['--clipboard', '--output']); + return stdout; + } + } + return null; + } catch { + return null; + } +} + +/** + * Read file content from a file path. + * Supports ~ home directory and relative paths. + */ +async function readFileFromPath(filePath: string): Promise { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + + // Strip surrounding quotes (from drag & drop on some terminals) + const cleaned = filePath.replace(/^['"]|['"]$/g, ''); + const resolved = cleaned.startsWith('~') + ? path.join(process.env.HOME || '', cleaned.slice(1)) + : path.resolve(cleaned); + + return fs.readFile(resolved, 'utf-8'); +} + +function PasteServiceAccountPhase({ + onSubmit, + onCancel, +}: { + onSubmit: (json: ServiceAccountJson) => void; + onCancel: () => void; +}): React.ReactElement { + const [input, setInput] = useState(''); + const [validation, setValidation] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + const [loading, setLoading] = useState(false); + const [loadingText, setLoadingText] = useState(''); + + useCancelInput(onCancel); + + const processJson = useCallback( + (content: string, source: string) => { + const result = parseServiceAccountJson(content); + if (result.valid && result.data) { + setValidation(result); + onSubmit(result.data); + } else { + setValidation(result); + setErrorMsg(`Invalid Service Account JSON (from ${source})`); + } + }, + [onSubmit], + ); + + const handleSubmit = useCallback(async () => { + const trimmed = input.trim(); + + setLoading(true); + setErrorMsg(null); + setValidation(null); + + try { + if (!trimmed) { + // Empty input → read from clipboard + setLoadingText('Reading from clipboard...'); + const clipboard = await readClipboard(); + if (!clipboard?.trim()) { + setErrorMsg('Clipboard is empty. Copy the JSON content first.'); + return; + } + processJson(clipboard, 'clipboard'); + } else if (trimmed.startsWith('{')) { + // JSON-like input → parse directly + processJson(trimmed, 'input'); + } else { + // File path → read file + setLoadingText('Reading file...'); + const content = await readFileFromPath(trimmed); + processJson(content, 'file'); + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + setErrorMsg('File not found. Please check the path.'); + } else { + setErrorMsg(err instanceof Error ? err.message : 'Failed to read input'); + } + } finally { + setLoading(false); + setLoadingText(''); + } + }, [input, processJson]); + + return ( + + + Import Service Account JSON + + + + Firebase Console → Project Settings → Service accounts → Generate new private key + + + + 1. Copy JSON to clipboard → press Enter + 2. Drag the JSON file here → press Enter + + + + {'> '} + + + + {loading && ( + + + + + {loadingText} + + )} + + {errorMsg && ( + + ✗ {errorMsg} + + )} + + {validation && !validation.valid && ( + + Validation errors: + {validation.errors.map((err) => ( + + • {err} + + ))} + + )} + + + Enter to import · Esc/Ctrl+C cancel + + + ); +} + +/** + * Saving Service Account phase component. + */ +function SavingServiceAccountPhase(): React.ReactElement { + return ( + + + Saving Service Account + + + + + + Saving service account key... + + + ); +} + +/** + * Checking sender config phase component. + */ +function CheckingSenderConfigPhase(): React.ReactElement { + return ( + + + Firebase Configuration + + + + + + Checking push notification configuration... + + + ); +} + +/** + * Sender config already registered phase component. + */ +function SenderConfigRegisteredPhase({ + updatedAt, + onContinue, + onCancel, +}: { + updatedAt: string | null; + onContinue: () => void; + onCancel: () => void; +}): React.ReactElement { + useInput((_input, key) => { + if (key.return) { + onContinue(); + } + }); + + useCancelInput(onCancel); + + const formattedDate = updatedAt ? new Date(updatedAt).toLocaleString() : 'unknown'; + + return ( + + + Push Notification Configuration + + + ✓ FCM sender config is already registered + + + Configured at: {formattedDate} + + + Press Enter to continue, Esc/Ctrl+C to cancel + + + ); +} + +/** + * Registering sender config phase component. + */ +function RegisteringSenderConfigPhase({ + result, +}: { + result: 'success' | 'failed' | null; +}): React.ReactElement { + return ( + + + Push Notification Configuration + + {result === 'success' ? ( + + ✓ Push notification configured successfully + + ) : result === 'failed' ? ( + + + ✗ Failed to register sender config (can be configured later in Console) + + + ) : ( + + + + + Registering push notification configuration... + + )} + + ); +} + /** * Detecting phase component. */ @@ -691,6 +1266,20 @@ Add this to your OAuth client: ${OAUTH_CALLBACK_CONFIG.getCallbackUrlIp()}`; if (error.includes('invalid_grant')) { return `The authorization code has expired or already been used. Please try again.`; + } + if (error.includes('invalid_request')) { + return `The OAuth request was malformed or missing required parameters. +This can happen when: +- OAuth client redirect URI is misconfigured +- Required PKCE parameters are missing or invalid +- Browser session expired before completing auth + +Check .clix/debug.log for detailed error information.`; + } + if (error.includes('API has not been used in project') || error.includes('it is disabled')) { + return `A required Google Cloud API is not enabled for this project. +Visit the URL shown above to enable it in Google Cloud Console. +After enabling, wait a few minutes then press Enter to retry.`; } return null; } @@ -739,6 +1328,11 @@ function ErrorPhase({ {hint} )} + {!hint && ( + + See .clix/debug.log for details + + )} Press Enter to retry, Esc/Ctrl+C to skip @@ -805,6 +1399,7 @@ function CompletePhase({ export const FirebaseWizard: React.FC = ({ projectPath, projectType: propProjectType, + clixProjectId, onComplete, onCancel, }) => { @@ -829,6 +1424,17 @@ export const FirebaseWizard: React.FC = ({ const [noAppsContext, setNoAppsContext] = useState(null); const [creatingAppPlatform, setCreatingAppPlatform] = useState<'android' | 'ios'>('android'); + // GCP project flow state (for adding Firebase to existing GCP project) + const [gcpProjects, setGcpProjects] = useState([]); + const [selectedGcpProject, setSelectedGcpProject] = useState(null); + + // Service Account flow state + const [, setServiceAccountJson] = useState(null); + + // Sender config state + const [senderConfigUpdatedAt, setSenderConfigUpdatedAt] = useState(null); + const [senderConfigResult, setSenderConfigResult] = useState<'success' | 'failed' | null>(null); + const [service, setService] = useState(null); const [projectType, setProjectType] = useState(propProjectType ?? null); @@ -842,10 +1448,10 @@ export const FirebaseWizard: React.FC = ({ setService(firebaseService); const detectionResult = await firebaseService.detect(); setResult(detectionResult); - setPhase('status'); + setPhase(transition('detecting', 'success')); } catch (err) { setError(err instanceof Error ? err.message : 'Detection failed'); - setPhase('error'); + setPhase(transition('detecting', 'error')); } }; @@ -854,13 +1460,47 @@ export const FirebaseWizard: React.FC = ({ } }, [phase, projectPath, propProjectType]); + // Sender config check effect + useEffect(() => { + if (phase !== 'checking_sender_config') return; + + const checkSenderConfig = async () => { + // Skip API check if no Clix project linked, but still go to SA setup + if (!clixProjectId) { + setPhase(transition('checking_sender_config', 'skip')); + return; + } + + try { + const { getInternalApiClient } = await import('@/lib/api'); + const apiClient = getInternalApiClient(); + const project = await apiClient.getProject(clixProjectId); + const pushConfig = project.sender_configs?.find( + (c) => c.channel_type === 'CHANNEL_TYPE_APP_PUSH', + ); + + if (pushConfig) { + setSenderConfigUpdatedAt(pushConfig.updated_at ?? pushConfig.created_at ?? null); + setPhase(transition('checking_sender_config', 'registered')); + } else { + setPhase(transition('checking_sender_config', 'not_registered')); + } + } catch { + // API error should not block setup, still go to SA menu + setPhase(transition('checking_sender_config', 'error')); + } + }; + + checkSenderConfig(); + }, [phase, clixProjectId]); + const handleContinue = useCallback(() => { - setPhase('menu'); + setPhase(transition('status', 'continue')); }, []); const handleSkip = useCallback(() => { setSkipped(true); - setPhase('complete'); + setPhase(transition('menu', 'skip')); onComplete({ completed: false, skipped: true, @@ -883,7 +1523,7 @@ export const FirebaseWizard: React.FC = ({ async (project: FirebaseProject, androidApp: AndroidApp | null, iosApp: IosApp | null) => { if (!projectType) { setError('Project type not detected'); - setPhase('error'); + setPhase(transition('select_project', 'error')); return; } @@ -894,7 +1534,7 @@ export const FirebaseWizard: React.FC = ({ } else { setDownloadingPlatform('ios'); } - setPhase('downloading'); + setPhase('downloading'); // Direct set: entry point from multiple phases try { const paths = downloader.getExpectedSavePaths(projectPath, projectType); @@ -916,10 +1556,10 @@ export const FirebaseWizard: React.FC = ({ const newResult = await service.detect(); setResult(newResult); } - setPhase('status'); + setPhase(transition('downloading', 'success')); } catch (err) { setError(err instanceof Error ? err.message : 'Download failed'); - setPhase('error'); + setPhase(transition('downloading', 'error')); } }, [projectPath, projectType, downloader, service], @@ -962,7 +1602,7 @@ export const FirebaseWizard: React.FC = ({ } else if (apps.length === 1) { downloadConfigsRef.current(project, app, apps[0]); } else { - setPhase('select_ios_app'); + setPhase(transition('select_android_app', 'select_ios_app')); } } catch { // Failed to get iOS apps, just download Android @@ -989,7 +1629,7 @@ export const FirebaseWizard: React.FC = ({ if (apps.length === 1) { androidAppSelectRef.current(apps[0], project, needsIosConfig); } else { - setPhase('select_android_app'); + setPhase(transition('select_project', 'select_android_app')); } return true; }, @@ -1009,7 +1649,7 @@ export const FirebaseWizard: React.FC = ({ if (apps.length === 1) { iosAppSelectRef.current(apps[0], project); } else { - setPhase('select_ios_app'); + setPhase(transition('select_project', 'select_ios_app')); } return true; }, @@ -1058,7 +1698,7 @@ export const FirebaseWizard: React.FC = ({ if (!shouldFetchAndroid && !shouldFetchIos) { setError('No configuration files needed for this platform.'); - setPhase('error'); + setPhase(transition('select_project', 'error')); return; } @@ -1076,11 +1716,11 @@ export const FirebaseWizard: React.FC = ({ needsAndroid: shouldFetchAndroid, needsIos: shouldFetchIos, }); - setPhase('no_apps_found'); + setPhase(transition('select_project', 'no_apps_found')); } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch apps'); - setPhase('error'); + setPhase(transition('select_project', 'error')); } }, [getPlatformNeeds, fetchAppsForPlatforms, result], @@ -1092,7 +1732,7 @@ export const FirebaseWizard: React.FC = ({ // Handle download authentication const handleDownload = useCallback(async () => { - setPhase('authenticating'); + setPhase('authenticating'); // Direct set: entry point from menu/download action try { // Check if already authenticated @@ -1103,7 +1743,7 @@ export const FirebaseWizard: React.FC = ({ const authResult = await downloader.authenticate(openBrowser); if (!authResult.success) { setError(authResult.error || 'Authentication failed. Please try again.'); - setPhase('error'); + setPhase(transition('authenticating', 'error')); return; } } @@ -1111,8 +1751,8 @@ export const FirebaseWizard: React.FC = ({ // Fetch projects const fetchedProjects = await downloader.listProjects(); if (fetchedProjects.length === 0) { - setError('No Firebase projects found for this account.'); - setPhase('error'); + // No Firebase projects - show options to create or add Firebase to GCP project + setPhase(transition('authenticating', 'no_projects')); return; } @@ -1122,14 +1762,27 @@ export const FirebaseWizard: React.FC = ({ if (fetchedProjects.length === 1) { projectSelectRef.current(fetchedProjects[0]); } else { - setPhase('select_project'); + setPhase(transition('authenticating', 'select_project')); } } catch (err) { setError(err instanceof Error ? err.message : 'Authentication failed'); - setPhase('error'); + setPhase(transition('authenticating', 'error')); } }, [downloader]); + // Handler for Service Account setup (defined before handleAction to be used in it) + const handleServiceAccountSetup = useCallback(() => { + // Get project ID from detection result + const projectId = getProjectIdFromResult(result); + if (!projectId) { + setError('No Firebase project configured. Please download config files first.'); + setPhase('error'); + return; + } + + setPhase(transition('menu', 'setup_service_account')); + }, [result]); + const handleAction = useCallback( async (action: CredentialAction) => { switch (action.type) { @@ -1138,27 +1791,27 @@ export const FirebaseWizard: React.FC = ({ break; case 'redetect': - setPhase('detecting'); + setPhase(transition('menu', 'redetect')); break; case 'redetect_platform': setValidatingPlatform(action.platform); - setPhase('detecting'); + setPhase(transition('menu', 'redetect_platform')); break; case 'validate': setValidatingPlatform(action.platform); - setPhase('validating'); + setPhase(transition('menu', 'validate')); // Re-detect to validate try { if (service) { const newResult = await service.detect(); setResult(newResult); } - setPhase('status'); + setPhase(transition('validating', 'success')); } catch (err) { setError(err instanceof Error ? err.message : 'Validation failed'); - setPhase('error'); + setPhase(transition('validating', 'error')); } break; @@ -1173,21 +1826,26 @@ export const FirebaseWizard: React.FC = ({ break; case 'done': - setPhase('complete'); + setPhase(transition('menu', 'done')); onComplete({ completed: true, skipped: false, detection: result, }); break; + + case 'setup_service_account': + // Start service account setup flow + await handleServiceAccountSetup(); + break; } }, - [service, handleSkip, handleDownload, onComplete, result], + [service, handleSkip, handleDownload, onComplete, result, handleServiceAccountSetup], ); const handleRetry = useCallback(async () => { - // Check if this was a scope error - if so, logout and re-authenticate - if (error && isScopeInsufficientError(error)) { + // Check if this was a scope error or invalid_grant - if so, logout and re-authenticate + if (error && (isScopeInsufficientError(error) || error.includes('invalid_grant'))) { // Clear tokens and trigger re-authentication await downloader.logout(); setError(null); @@ -1197,7 +1855,7 @@ export const FirebaseWizard: React.FC = ({ } setError(null); - setPhase('detecting'); + setPhase(transition('error', 'retry')); }, [error, downloader, handleDownload]); const handleCancel = useCallback(() => { @@ -1218,7 +1876,7 @@ export const FirebaseWizard: React.FC = ({ } setCreatingAppPlatform(platform); - setPhase('creating_app'); + setPhase('creating_app'); // Direct set: entry point from create_android_app/create_ios_app try { if (platform === 'android') { @@ -1237,7 +1895,7 @@ export const FirebaseWizard: React.FC = ({ ...noAppsContext, noAndroidApps: false, }); - setPhase('no_apps_found'); + setPhase(transition('creating_app', 'no_apps_found')); } else if (needsIos) { // Fetch iOS apps const iosAppsList = await downloader.listIosApps(selectedProject.projectId); @@ -1248,7 +1906,7 @@ export const FirebaseWizard: React.FC = ({ downloadConfigsRef.current(selectedProject, app, iosAppsList[0]); } else { setIosApps(iosAppsList); - setPhase('select_ios_app'); + setPhase(transition('creating_app', 'select_ios_app')); } } else { // Only needed Android, download config @@ -1266,7 +1924,7 @@ export const FirebaseWizard: React.FC = ({ } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create app'); - setPhase('error'); + setPhase(transition('creating_app', 'error')); } }, [selectedProject, downloader, getPlatformNeeds, noAppsContext, selectedAndroidApp], @@ -1275,12 +1933,12 @@ export const FirebaseWizard: React.FC = ({ // Handlers for no apps found phase const handleStartCreateAndroid = useCallback(() => { setCreatingAppPlatform('android'); - setPhase('create_android_app'); + setPhase(transition('no_apps_found', 'create_android')); }, []); const handleStartCreateIos = useCallback(() => { setCreatingAppPlatform('ios'); - setPhase('create_ios_app'); + setPhase(transition('no_apps_found', 'create_ios')); }, []); const handleCreateAndroidSubmit = useCallback( @@ -1299,9 +1957,154 @@ export const FirebaseWizard: React.FC = ({ const handleNoAppsCancel = useCallback(() => { // Go back to project selection - setPhase('select_project'); + setPhase(transition('no_apps_found', 'cancel')); }, []); + // Handler for opening Firebase Console + const handleOpenFirebaseConsole = useCallback(() => { + openBrowser('https://console.firebase.google.com/'); + // Go back to menu after opening + setPhase(transition('no_projects', 'open_console')); + }, []); + + // Handler for fetching GCP projects (to add Firebase) + const handleFetchGcpProjects = useCallback(async () => { + setPhase('authenticating'); // Direct set: entry point from no_projects + try { + const availableGcpProjects = await downloader.listAvailableGcpProjects(); + if (availableGcpProjects.length === 0) { + setError('No GCP projects available to add Firebase. Create a project first.'); + setPhase(transition('authenticating', 'error')); + return; + } + setGcpProjects(availableGcpProjects); + setPhase(transition('authenticating', 'select_gcp_project')); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch GCP projects'); + setPhase(transition('authenticating', 'error')); + } + }, [downloader]); + + // Handler for GCP project selection + const handleGcpProjectSelect = useCallback( + async (project: GcpProject) => { + setSelectedGcpProject(project); + setPhase(transition('select_gcp_project', 'adding_firebase')); + + try { + const firebaseProject = await downloader.addFirebaseToProject(project.projectId); + // Add to projects list and select it + setProjects([firebaseProject]); + projectSelectRef.current(firebaseProject); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add Firebase to project'); + setPhase(transition('adding_firebase', 'error')); + } + }, + [downloader], + ); + + // Handler for Service Account menu action + const handleServiceAccountMenuAction = useCallback( + (action: ServiceAccountMenuAction) => { + const projectId = getProjectIdFromResult(result); + if (!projectId) { + setError('No project ID found'); + setPhase(transition('service_account_menu', 'error')); + return; + } + + switch (action.type) { + case 'open_console': + openBrowser( + `https://console.firebase.google.com/project/${projectId}/settings/serviceaccounts/adminsdk`, + ); + // Stay on menu so user can paste after downloading + setPhase(transition('service_account_menu', 'open_console')); + break; + + case 'paste_json': + setPhase(transition('service_account_menu', 'paste_json')); + break; + + case 'skip': + setPhase(transition('service_account_menu', 'skip')); + break; + } + }, + [result], + ); + + // Handler for pasting service account JSON + // Only saves the file, then transitions to registering_sender_config or complete + const handleSaveServiceAccountJson = useCallback( + async (json: ServiceAccountJson) => { + setPhase(transition('paste_service_account', 'save')); + + try { + setServiceAccountJson(json); + await downloader.saveServiceAccountJson(projectPath, json); + + // Decide next phase: register sender config if Clix project is linked + if (clixProjectId) { + setPhase(transition('saving_service_account', 'register')); + } else { + setPhase(transition('saving_service_account', 'complete')); + onComplete({ completed: true, skipped: false, detection: result }); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save service account'); + setPhase(transition('saving_service_account', 'error')); + } + }, + [downloader, projectPath, clixProjectId, onComplete, result], + ); + + // Sender config registration effect - triggered when entering registering_sender_config phase + useEffect(() => { + if (phase !== 'registering_sender_config' || !clixProjectId) return; + + const registerSenderConfig = async () => { + try { + const { getInternalApiClient } = await import('@/lib/api'); + const apiClient = getInternalApiClient(); + const saJsonPath = `${projectPath}/.clix/firebase-service-account.json`; + const { readFile } = await import('node:fs/promises'); + const saContent = await readFile(saJsonPath, 'utf-8'); + const encoded = btoa(saContent); + await apiClient.createOrUpdateSenderConfig(clixProjectId, { + channel_type: 'CHANNEL_TYPE_APP_PUSH', + app_push: { + ios_config: { fcm_sa_json_base64_encoded: encoded }, + android_config: { fcm_sa_json_base64_encoded: encoded }, + }, + }); + setSenderConfigResult('success'); + } catch { + setSenderConfigResult('failed'); + } + + // Auto-complete after brief display + setTimeout(() => { + setPhase(transition('registering_sender_config', 'complete')); + onComplete({ completed: true, skipped: false, detection: result }); + }, 1500); + }; + + registerSenderConfig(); + }, [phase, clixProjectId, projectPath, onComplete, result]); + + // Handler for going back to service account menu + const handleServiceAccountCancel = useCallback(() => { + setPhase(transition('paste_service_account', 'cancel')); + }, []); + + // Handler for sender config registered → complete wizard + const handleSenderConfigContinue = useCallback(() => { + setPhase(transition('sender_config_registered', 'continue')); + onComplete({ completed: true, skipped: false, detection: result }); + }, [onComplete, result]); + switch (phase) { case 'detecting': return ; @@ -1404,6 +2207,63 @@ export const FirebaseWizard: React.FC = ({ case 'creating_app': return ; + // New phases for no projects and service account + case 'no_projects': + return ( + + ); + + case 'select_gcp_project': + return ( + + ); + + case 'adding_firebase': + return ; + + case 'service_account_menu': + return ( + + ); + + case 'paste_service_account': + return ( + + ); + + case 'saving_service_account': + return ; + + case 'checking_sender_config': + return ; + + case 'sender_config_registered': + return ( + + ); + + case 'registering_sender_config': + return ; + case 'error': return ( diff --git a/src/ui/components/InstallPreparationUI.tsx b/src/ui/components/InstallPreparationUI.tsx new file mode 100644 index 0000000..bdf53bc --- /dev/null +++ b/src/ui/components/InstallPreparationUI.tsx @@ -0,0 +1,481 @@ +/** + * Install preparation UI component. + * + * Orchestrates the preparation steps before SDK installation: + * 1. Check project config + * 2. Firebase setup (if needed) + * 3. iOS setup (if needed) + * 4. Ready for agent execution + * + * @module ui/components/InstallPreparationUI + */ + +import { Box, Text } from 'ink'; +import Spinner from 'ink-spinner'; +import type React from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import type { FirebaseSetupResult } from '@/lib/services/firebase'; +import { formatProjectType } from '@/lib/services/project-detector'; +import { useCancelInput } from '@/ui/hooks'; +import { + type FirebaseStatus, + gatherPreparationContext, + type PreparationContext, + saveSetupStatus, +} from '../../commands/skill/preparation'; +import { FirebaseWizard } from './FirebaseWizard'; +import { GenericSelector, type SelectorItem } from './GenericSelector'; + +type PreparationPhase = + | 'checking' + | 'config_missing' + | 'firebase_check' + | 'firebase_setup' + | 'ios_check' + | 'ios_setup' + | 'ready' + | 'cancelled'; + +interface InstallPreparationUIProps { + projectPath?: string; + onComplete: (context: PreparationContext) => void; + onCancel: () => void; +} + +interface ActionItem extends SelectorItem { + action: 'configure' | 'skip' | 'cancel'; +} + +export function getInitialPreparationPhase(context: PreparationContext): PreparationPhase { + if (!context.firebase.configured && context.firebase.needed) { + return 'firebase_check'; + } + if (!context.ios.entitlementsConfigured && context.ios.needed) { + return 'ios_check'; + } + if (context.ready) { + return 'ready'; + } + return 'firebase_check'; +} + +export function getPostFirebasePhase(context: PreparationContext): PreparationPhase { + if (context.ios.needed && !context.ios.entitlementsConfigured) { + return 'ios_setup'; + } + return 'ready'; +} + +function StatusLine({ + label, + status, + detail, +}: { + label: string; + status: 'pending' | 'checking' | 'ok' | 'missing' | 'skipped'; + detail?: string; +}): React.ReactElement { + const icon = + status === 'ok' + ? '✓' + : status === 'missing' + ? '✗' + : status === 'skipped' + ? '○' + : status === 'checking' + ? '○' + : '○'; + const color = + status === 'ok' + ? 'green' + : status === 'missing' + ? 'red' + : status === 'skipped' + ? 'yellow' + : 'gray'; + + return ( + + {icon} + {label} + {detail && ({detail})} + {status === 'checking' && ( + + {' '} + + + )} + + ); +} + +function CheckingPhase(): React.ReactElement { + return ( + + + + + + Checking project configuration... + + + ); +} + +function ConfigMissingPhase({ onCancel }: { onCancel: () => void }): React.ReactElement { + useCancelInput(onCancel); + + return ( + + ✗ Project not linked + Run "clix login" first to link this project. + + Press Esc to exit + + + ); +} + +function StatusPhase({ + context, + onContinue, + onCancel, +}: { + context: PreparationContext; + onContinue: () => void; + onCancel: () => void; +}): React.ReactElement { + const items: ActionItem[] = [ + { + id: 'continue', + label: context.ready ? 'Continue to installation' : 'Continue anyway (some setup missing)', + action: 'configure', + }, + { id: 'cancel', label: 'Cancel', action: 'cancel' }, + ]; + + const handleSelect = useCallback( + (item: ActionItem) => { + if (item.action === 'cancel') { + onCancel(); + } else { + onContinue(); + } + }, + [onCancel, onContinue], + ); + + return ( + + Install Preparation + + + + {context.firebase.needed && ( + + )} + {context.ios.needed && ( + <> + + + + )} + + + {context.missing.length > 0 && ( + + Missing setup: + {context.missing.map((item) => ( + + • {item} + + ))} + + )} + + + + ); +} + +function FirebaseSetupPhase({ + context, + onComplete, + onCancel, +}: { + context: PreparationContext; + onComplete: (result: FirebaseSetupResult) => void; + onCancel: () => void; +}): React.ReactElement { + return ( + + Firebase Setup + + Firebase configuration is required for push notifications. + + + + ); +} + +function ReadyPhase({ + context, + onContinue, + onCancel, +}: { + context: PreparationContext; + onContinue: () => void; + onCancel: () => void; +}): React.ReactElement { + const items: ActionItem[] = [ + { id: 'continue', label: 'Continue to installation', action: 'configure' }, + { id: 'cancel', label: 'Cancel', action: 'cancel' }, + ]; + + const handleSelect = useCallback( + (item: ActionItem) => { + if (item.action === 'cancel') { + onCancel(); + } else { + onContinue(); + } + }, + [onCancel, onContinue], + ); + + return ( + + + ✓ Preparation review complete + + + Project: {context.config.project.name} + Type: {formatProjectType(context.projectType)} + {context.firebase.projectId && Firebase: {context.firebase.projectId}} + + {context.missing.length > 0 && ( + + Will be handled during installation: + {context.missing.map((item) => ( + + • {item} + + ))} + + )} + + + ); +} + +function IosSetupAutoPhase({ onContinue }: { onContinue: () => void }): React.ReactElement { + useEffect(() => { + onContinue(); + }, [onContinue]); + + return ( + + + + iOS setup will continue during installation. + + + + + + Proceeding to final confirmation... + + + ); +} + +/** + * Install preparation UI component. + * + * Guides user through setup steps before SDK installation. + */ +export function InstallPreparationUI({ + projectPath = process.cwd(), + onComplete, + onCancel, +}: InstallPreparationUIProps): React.ReactElement { + const [phase, setPhase] = useState('checking'); + const [context, setContext] = useState(null); + + // Gather preparation context on mount + useEffect(() => { + let mounted = true; + + async function check() { + const ctx = await gatherPreparationContext(projectPath); + + if (!mounted) return; + + if (!ctx) { + setPhase('config_missing'); + return; + } + + setContext(ctx); + setPhase(getInitialPreparationPhase(ctx)); + } + + check(); + + return () => { + mounted = false; + }; + }, [projectPath]); + + const handleFirebaseComplete = useCallback( + async (result: FirebaseSetupResult) => { + if (!context) return; + + // Extract status from detection result + const detection = result.detection; + const androidConfigured = detection?.android?.valid ?? context.firebase.androidConfigured; + const iosConfigured = detection?.ios?.valid ?? context.firebase.iosConfigured; + + // Extract project ID from detection + let projectId = context.firebase.projectId; + if (detection?.android?.content && 'project_info' in detection.android.content) { + projectId = detection.android.content.project_info?.project_id; + } else if (detection?.ios?.content && 'PROJECT_ID' in detection.ios.content) { + projectId = detection.ios.content.PROJECT_ID; + } + + // Update Firebase status based on result + const updatedFirebase: FirebaseStatus = { + ...context.firebase, + configured: result.completed && androidConfigured && iosConfigured, + androidConfigured, + iosConfigured, + projectId, + }; + + const updatedContext: PreparationContext = { + ...context, + firebase: updatedFirebase, + missing: context.missing.filter((m) => !m.includes('Firebase')), + ready: + updatedFirebase.configured && + (!context.ios.needed || + (context.ios.entitlementsConfigured && context.ios.nseConfigured)), + }; + + setContext(updatedContext); + + // Save setup status + await saveSetupStatus(projectPath, updatedFirebase, context.ios); + + // Move to next phase + setPhase(getPostFirebasePhase(updatedContext)); + }, + [context, projectPath], + ); + + const handleIosAutoContinue = useCallback(() => { + setPhase('ready'); + }, []); + + const handleContinue = useCallback(() => { + if (!context) return; + + if (phase === 'firebase_check') { + if (!context.firebase.configured && context.firebase.needed) { + setPhase('firebase_setup'); + } else if (context.ios.needed && !context.ios.entitlementsConfigured) { + setPhase('ios_setup'); + } else { + setPhase('ready'); + } + } else if (phase === 'ios_check') { + if (!context.ios.entitlementsConfigured && context.ios.needed) { + setPhase('ios_setup'); + } else { + setPhase('ready'); + } + } else if (phase === 'ready') { + onComplete(context); + } + }, [context, phase, onComplete]); + + const handleCancel = useCallback(() => { + setPhase('cancelled'); + onCancel(); + }, [onCancel]); + + // Render based on phase + switch (phase) { + case 'checking': + return ; + + case 'config_missing': + return ; + + case 'firebase_check': + if (!context) return ; + return ; + + case 'firebase_setup': + if (!context) return ; + return ( + + ); + + case 'ios_check': + if (!context) return ; + return ; + + case 'ios_setup': + if (!context) return ; + return ; + + case 'ready': + if (!context) return ; + return ( + onComplete(context)} + onCancel={handleCancel} + /> + ); + + case 'cancelled': + return ( + + Installation cancelled. + + ); + + default: + return ; + } +} diff --git a/src/ui/components/ProjectSelector.tsx b/src/ui/components/ProjectSelector.tsx index 1fa2a1b..da39f08 100644 --- a/src/ui/components/ProjectSelector.tsx +++ b/src/ui/components/ProjectSelector.tsx @@ -3,16 +3,18 @@ import type React from 'react'; import { useEffect, useMemo, useState } from 'react'; import type { Organization, Project } from '@/lib/api'; -interface OrgWithProjects { +export interface OrgWithProjects { org: Organization; projects: Project[]; } -interface FlattenedProject { +export interface FlattenedProject { project: Project; org: Organization; } +const PROJECT_SORTER: Intl.Collator = new Intl.Collator(undefined, { sensitivity: 'base' }); + export interface ProjectSelectorProps { organizations: OrgWithProjects[]; onSelect: (project: Project, org: Organization) => void; @@ -35,6 +37,50 @@ function flattenProjects(organizations: OrgWithProjects[]): FlattenedProject[] { return flattened; } +function compareProjectsAsc(a: FlattenedProject, b: FlattenedProject): number { + const byProjectName = PROJECT_SORTER.compare(a.project.name, b.project.name); + if (byProjectName !== 0) return byProjectName; + + const byOrgName = PROJECT_SORTER.compare(a.org.name, b.org.name); + if (byOrgName !== 0) return byOrgName; + + return PROJECT_SORTER.compare(a.project.id, b.project.id); +} + +export function getSortedProjects(organizations: OrgWithProjects[]): FlattenedProject[] { + return flattenProjects(organizations).sort(compareProjectsAsc); +} + +export function filterProjectsByQuery( + projects: FlattenedProject[], + query: string, +): FlattenedProject[] { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return projects; + + return projects.filter((item) => item.project.name.toLowerCase().includes(normalizedQuery)); +} + +function applySearchInput( + input: string, + key: { backspace?: boolean; delete?: boolean; space?: boolean }, + setSearchQuery: React.Dispatch>, +): boolean { + if (key.backspace || key.delete) { + setSearchQuery((prev) => prev.slice(0, -1)); + return true; + } + if (key.space) { + setSearchQuery((prev) => `${prev} `); + return true; + } + if (input && !input.startsWith('\u001b')) { + setSearchQuery((prev) => `${prev}${input}`); + return true; + } + return false; +} + export const ProjectSelector: React.FC = ({ organizations, onSelect, @@ -42,23 +88,19 @@ export const ProjectSelector: React.FC = ({ workspacePath, showSkip = true, }) => { - const flattenedProjects = useMemo(() => flattenProjects(organizations), [organizations]); + const sortedProjects = useMemo(() => getSortedProjects(organizations), [organizations]); + const [searchQuery, setSearchQuery] = useState(''); + const filteredProjects = useMemo( + () => filterProjectsByQuery(sortedProjects, searchQuery), + [sortedProjects, searchQuery], + ); const [selectedIndex, setSelectedIndex] = useState(0); // Calculate visible window for scrolling (show max 10 items) const maxVisible = 10; - const totalItems = flattenedProjects.length; + const totalItems = filteredProjects.length; const halfWindow = Math.floor(maxVisible / 2); - // Clamp selectedIndex when list changes - useEffect(() => { - setSelectedIndex((prev) => { - if (totalItems <= 0) return 0; - if (prev >= totalItems) return totalItems - 1; - return prev; - }); - }, [totalItems]); - let startIndex = 0; let endIndex = maxVisible; @@ -77,32 +119,54 @@ export const ProjectSelector: React.FC = ({ endIndex = totalItems; } - const visibleProjects = flattenedProjects.slice(startIndex, endIndex); + const visibleProjects = filteredProjects.slice(startIndex, endIndex); - useInput((_input, key) => { - // Handle empty list - only Enter/Esc work + useInput((input, key) => { + // Handle empty state differently for no projects vs no search matches. if (totalItems === 0) { - if (key.return || (showSkip && key.escape)) { + if (sortedProjects.length === 0 && (key.return || (showSkip && key.escape))) { onSkip(); + return; } + if (showSkip && key.escape) { + onSkip(); + return; + } + applySearchInput(input, key, setSearchQuery); return; } if (key.upArrow) { setSelectedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1)); - } else if (key.downArrow) { + return; + } + if (key.downArrow) { setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0)); - } else if (key.return) { - const selected = flattenedProjects[selectedIndex]; + return; + } + if (key.return) { + const selected = filteredProjects[selectedIndex]; if (selected) { onSelect(selected.project, selected.org); } - } else if (showSkip && key.escape) { + return; + } + if (showSkip && key.escape) { onSkip(); + return; } + applySearchInput(input, key, setSearchQuery); }); - if (flattenedProjects.length === 0) { + useEffect(() => { + setSelectedIndex((prev) => { + if (totalItems <= 0) return 0; + if (prev >= totalItems) return 0; + return prev; + }); + }, [totalItems]); + + if (sortedProjects.length === 0) { return ( No projects available to link. @@ -115,6 +179,29 @@ export const ProjectSelector: React.FC = ({ ); } + if (filteredProjects.length === 0) { + return ( + + + Select a project to link to this workspace: + + + {workspacePath} + + + Search: + {searchQuery || '(type to search)'} + + No matching projects found. + + + Type to search · Backspace to clear{showSkip ? ' · Esc to skip' : ''} + + + + ); + } + return ( @@ -123,6 +210,10 @@ export const ProjectSelector: React.FC = ({ {workspacePath} + + Search: + {searchQuery || '(type to search)'} + {startIndex > 0 && ( @@ -151,7 +242,9 @@ export const ProjectSelector: React.FC = ({ )} - ↑↓ to navigate · Enter to select{showSkip ? ' · Esc to skip' : ''} + + Type to search · ↑↓ to navigate · Enter to select{showSkip ? ' · Esc to skip' : ''} + ); diff --git a/src/ui/components/__tests__/firebase-wizard-transitions.test.ts b/src/ui/components/__tests__/firebase-wizard-transitions.test.ts new file mode 100644 index 0000000..92d9f23 --- /dev/null +++ b/src/ui/components/__tests__/firebase-wizard-transitions.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, test } from 'bun:test'; +import { + type ExtendedWizardPhase, + PHASE_TRANSITIONS, + transition, +} from '../firebase-wizard-transitions'; + +describe('PHASE_TRANSITIONS', () => { + test('all target phases are valid ExtendedWizardPhase values', () => { + // Collect all phases that appear as targets + const targetPhases = new Set(); + for (const events of Object.values(PHASE_TRANSITIONS)) { + if (events) { + for (const target of Object.values(events)) { + targetPhases.add(target); + } + } + } + + // Collect all phases that appear as sources (keys in PHASE_TRANSITIONS) + // plus 'complete' which is a terminal state + const sourcePhases = new Set(Object.keys(PHASE_TRANSITIONS)); + sourcePhases.add('complete'); + + // Every target phase must be either a source phase or 'complete' + for (const target of targetPhases) { + expect(sourcePhases.has(target)).toBe(true); + } + }); + + test('error phase has retry transition', () => { + expect(PHASE_TRANSITIONS.error?.retry).toBe('detecting'); + }); + + test('complete phase has no transitions', () => { + expect(PHASE_TRANSITIONS.complete).toBeUndefined(); + }); +}); + +describe('transition()', () => { + test('returns correct target phase for valid transitions', () => { + expect(transition('detecting', 'success')).toBe('status'); + expect(transition('detecting', 'error')).toBe('error'); + expect(transition('status', 'continue')).toBe('menu'); + expect(transition('menu', 'done')).toBe('complete'); + }); + + test('throws on invalid event', () => { + expect(() => transition('detecting', 'nonexistent')).toThrow( + 'Invalid phase transition: detecting + "nonexistent"', + ); + }); + + test('throws on phase with no transitions', () => { + expect(() => transition('complete' as ExtendedWizardPhase, 'anything')).toThrow( + 'Invalid phase transition: complete + "anything"', + ); + }); + + test('error message includes valid events', () => { + try { + transition('detecting', 'bad'); + expect(true).toBe(false); // Should not reach + } catch (err) { + expect((err as Error).message).toContain('Valid events: success, error'); + } + }); +}); + +describe('flow: download → sender config → SA → complete', () => { + test('happy path: download → check sender config (not registered) → SA setup → register → complete', () => { + // Download completes + expect(transition('downloading', 'success')).toBe('checking_sender_config'); + // Sender config not registered + expect(transition('checking_sender_config', 'not_registered')).toBe('service_account_menu'); + // User pastes SA JSON + expect(transition('service_account_menu', 'paste_json')).toBe('paste_service_account'); + // Save SA JSON + expect(transition('paste_service_account', 'save')).toBe('saving_service_account'); + // Register sender config (has clixProjectId) + expect(transition('saving_service_account', 'register')).toBe('registering_sender_config'); + // Registration completes + expect(transition('registering_sender_config', 'complete')).toBe('complete'); + }); + + test('sender config already registered → complete', () => { + expect(transition('downloading', 'success')).toBe('checking_sender_config'); + expect(transition('checking_sender_config', 'registered')).toBe('sender_config_registered'); + expect(transition('sender_config_registered', 'continue')).toBe('complete'); + }); + + test('no clixProjectId → skip sender config check → SA setup → complete without register', () => { + expect(transition('downloading', 'success')).toBe('checking_sender_config'); + // Skip because no clixProjectId + expect(transition('checking_sender_config', 'skip')).toBe('service_account_menu'); + expect(transition('service_account_menu', 'paste_json')).toBe('paste_service_account'); + expect(transition('paste_service_account', 'save')).toBe('saving_service_account'); + // No clixProjectId → complete directly + expect(transition('saving_service_account', 'complete')).toBe('complete'); + }); + + test('sender config API error → still goes to SA menu', () => { + expect(transition('checking_sender_config', 'error')).toBe('service_account_menu'); + }); +}); + +describe('flow: initial detection → menu → done', () => { + test('detect → status → menu → done', () => { + expect(transition('detecting', 'success')).toBe('status'); + expect(transition('status', 'continue')).toBe('menu'); + expect(transition('menu', 'done')).toBe('complete'); + }); + + test('detect → status → menu → skip', () => { + expect(transition('detecting', 'success')).toBe('status'); + expect(transition('status', 'continue')).toBe('menu'); + expect(transition('menu', 'skip')).toBe('complete'); + }); + + test('detect error → retry', () => { + expect(transition('detecting', 'error')).toBe('error'); + expect(transition('error', 'retry')).toBe('detecting'); + }); +}); + +describe('flow: download authentication', () => { + test('auth → select project → select apps → download', () => { + expect(transition('authenticating', 'select_project')).toBe('select_project'); + expect(transition('select_project', 'select_android_app')).toBe('select_android_app'); + expect(transition('select_android_app', 'select_ios_app')).toBe('select_ios_app'); + // select_ios_app triggers download via ref (not a phase transition) + }); + + test('auth → no projects → open console → menu', () => { + expect(transition('authenticating', 'no_projects')).toBe('no_projects'); + expect(transition('no_projects', 'open_console')).toBe('menu'); + }); + + test('auth → no projects → GCP → add firebase', () => { + expect(transition('authenticating', 'no_projects')).toBe('no_projects'); + // Note: no_projects → select_gcp is indirect (goes through authenticating again) + }); + + test('auth error', () => { + expect(transition('authenticating', 'error')).toBe('error'); + }); +}); + +describe('flow: app creation', () => { + test('no apps → create android → creating → needs iOS → no apps found again', () => { + expect(transition('no_apps_found', 'create_android')).toBe('create_android_app'); + // create_android_app submission triggers handleCreateApp directly (not via transition) + // After creating: needs iOS → go back to no_apps_found + expect(transition('creating_app', 'no_apps_found')).toBe('no_apps_found'); + }); + + test('no apps → create ios', () => { + expect(transition('no_apps_found', 'create_ios')).toBe('create_ios_app'); + }); + + test('no apps → cancel → back to project selection', () => { + expect(transition('no_apps_found', 'cancel')).toBe('select_project'); + }); + + test('app creation error', () => { + expect(transition('creating_app', 'error')).toBe('error'); + }); +}); + +describe('flow: service account menu', () => { + test('open console → paste SA', () => { + expect(transition('service_account_menu', 'open_console')).toBe('paste_service_account'); + }); + + test('paste json → paste SA', () => { + expect(transition('service_account_menu', 'paste_json')).toBe('paste_service_account'); + }); + + test('skip SA → status', () => { + expect(transition('service_account_menu', 'skip')).toBe('status'); + }); + + test('cancel paste → back to SA menu', () => { + expect(transition('paste_service_account', 'cancel')).toBe('service_account_menu'); + }); + + test('save error', () => { + expect(transition('saving_service_account', 'error')).toBe('error'); + }); +}); + +describe('flow: menu → validate', () => { + test('validate → success → status', () => { + expect(transition('menu', 'validate')).toBe('validating'); + expect(transition('validating', 'success')).toBe('status'); + }); + + test('validate → error', () => { + expect(transition('menu', 'validate')).toBe('validating'); + expect(transition('validating', 'error')).toBe('error'); + }); +}); + +describe('flow: menu → redetect', () => { + test('redetect all → detecting', () => { + expect(transition('menu', 'redetect')).toBe('detecting'); + }); + + test('redetect platform → detecting', () => { + expect(transition('menu', 'redetect_platform')).toBe('detecting'); + }); +}); + +describe('flow: GCP project', () => { + test('select GCP → add firebase', () => { + expect(transition('select_gcp_project', 'adding_firebase')).toBe('adding_firebase'); + }); + + test('select GCP error', () => { + expect(transition('select_gcp_project', 'error')).toBe('error'); + }); + + test('add firebase error', () => { + expect(transition('adding_firebase', 'error')).toBe('error'); + }); +}); diff --git a/src/ui/components/firebase-wizard-transitions.ts b/src/ui/components/firebase-wizard-transitions.ts new file mode 100644 index 0000000..fa0d3e0 --- /dev/null +++ b/src/ui/components/firebase-wizard-transitions.ts @@ -0,0 +1,193 @@ +import type { WizardPhase } from '@/lib/services/firebase'; + +/** + * Extended wizard phase for download flow. + */ +export type ExtendedWizardPhase = + | WizardPhase + | 'authenticating' + | 'select_project' + | 'select_android_app' + | 'select_ios_app' + | 'downloading' + | 'no_apps_found' + | 'create_android_app' + | 'create_ios_app' + | 'creating_app' + | 'no_projects' + | 'select_gcp_project' + | 'adding_firebase' + | 'checking_sender_config' + | 'sender_config_registered' + | 'service_account_menu' + | 'paste_service_account' + | 'saving_service_account' + | 'registering_sender_config'; + +/** + * Phase transition event type. + * Each key represents a named event that triggers a transition. + */ +type TransitionEvent = string; + +/** + * Centralized phase transition map. + * + * Every valid phase transition is defined here. This makes the entire + * wizard flow visible in one place and prevents invalid transitions. + * + * Flow overview: + * detecting → status → menu → (various actions) + * menu/download → authenticating → select_project → select_*_app → downloading + * downloading → checking_sender_config → service_account_menu → paste → saving → complete + * + * Adding a new phase: + * 1. Add the phase to ExtendedWizardPhase type + * 2. Add transition rules here + * 3. Add the phase component and rendering case + */ +export const PHASE_TRANSITIONS: Partial< + Record> +> = { + // === Initial Detection === + detecting: { + success: 'status', + error: 'error', + }, + + // === Status & Menu === + status: { + continue: 'menu', + }, + menu: { + redetect: 'detecting', + redetect_platform: 'detecting', + validate: 'validating', + download: 'authenticating', + setup_service_account: 'service_account_menu', + done: 'complete', + skip: 'complete', + }, + validating: { + success: 'status', + error: 'error', + }, + + // === Download / Auth Flow === + authenticating: { + no_projects: 'no_projects', + select_project: 'select_project', + select_gcp_project: 'select_gcp_project', + error: 'error', + }, + select_project: { + no_apps_found: 'no_apps_found', + select_android_app: 'select_android_app', + select_ios_app: 'select_ios_app', + error: 'error', + }, + select_android_app: { + select_ios_app: 'select_ios_app', + // note: also triggers download via ref (not a phase transition) + }, + select_ios_app: { + // note: triggers download via ref (not a phase transition) + }, + downloading: { + success: 'checking_sender_config', + error: 'error', + }, + + // === No Apps / App Creation === + no_apps_found: { + create_android: 'create_android_app', + create_ios: 'create_ios_app', + cancel: 'select_project', + }, + create_android_app: { + submit: 'creating_app', + cancel: 'select_project', + }, + create_ios_app: { + submit: 'creating_app', + cancel: 'select_project', + }, + creating_app: { + no_apps_found: 'no_apps_found', + select_ios_app: 'select_ios_app', + error: 'error', + // note: also triggers download via ref (not a phase transition) + }, + + // === No Projects / GCP === + no_projects: { + open_console: 'menu', + select_gcp: 'authenticating', + }, + select_gcp_project: { + adding_firebase: 'adding_firebase', + error: 'error', + }, + adding_firebase: { + error: 'error', + // note: on success, triggers project selection via ref + }, + + // === Sender Config Check === + checking_sender_config: { + registered: 'sender_config_registered', + not_registered: 'service_account_menu', + skip: 'service_account_menu', + error: 'service_account_menu', + }, + sender_config_registered: { + continue: 'complete', + }, + + // === Service Account Setup === + service_account_menu: { + open_console: 'paste_service_account', + paste_json: 'paste_service_account', + skip: 'status', + error: 'error', + }, + paste_service_account: { + save: 'saving_service_account', + cancel: 'service_account_menu', + }, + saving_service_account: { + register: 'registering_sender_config', + complete: 'complete', + error: 'error', + }, + registering_sender_config: { + complete: 'complete', + }, + + // === Terminal States === + error: { + retry: 'detecting', + // note: retry with re-auth goes through handleDownload (not a simple transition) + }, + // 'complete' has no outgoing transitions +}; + +/** + * Perform a validated phase transition. + * + * @param from - Current phase + * @param event - Named event triggering the transition + * @returns Target phase + * @throws Error if the transition is not defined in PHASE_TRANSITIONS + */ +export function transition(from: ExtendedWizardPhase, event: TransitionEvent): ExtendedWizardPhase { + const transitions = PHASE_TRANSITIONS[from]; + const target = transitions?.[event]; + if (!target) { + throw new Error( + `Invalid phase transition: ${from} + "${event}". ` + + `Valid events: ${transitions ? Object.keys(transitions).join(', ') : 'none'}`, + ); + } + return target; +}