diff --git a/README.md b/README.md index 057940b..d415c42 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,7 @@ Use these commands within the interactive chat (`clix`): | `/new` | /clear | Start a new session | | `/compact` | /c | Compress conversation history | | `/agent` | /a | List or switch agents | +| `/firebase` | | Check and configure Firebase credentials | | `/transfer` | /t | Transfer to agent CLI | | `/resume` | | Resume a previous session | | `/install-mcp` | /mcp | Install Clix MCP Server | diff --git a/bun.lock b/bun.lock index d1b9e2a..0b590f9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,6 @@ { "lockfileVersion": 1, - "configVersion": 0, + "configVersion": 1, "workspaces": { "": { "name": "@clix-so/clix-cli", @@ -8,12 +8,14 @@ "@clix-so/clix-agent-skills": "^0.2.3", "@expo/apple-utils": "^2.1.14", "@expo/plist": "^0.4.8", + "google-auth-library": "^10.5.0", "ink": "^6.6.0", "ink-select-input": "^6.2.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", "meow": "^14.0.0", "picocolors": "^1.1.1", + "plist": "^3.1.0", "react": "^19.2.3", "xdg-app-paths": "^8.3.0", "zod": "^4.3.5", @@ -22,6 +24,7 @@ "@biomejs/biome": "^2.0.0", "@types/bun": "^1.3.5", "@types/node": "^22.10.5", + "@types/plist": "^3.0.5", "@types/react": "^19.2.7", "husky": "^9.0.0", "lint-staged": "^16.2.7", @@ -32,25 +35,25 @@ "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ=="], - "@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="], + "@biomejs/biome": ["@biomejs/biome@2.3.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.12", "@biomejs/cli-darwin-x64": "2.3.12", "@biomejs/cli-linux-arm64": "2.3.12", "@biomejs/cli-linux-arm64-musl": "2.3.12", "@biomejs/cli-linux-x64": "2.3.12", "@biomejs/cli-linux-x64-musl": "2.3.12", "@biomejs/cli-win32-arm64": "2.3.12", "@biomejs/cli-win32-x64": "2.3.12" }, "bin": { "biome": "bin/biome" } }, "sha512-AR7h4aSlAvXj7TAajW/V12BOw2EiS0AqZWV5dGozf4nlLoUF/ifvD0+YgKSskT0ylA6dY1A8AwgP8kZ6yaCQnA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cO6fn+KiMBemva6EARDLQBxeyvLzgidaFRJi8G7OeRqz54kWK0E+uSjgFaiHlc3DZYoa0+1UFE8mDxozpc9ieg=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-/fiF/qmudKwSdvmSrSe/gOTkW77mHHkH8Iy7YC2rmpLuk27kbaUOPa7kPiH5l+3lJzTUfU/t6x1OuIq/7SGtxg=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-nbOsuQROa3DLla5vvsTZg+T5WVPGi9/vYxETm9BOuLHBJN3oWQIg3MIkE2OfL18df1ZtNkqXkH6Yg9mdTPem7A=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-aqkeSf7IH+wkzFpKeDVPSXy9uDjxtLpYA6yzkYsY+tVjwFFirSuajHDI3ul8en90XNs1NA0n8kgBrjwRi5JeyA=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.12", "", { "os": "linux", "cpu": "x64" }, "sha512-CQtqrJ+qEEI8tgRSTjjzk6wJAwfH3wQlkIGsM5dlecfRZaoT+XCms/mf7G4kWNexrke6mnkRzNy6w8ebV177ow=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.12", "", { "os": "linux", "cpu": "x64" }, "sha512-kVGWtupRRsOjvw47YFkk5mLiAdpCPMWBo1jOwAzh+juDpUb2sWarIp+iq+CPL1Wt0LLZnYtP7hH5kD6fskcxmg=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-Re4I7UnOoyE4kHMqpgtG6UvSBGBbbtvsOvBROgCCoH7EgANN6plSQhvo2W7OCITvTp7gD6oZOyZy72lUdXjqZg=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.12", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="], - "@clix-so/clix-agent-skills": ["@clix-so/clix-agent-skills@0.2.3", "", { "dependencies": { "@iarna/toml": "^2.2.5", "chalk": "^4.1.2", "commander": "^14.0.2", "fs-extra": "^11.3.3", "inquirer": "^8.2.5", "ora": "^5.4.1" }, "bin": { "clix-agent-skills": "dist/bin/cli.js" } }, "sha512-8klwTULuuVCBkN9Q8wMvktq101I2RvMft35zTUe16LRtqfjDn1o00T6OMeEIQevQo/lCTej6qa0RLR55nmlR8Q=="], + "@clix-so/clix-agent-skills": ["@clix-so/clix-agent-skills@0.2.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "chalk": "^4.1.2", "commander": "^14.0.2", "fs-extra": "^11.3.3", "inquirer": "^8.2.5", "ora": "^5.4.1" }, "bin": { "clix-agent-skills": "dist/bin/cli.js" } }, "sha512-A4+X3r6XyF366qVvdSPGRaYodH4HTOcTL1rHx2kqlNmREwNn9RiP3Is3Rg1kyoJEkBguIduZRQ9TxSurSpN2Og=="], "@expo/apple-utils": ["@expo/apple-utils@2.1.14", "", { "bin": { "apple-utils": "bin.js" } }, "sha512-6k9KAyk/itPvNgkAI3LactHJyD/S8Jq2V3iEZRm2LMRDx/Gpu4KvqX1dQBon2G7qFM/AEkLO1dToF/dirB7K2Q=="], @@ -60,14 +63,22 @@ "@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=="], - "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], - "@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + + "@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], + + "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], + + "@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ansi-escapes": ["ansi-escapes@7.2.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -76,15 +87,23 @@ "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -114,37 +133,69 @@ "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], - "es-toolkit": ["es-toolkit@1.43.0", "", {}, "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA=="], + "es-toolkit": ["es-toolkit@1.44.0", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="], "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], - "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "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=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], - "iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -172,18 +223,30 @@ "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "lint-staged": ["lint-staged@16.2.7", "", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="], "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], - "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "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=="], @@ -192,24 +255,42 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mute-stream": ["mute-stream@0.0.8", "", {}, "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="], "nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], "os-paths": ["os-paths@7.4.0", "", { "optionalDependencies": { "fsevents": "*" } }, "sha512-Ux1J4NUqC6tZayBqLN1kUlDAEvLiQlli/53sSddU4IN+h+3xxnv2HmRSMpVSvr1hvJzotfMs3ERvETGK+f4OwA=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], + "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], @@ -220,6 +301,8 @@ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + "run-async": ["run-async@2.4.1", "", {}, "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="], "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], @@ -230,6 +313,10 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], @@ -240,10 +327,14 @@ "string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], @@ -266,11 +357,17 @@ "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], - "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], "xdg-app-paths": ["xdg-app-paths@8.3.0", "", { "dependencies": { "xdg-portable": "^10.6.0" }, "optionalDependencies": { "fsevents": "*" } }, "sha512-mgxlWVZw0TNWHoGmXq+NC3uhCIc55dDpAlDkMQUaIAcQzysb0kxctwv//fvuW61/nAAeUBJMQ8mnZjMmuYwOcQ=="], @@ -282,12 +379,20 @@ "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "figures/is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "ink/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "ink-text-input/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -310,12 +415,22 @@ "string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "string-width-cjs/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "inquirer/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "inquirer/cli-cursor/restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], @@ -338,6 +453,8 @@ "widest-line/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], diff --git a/llms.txt b/llms.txt index b0e543b..0bcc9d3 100644 --- a/llms.txt +++ b/llms.txt @@ -9,7 +9,7 @@ Clix CLI is an interactive command-line tool that provides a chat interface with **Core Features:** - Interactive chat interface as the primary interaction mode - Support for 6 AI agents: Claude, Codex, Gemini, OpenCode, Cursor, and GitHub Copilot -- 18 slash commands for quick actions +- 19 slash commands for quick actions - Skills system with 5 interactive skills + 4 autonomous commands - Interactive debug assistant for problem diagnosis - Session transfer to native agent CLIs @@ -70,14 +70,14 @@ clix **Features:** - Natural language conversation with AI - Real-time streaming responses -- 13 slash commands (type `/` to see menu) +- 19 slash commands (type `/` to see menu) - Context usage tracking (200K token window for Claude Sonnet) - History navigation with ↑/↓ arrow keys - Press Escape to cancel streaming requests - Automatic history compaction at 90% context threshold **Available within chat:** -- All 17 slash commands (3 autonomous + 5 skills + 9 system) +- All 19 slash commands (4 autonomous + 5 skills + 10 system) - Natural language queries - File exploration and code analysis by agent - Real-time tool execution visibility @@ -232,7 +232,7 @@ The interactive chat (`clix` command) is the primary way to interact with Clix C ### Slash Commands -Type `/` in the chat to see the autocomplete menu. All 17 slash commands are organized into three categories: +Type `/` in the chat to see the autocomplete menu. All 19 slash commands are organized into three categories: #### Autonomous Commands (4 commands) @@ -253,7 +253,7 @@ These execute pre-built workflows from the `@clix-so/clix-agent-skills` package - `/personalization` - Personalization template creation and debugging - `/api-triggered-campaigns` - API-triggered campaign setup -#### System Commands (9 commands) +#### System Commands (10 commands) These are built-in commands for chat management and tools: @@ -261,6 +261,7 @@ These are built-in commands for chat management and tools: - `/new` (alias: `/clear`) - Start a new session (clear history) - `/compact` (alias: `/c`) - Compress conversation history - `/agent` (alias: `/a`) - List available agents or switch to specific agent +- `/firebase` - Check and configure Firebase credentials - `/transfer` (alias: `/t`) - Transfer session to agent CLI - `/resume` - Resume a previous session - `/install-mcp` (alias: `/mcp`) - Install Clix MCP Server for current agent @@ -535,6 +536,7 @@ System Commands: /new - Start a new session /compact - Compress conversation history /agent - List or switch agents + /firebase - Check and configure Firebase credentials /transfer - Transfer to agent CLI /resume - Resume a previous session /install-mcp - Install Clix MCP Server @@ -581,6 +583,39 @@ Compressing conversation history... Reduced size by 68% ``` +#### `/firebase` - Firebase Configuration + +**Category:** System Command + +**What it does:** Opens an interactive wizard to check and configure Firebase credentials (google-services.json for Android, GoogleService-Info.plist for iOS). + +**Features:** +- Auto-detects Firebase credential files in project +- Validates JSON/plist structure against Firebase schema +- Reports issues (missing files, invalid format, wrong location) +- Provides recommendations with help URLs +- Supports React Native, Flutter, iOS, and Android projects + +**Detection locations:** +- Android: `app/google-services.json`, `android/app/google-services.json` +- iOS: `ios/GoogleService-Info.plist`, `ios/Runner/GoogleService-Info.plist` + +**Example:** +``` +> /firebase +Firebase Configuration (react-native) + +✓ Android (google-services.json): valid + Location: android/app/google-services.json + Project ID: my-project-12345 + +✗ iOS (GoogleService-Info.plist): not found + → Expected: ios/GoogleService-Info.plist + → Download from Firebase Console + +Press Enter to configure, Esc to skip +``` + #### `/agent` - Agent Management **Category:** System Command @@ -1200,7 +1235,7 @@ When helping users with Clix CLI, keep these points in mind: 1. **Primary command is `clix`** - This launches interactive chat, not just a welcome screen 2. **Interactive > Commands** - The tool is primarily interactive, not command-based 3. **6 supported agents** - Gemini, Copilot, OpenCode, Cursor, Claude, Codex (recommend starting with Gemini or Copilot for free tiers) -4. **18 slash commands** - 4 autonomous commands + 5 interactive skills + 9 system commands +4. **19 slash commands** - 4 autonomous commands + 5 interactive skills + 10 system commands 5. **Autonomous vs Interactive** - Autonomous commands (`/install`, `/doctor`, `/debug`, `/ios-setup`) can run from CLI, Interactive skills (`/integration`, `/event-tracking`, etc.) require chat mode 6. **Skills from package** - Interactive skills from @clix-so/clix-agent-skills package, Autonomous commands are local 7. **/install vs /integration** - `/install` makes changes autonomously, `/integration` provides guided steps diff --git a/package.json b/package.json index 475faa6..cdc4905 100644 --- a/package.json +++ b/package.json @@ -49,12 +49,14 @@ "@clix-so/clix-agent-skills": "^0.2.3", "@expo/apple-utils": "^2.1.14", "@expo/plist": "^0.4.8", + "google-auth-library": "^10.5.0", "ink": "^6.6.0", "ink-select-input": "^6.2.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", "meow": "^14.0.0", "picocolors": "^1.1.1", + "plist": "^3.1.0", "react": "^19.2.3", "xdg-app-paths": "^8.3.0", "zod": "^4.3.5" @@ -63,6 +65,7 @@ "@biomejs/biome": "^2.0.0", "@types/bun": "^1.3.5", "@types/node": "^22.10.5", + "@types/plist": "^3.0.5", "@types/react": "^19.2.7", "husky": "^9.0.0", "lint-staged": "^16.2.7", diff --git a/scripts/embed-skills.ts b/scripts/embed-skills.ts index 6fea806..d0a918e 100644 --- a/scripts/embed-skills.ts +++ b/scripts/embed-skills.ts @@ -13,6 +13,12 @@ import { dirname, join } from 'node:path'; const OUTPUT_FILE = './src/lib/embedded-skills.ts'; +/** + * Skills to exclude from embedding. + * These are internal/developer tools not meant for end users. + */ +const EXCLUDED_SKILLS = new Set(['skill-creator']); + /** * Skill metadata parsed from SKILL.md frontmatter. */ @@ -60,6 +66,10 @@ function discoverSkillFolders(packagePath: string): string[] { } return readdirSync(skillsDir).filter((entry) => { + // Skip excluded skills + if (EXCLUDED_SKILLS.has(entry)) { + return false; + } const entryPath = join(skillsDir, entry); const skillMdPath = join(entryPath, 'SKILL.md'); return statSync(entryPath).isDirectory() && existsSync(skillMdPath); diff --git a/src/lib/commands/firebase.tsx b/src/lib/commands/firebase.tsx new file mode 100644 index 0000000..af2d1f5 --- /dev/null +++ b/src/lib/commands/firebase.tsx @@ -0,0 +1,47 @@ +/** + * 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/registry.ts b/src/lib/commands/registry.ts index 5f3ef68..fb6c5fb 100644 --- a/src/lib/commands/registry.ts +++ b/src/lib/commands/registry.ts @@ -9,6 +9,7 @@ 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 { newCommand } from './new'; @@ -29,6 +30,7 @@ const BUILT_IN_COMMANDS: Command[] = [ compactCommand, agentCommand, debugCommand, + firebaseCommand, transferCommand, resumeCommand, installMcpCommand, diff --git a/src/lib/embedded-skills.ts b/src/lib/embedded-skills.ts index e1128cd..c76dbd3 100644 --- a/src/lib/embedded-skills.ts +++ b/src/lib/embedded-skills.ts @@ -34,8 +34,7 @@ export const EMBEDDED_SKILLS: Record = { name: clix-personalization display-name: Personalization short-description: Personalization templates -description: - Helps developers author and debug Clix personalization templates +description: Helps developers author and debug Clix personalization templates (Liquid-style) for message content, deep links/URLs, and audience targeting. Use when the user mentions personalization variables, Liquid, templates, conditional logic, loops, filters, deep links, message logs, or when the user @@ -166,8 +165,7 @@ variables exist — you still need a payload + console verification. name: clix-integration display-name: SDK Integration short-description: SDK integration guide -description: - Integrates Clix Mobile SDK into iOS, Android, Flutter, and React Native +description: Integrates Clix Mobile SDK into iOS, Android, Flutter, and React Native projects. Provides step-by-step guidance for installation, initialization, and verification. Use when the user asks to install, setup, integrate Clix or when the user types \`clix-integration\` / "clix integration". @@ -650,8 +648,7 @@ customization is absolutely necessary. name: clix-api-triggered-campaigns display-name: API-Triggered Campaigns short-description: API-triggered campaign setup -description: - Helps developers configure API-triggered campaigns in the Clix console and +description: Helps developers configure API-triggered campaigns in the Clix console and trigger them from backend services with safe auth, payload schemas, dynamic audience filters (trigger.*), and personalization best practices. Use when the user mentions transactional notifications, backend-triggered sends, @@ -840,8 +837,7 @@ See \`references/debugging.md\`. name: clix-event-tracking display-name: Event Tracking short-description: Event tracking setup -description: - Implements Clix event tracking (Clix.trackEvent) with consistent naming, safe +description: Implements Clix event tracking (Clix.trackEvent) with consistent naming, safe property schemas, and campaign-ready validation. Use when adding, reviewing, or debugging event tracking; when configuring event-triggered campaigns; or when the user mentions events, tracking, funnels, or properties — or when the @@ -939,7 +935,8 @@ The skill directory is typically: - \`.cursor/skills/event-tracking/\` (Cursor) - \`.claude/skills/event-tracking/\` (Claude Code) -- \`.vscode/skills/event-tracking/\` (VS Code/Amp) +- \`.vscode/skills/event-tracking/\` (VS Code) +- \`.agents/skills/event-tracking/\` (Amp) - Or check where this skill was installed If validation fails: fix the plan first, then implement. @@ -971,8 +968,7 @@ For troubleshooting steps, see \`references/debugging.md\`. name: clix-user-management display-name: User Management short-description: User management setup -description: - Implements Clix user identification and user properties (setUserId, +description: Implements Clix user identification and user properties (setUserId, removeUserId, setUserProperty/setUserProperties, removeUserProperty/removeUserProperties) with safe schemas, logout best practices, and campaign-ready personalization/audience usage. Use when the @@ -1182,11 +1178,12 @@ First, detect the dependency manager being used: **Android:** - Modify MainActivity or Application class - Update AndroidManifest.xml with permissions -- Note: Firebase setup may require manual steps +- Verify Firebase configuration (see step 6) **Flutter:** - Modify main.dart to initialize SDK - Update platform-specific files as needed +- Verify Firebase configuration (see step 6) ### 4. Use Placeholders for Secrets @@ -1199,6 +1196,32 @@ Execute necessary commands: - \`cd ios && pod install\` for iOS dependencies - \`flutter pub get\` for Flutter +### 6. Verify Firebase Configuration + +For push notifications to work, Firebase must be properly configured: + +**Android (google-services.json):** +- Expected locations: + - Standard Android: \`app/google-services.json\` + - React Native/Flutter: \`android/app/google-services.json\` +- Download from Firebase Console > Project Settings > Your apps > Android app +- Verify package name matches your AndroidManifest.xml + +**iOS (GoogleService-Info.plist):** +- Expected locations: + - React Native: \`ios/GoogleService-Info.plist\` + - Flutter: \`ios/Runner/GoogleService-Info.plist\` + - Native iOS: \`/GoogleService-Info.plist\` +- Download from Firebase Console > Project Settings > Your apps > iOS app +- Verify bundle ID matches your Xcode project + +**Validation:** +- Check if files exist in correct locations +- Verify JSON/plist structure is valid +- Confirm project IDs match between platforms (for cross-platform apps) + +Use \`/firebase\` command in interactive mode to check and configure Firebase credentials. + ## Automation Rules ✅ **DO:** @@ -1260,7 +1283,11 @@ Analyze the project and output a diagnostic JSON report: "apiKeyConfigured": true | false, "pushPermissions": true | false, "entitlements": true | false, - "firebaseConfig": true | false + "firebaseConfig": true | false, + "firebaseAndroid": true | false, + "firebaseIos": true | false, + "firebasePackageMatch": true | false, + "firebaseBundleMatch": true | false }, "nextSteps": ["Step 1", "Step 2"] } @@ -1288,6 +1315,30 @@ Analyze the project and output a diagnostic JSON report: - Android: Check AndroidManifest.xml for FCM service - Check for google-services.json (Android) or GoogleService-Info.plist (iOS) +### Firebase Configuration Check (Detailed) + +**Android (google-services.json):** +- Check file presence in expected locations: + - Standard Android: \`app/google-services.json\` + - React Native/Flutter: \`android/app/google-services.json\` +- Validate JSON structure against Firebase schema +- Verify \`project_info.project_id\` exists +- Verify \`client[].client_info.android_client_info.package_name\` matches AndroidManifest.xml +- Report if file found in wrong location (e.g., project root) + +**iOS (GoogleService-Info.plist):** +- Check file presence in expected locations: + - React Native: \`ios/GoogleService-Info.plist\` + - Flutter: \`ios/Runner/GoogleService-Info.plist\` + - Native iOS: \`/GoogleService-Info.plist\` +- Validate plist structure (API_KEY, GCM_SENDER_ID, GOOGLE_APP_ID, PROJECT_ID, BUNDLE_ID) +- Verify BUNDLE_ID matches Xcode project bundle identifier +- Report if file found in wrong location + +**Cross-Platform Validation:** +- For React Native/Flutter projects, verify both Android and iOS configs exist +- Verify PROJECT_ID matches between platforms + ### Common Issues to Detect - Missing SDK dependency - Missing or invalid API key @@ -1295,8 +1346,15 @@ Analyze the project and output a diagnostic JSON report: - Missing capabilities/entitlements - Outdated SDK version - Incomplete Firebase/APNs setup +- Firebase config file missing +- Firebase config file in wrong location +- Firebase config file invalid (malformed JSON/plist) +- Firebase package name / bundle ID mismatch +- Firebase project ID mismatch between platforms Output the JSON diagnostic, then provide a brief summary with actionable recommendations. + +Use \`/firebase\` command to interactively check and configure Firebase credentials. `, 'local-ios-setup': `# iOS Capabilities Configuration diff --git a/src/lib/errors/types.ts b/src/lib/errors/types.ts index eb6996e..e788ec0 100644 --- a/src/lib/errors/types.ts +++ b/src/lib/errors/types.ts @@ -34,6 +34,13 @@ export const ERROR_CODES = { // General errors UNKNOWN_ERROR: 'UNKNOWN_ERROR', OPERATION_CANCELLED: 'OPERATION_CANCELLED', + + // Firebase errors + FIREBASE_CONFIG_MISSING: 'FIREBASE_CONFIG_MISSING', + FIREBASE_CONFIG_INVALID: 'FIREBASE_CONFIG_INVALID', + FIREBASE_PACKAGE_MISMATCH: 'FIREBASE_PACKAGE_MISMATCH', + FIREBASE_BUNDLE_MISMATCH: 'FIREBASE_BUNDLE_MISMATCH', + FIREBASE_DETECTION_FAILED: 'FIREBASE_DETECTION_FAILED', } as const; export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; @@ -176,3 +183,21 @@ export class SessionError extends ClixError { this.sessionId = sessionId; } } + +/** + * Error for Firebase configuration failures. + */ +export class FirebaseError extends ClixError { + public readonly platform: 'android' | 'ios'; + public readonly file?: string; + + constructor(message: string, platform: 'android' | 'ios', code?: ErrorCode, file?: string) { + super(message, code ?? ERROR_CODES.FIREBASE_CONFIG_INVALID, true, { + platform, + file, + }); + this.name = 'FirebaseError'; + this.platform = platform; + this.file = file; + } +} diff --git a/src/lib/services/firebase/api/firebase-api.ts b/src/lib/services/firebase/api/firebase-api.ts new file mode 100644 index 0000000..ed54116 --- /dev/null +++ b/src/lib/services/firebase/api/firebase-api.ts @@ -0,0 +1,177 @@ +/** + * Firebase Management REST API client. + * + * @module services/firebase/api/firebase-api + */ + +import type { + AndroidApp, + AppConfigResponse, + FirebaseProject, + IosApp, + ListAndroidAppsResponse, + ListIosAppsResponse, + ListProjectsResponse, +} from './types'; + +const BASE_URL = 'https://firebase.googleapis.com/v1beta1'; + +/** + * Firebase Management API client. + * + * Uses the Firebase Management REST API to list projects, apps, and download configs. + */ +export class FirebaseApiClient { + private getAccessToken: () => Promise; + + /** + * Create a new Firebase API client. + * + * @param getAccessToken - Function to get a valid access token + */ + constructor(getAccessToken: () => Promise) { + this.getAccessToken = getAccessToken; + } + + /** + * Make an authenticated request to the Firebase API. + */ + 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', + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Firebase API error (${response.status}): ${error}`); + } + + return response.json() as Promise; + } + + /** + * List all Firebase projects accessible to the user. + * + * @returns List of Firebase projects + */ + async listProjects(): Promise { + const projects: FirebaseProject[] = []; + let pageToken: string | undefined; + + do { + const params = new URLSearchParams(); + if (pageToken) { + params.set('pageToken', pageToken); + } + + const query = params.toString(); + const path = query ? `/projects?${query}` : '/projects'; + const response = await this.request(path); + + if (response.results) { + projects.push(...response.results); + } + pageToken = response.nextPageToken; + } while (pageToken); + + return projects; + } + + /** + * List Android apps in a Firebase project. + * + * @param projectId - Firebase project ID + * @returns List of Android apps + */ + async listAndroidApps(projectId: string): Promise { + const apps: AndroidApp[] = []; + let pageToken: string | undefined; + + do { + const params = new URLSearchParams(); + if (pageToken) { + params.set('pageToken', pageToken); + } + + const query = params.toString(); + const path = query + ? `/projects/${projectId}/androidApps?${query}` + : `/projects/${projectId}/androidApps`; + const response = await this.request(path); + + if (response.apps) { + apps.push(...response.apps); + } + pageToken = response.nextPageToken; + } while (pageToken); + + return apps; + } + + /** + * List iOS apps in a Firebase project. + * + * @param projectId - Firebase project ID + * @returns List of iOS apps + */ + async listIosApps(projectId: string): Promise { + const apps: IosApp[] = []; + let pageToken: string | undefined; + + do { + const params = new URLSearchParams(); + if (pageToken) { + params.set('pageToken', pageToken); + } + + const query = params.toString(); + const path = query + ? `/projects/${projectId}/iosApps?${query}` + : `/projects/${projectId}/iosApps`; + const response = await this.request(path); + + if (response.apps) { + apps.push(...response.apps); + } + pageToken = response.nextPageToken; + } while (pageToken); + + return apps; + } + + /** + * 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 { + const response = await this.request( + `/projects/${projectId}/androidApps/${appId}/config`, + ); + + return Buffer.from(response.configFileContents, 'base64').toString('utf-8'); + } + + /** + * 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 { + const response = await this.request( + `/projects/${projectId}/iosApps/${appId}/config`, + ); + + return Buffer.from(response.configFileContents, 'base64').toString('utf-8'); + } +} diff --git a/src/lib/services/firebase/api/index.ts b/src/lib/services/firebase/api/index.ts new file mode 100644 index 0000000..f30ab8c --- /dev/null +++ b/src/lib/services/firebase/api/index.ts @@ -0,0 +1,17 @@ +/** + * Firebase Management API module. + * + * @module services/firebase/api + */ + +export { FirebaseApiClient } from './firebase-api'; +export type { + AndroidApp, + AppConfigResponse, + FirebaseApiError, + FirebaseProject, + IosApp, + ListAndroidAppsResponse, + ListIosAppsResponse, + ListProjectsResponse, +} from './types'; diff --git a/src/lib/services/firebase/api/types.ts b/src/lib/services/firebase/api/types.ts new file mode 100644 index 0000000..f412cc9 --- /dev/null +++ b/src/lib/services/firebase/api/types.ts @@ -0,0 +1,143 @@ +/** + * Firebase Management API types. + * + * @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; +} diff --git a/src/lib/services/firebase/detector.ts b/src/lib/services/firebase/detector.ts new file mode 100644 index 0000000..8e6a2e1 --- /dev/null +++ b/src/lib/services/firebase/detector.ts @@ -0,0 +1,667 @@ +/** + * Firebase credential file detection. + * + * Automatically detects Firebase configuration files in project directories. + * + * @module services/firebase/detector + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import plist from 'plist'; +import type { + ExpectedPaths, + FirebaseCredentialFile, + FirebaseDetectionResult, + FirebaseIssue, + Platform, +} from './types'; +import { FIREBASE_HELP_URLS } from './types'; +import { validateGoogleServiceInfoPlist, validateGoogleServicesJson } from './validator'; + +/** + * Expected paths for google-services.json by platform. + */ +const ANDROID_SEARCH_PATHS = [ + 'app/google-services.json', // Standard Android + 'android/app/google-services.json', // React Native / Flutter +]; + +/** + * Misplaced paths for google-services.json that will trigger warnings. + */ +const ANDROID_MISPLACED_PATHS = [ + 'google-services.json', // Root (wrong location) + 'android/google-services.json', // Android root (wrong location) +]; + +/** + * Expected paths for GoogleService-Info.plist by platform. + */ +const IOS_SEARCH_PATHS = [ + 'ios/GoogleService-Info.plist', // React Native + 'ios/Runner/GoogleService-Info.plist', // Flutter + 'GoogleService-Info.plist', // iOS project root +]; + +/** + * Directories to ignore when scanning. + */ +const IGNORE_DIRS = new Set(['node_modules', '.git', 'build', 'dist', '.gradle', 'Pods']); + +/** + * Check if Flutter project (pubspec.yaml exists). + */ +async function isFlutterProject(projectPath: string): Promise { + try { + await fs.access(path.join(projectPath, 'pubspec.yaml')); + return true; + } catch { + return false; + } +} + +/** + * Check if React Native project. + */ +async function isReactNativeProject(projectPath: string): Promise { + try { + const packageJson = await fs.readFile(path.join(projectPath, 'package.json'), 'utf-8'); + const pkg = JSON.parse(packageJson); + const deps = { ...pkg.dependencies, ...pkg.devDependencies }; + return Boolean(deps['react-native'] || deps.expo); + } catch { + return false; + } +} + +/** + * Check if directory has build.gradle files. + */ +async function hasBuildGradle(projectPath: string, dirName: string): Promise { + const gradlePath = path.join(projectPath, dirName, 'build.gradle'); + const gradleKtsPath = path.join(projectPath, dirName, 'build.gradle.kts'); + try { + await fs.access(gradlePath); + return true; + } catch { + try { + await fs.access(gradleKtsPath); + return true; + } catch { + return false; + } + } +} + +/** + * Check directory entry for iOS indicators. + */ +function isIosIndicator(entryName: string): boolean { + return ( + entryName === 'ios' || entryName.endsWith('.xcodeproj') || entryName.endsWith('.xcworkspace') + ); +} + +/** + * Check file entry for Android indicators. + */ +function isAndroidFile(fileName: string): boolean { + return ( + fileName === 'build.gradle' || + fileName === 'build.gradle.kts' || + fileName === 'AndroidManifest.xml' + ); +} + +/** + * Detect native platforms from directory entries. + */ +async function detectNativePlatforms( + projectPath: string, +): Promise<{ hasIos: boolean; hasAndroid: boolean }> { + const entries = await fs.readdir(projectPath, { withFileTypes: true }); + let hasIos = false; + let hasAndroid = false; + + for (const entry of entries) { + if (entry.isDirectory()) { + if (isIosIndicator(entry.name)) { + hasIos = true; + } + if (entry.name === 'android' || entry.name === 'app') { + if (await hasBuildGradle(projectPath, entry.name)) { + hasAndroid = true; + } + } + } + if (entry.isFile() && isAndroidFile(entry.name)) { + hasAndroid = true; + } + } + + return { hasIos, hasAndroid }; +} + +/** + * Detect the project platform based on project files. + */ +export async function detectPlatform(projectPath: string): Promise { + try { + // Check for cross-platform frameworks first + if (await isFlutterProject(projectPath)) { + return 'flutter'; + } + + if (await isReactNativeProject(projectPath)) { + return 'react-native'; + } + + // Check for native platforms + const { hasIos, hasAndroid } = await detectNativePlatforms(projectPath); + + if (hasIos && hasAndroid) { + // For dual-platform native projects without cross-platform framework, + // return 'unknown' to check all common locations + return 'unknown'; + } + if (hasIos) { + return 'ios'; + } + if (hasAndroid) { + return 'android'; + } + + return 'unknown'; + } catch { + return 'unknown'; + } +} + +/** + * Get expected credential file paths based on platform. + */ +export function getExpectedPaths(platform: Platform, _projectPath: string): ExpectedPaths { + const androidPaths: string[] = []; + const iosPaths: string[] = []; + + switch (platform) { + case 'react-native': + androidPaths.push('android/app/google-services.json'); + iosPaths.push('ios/GoogleService-Info.plist'); + break; + case 'flutter': + androidPaths.push('android/app/google-services.json'); + iosPaths.push('ios/Runner/GoogleService-Info.plist'); + break; + case 'android': + androidPaths.push('app/google-services.json'); + break; + case 'ios': + iosPaths.push('GoogleService-Info.plist'); + // Also check for app-specific directories + break; + default: + // For unknown, check all common locations + androidPaths.push(...ANDROID_SEARCH_PATHS); + iosPaths.push(...IOS_SEARCH_PATHS); + } + + return { android: androidPaths, ios: iosPaths }; +} + +/** + * Check if a file exists at the given path. + */ +async function fileExists(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + return stat.isFile(); + } catch { + return false; + } +} + +/** + * Read and parse a JSON file. + */ +async function readJsonFile(filePath: string): Promise { + const content = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(content); +} + +/** + * Read and parse a plist file (JSON or XML format). + */ +async function readPlistFile(filePath: string): Promise { + const content = await fs.readFile(filePath, 'utf-8'); + const trimmed = content.trim(); + + // Try JSON format first (faster) + if (trimmed.startsWith('{')) { + try { + return JSON.parse(content); + } catch { + // Fall through to plist parser + } + } + + // Try XML plist format + if (trimmed.includes(' { + const results: { path: string; inExpectedLocation: boolean }[] = []; + + // Check expected locations first + for (const searchPath of ANDROID_SEARCH_PATHS) { + const fullPath = path.join(projectPath, searchPath); + if (await fileExists(fullPath)) { + results.push({ path: searchPath, inExpectedLocation: true }); + } + } + + // Check misplaced locations + for (const misplacedPath of ANDROID_MISPLACED_PATHS) { + const fullPath = path.join(projectPath, misplacedPath); + if (await fileExists(fullPath)) { + // Only add if not already found in expected locations + if (!results.some((r) => r.path === misplacedPath)) { + results.push({ path: misplacedPath, inExpectedLocation: false }); + } + } + } + + return results; +} + +/** + * Find GoogleService-Info.plist files in the project. + */ +export async function findGoogleServiceInfoPlist( + projectPath: string, +): Promise<{ path: string; inExpectedLocation: boolean }[]> { + const results: { path: string; inExpectedLocation: boolean }[] = []; + + // Check expected locations + for (const searchPath of IOS_SEARCH_PATHS) { + const fullPath = path.join(projectPath, searchPath); + if (await fileExists(fullPath)) { + results.push({ path: searchPath, inExpectedLocation: true }); + } + } + + // Also search for app-specific plist locations in iOS directory + const iosDir = path.join(projectPath, 'ios'); + try { + const entries = await fs.readdir(iosDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && !IGNORE_DIRS.has(entry.name)) { + // Use POSIX join for relative path to ensure consistent forward slashes across platforms + const plistPath = path.posix.join('ios', entry.name, 'GoogleService-Info.plist'); + const fullPath = path.join(projectPath, 'ios', entry.name, 'GoogleService-Info.plist'); + if (await fileExists(fullPath)) { + if (!results.some((r) => r.path === plistPath)) { + results.push({ path: plistPath, inExpectedLocation: true }); + } + } + } + } + } catch { + // iOS directory doesn't exist or can't be read + } + + // Search for native iOS projects at root level (sibling directories to .xcodeproj) + try { + const rootEntries = await fs.readdir(projectPath, { withFileTypes: true }); + for (const entry of rootEntries) { + if (entry.isDirectory() && !IGNORE_DIRS.has(entry.name) && entry.name !== 'ios') { + // Use POSIX join for relative path to ensure consistent forward slashes across platforms + const plistPath = path.posix.join(entry.name, 'GoogleService-Info.plist'); + const fullPath = path.join(projectPath, entry.name, 'GoogleService-Info.plist'); + if (await fileExists(fullPath)) { + if (!results.some((r) => r.path === plistPath)) { + results.push({ path: plistPath, inExpectedLocation: true }); + } + } + } + } + } catch { + // Root directory can't be read + } + + return results; +} + +/** + * Detect and validate Android Firebase credential file. + */ +async function detectAndroidCredential( + projectPath: string, + expectedPaths: string[], +): Promise { + const found = await findGoogleServicesJson(projectPath); + + if (found.length === 0) { + return null; + } + + // Use the first found file (prefer platform-specific expected locations) + const expectedFile = found.find((f) => expectedPaths.includes(f.path)); + const file = expectedFile || found[0]; + const inExpectedLocation = + expectedPaths.length === 0 ? file.inExpectedLocation : expectedPaths.includes(file.path); + const absolutePath = path.join(projectPath, file.path); + + try { + const content = await readJsonFile(absolutePath); + const validation = validateGoogleServicesJson(content); + + return { + path: file.path, + absolutePath, + platform: 'android', + type: 'google-services', + exists: true, + valid: validation.valid, + errors: validation.errors, + content: validation.valid + ? (validation.data as FirebaseCredentialFile['content']) + : undefined, + inExpectedLocation, + expectedPath: !inExpectedLocation ? expectedPaths[0] : undefined, + }; + } catch (error) { + return { + path: file.path, + absolutePath, + platform: 'android', + type: 'google-services', + exists: true, + valid: false, + errors: [ + { + path: 'root', + message: + error instanceof SyntaxError + ? 'Invalid JSON format' + : `Failed to read file: ${String(error)}`, + code: 'PARSE_ERROR', + }, + ], + inExpectedLocation, + expectedPath: !inExpectedLocation ? expectedPaths[0] : undefined, + }; + } +} + +/** + * Detect and validate iOS Firebase credential file. + */ +async function detectIosCredential( + projectPath: string, + expectedPaths: string[], +): Promise { + const found = await findGoogleServiceInfoPlist(projectPath); + + if (found.length === 0) { + return null; + } + + // Use file in expected location if available, otherwise first found + const expectedFile = found.find((f) => expectedPaths.includes(f.path)); + const file = expectedFile || found[0]; + const absolutePath = path.join(projectPath, file.path); + // Determine if file is in expected location based on expectedPaths parameter + const inExpectedLocation = + expectedPaths.length === 0 ? file.inExpectedLocation : expectedPaths.includes(file.path); + + try { + const content = await readPlistFile(absolutePath); + const validation = validateGoogleServiceInfoPlist(content); + + return { + path: file.path, + absolutePath, + platform: 'ios', + type: 'google-service-info', + exists: true, + valid: validation.valid, + errors: validation.errors, + content: validation.valid + ? (validation.data as FirebaseCredentialFile['content']) + : undefined, + inExpectedLocation, + expectedPath: !inExpectedLocation ? expectedPaths[0] : undefined, + }; + } catch (error) { + return { + path: file.path, + absolutePath, + platform: 'ios', + type: 'google-service-info', + exists: true, + valid: false, + errors: [ + { + path: 'root', + message: `Invalid plist format: ${error instanceof Error ? error.message : String(error)}`, + code: 'PARSE_ERROR', + }, + ], + inExpectedLocation, + expectedPath: !inExpectedLocation ? expectedPaths[0] : undefined, + }; + } +} + +/** + * Check if platform needs Android config. + */ +function needsAndroidConfig(platform: Platform): boolean { + return platform === 'android' || platform === 'react-native' || platform === 'flutter'; +} + +/** + * Check if platform needs iOS config. + */ +function needsIosConfig(platform: Platform): boolean { + return platform === 'ios' || platform === 'react-native' || platform === 'flutter'; +} + +/** + * Generate Android-specific issues. + */ +function generateAndroidIssues( + android: FirebaseCredentialFile | null, + expectedPath: string, +): FirebaseIssue[] { + if (!android) { + return [ + { + type: 'missing', + severity: 'error', + platform: 'android', + description: 'google-services.json not found', + recommendation: `Download from Firebase Console and place in ${expectedPath || 'android/app/'}`, + helpUrl: FIREBASE_HELP_URLS.downloadConfig, + }, + ]; + } + + const issues: FirebaseIssue[] = []; + + if (!android.valid && android.errors.length > 0) { + const parseError = android.errors.find((e) => e.code === 'PARSE_ERROR'); + if (parseError) { + issues.push({ + type: 'parse_error', + severity: 'error', + platform: 'android', + file: android.path, + description: parseError.message, + recommendation: 'Re-download the file from Firebase Console or fix the JSON syntax', + helpUrl: FIREBASE_HELP_URLS.downloadConfig, + }); + } else { + issues.push({ + type: 'invalid', + severity: 'error', + platform: 'android', + file: android.path, + description: `Invalid google-services.json: ${android.errors.map((e) => e.message).join(', ')}`, + recommendation: 'Re-download the file from Firebase Console', + helpUrl: FIREBASE_HELP_URLS.downloadConfig, + }); + } + } + + if (!android.inExpectedLocation) { + issues.push({ + type: 'misplaced', + severity: 'warning', + platform: 'android', + file: android.path, + description: `google-services.json found in unexpected location: ${android.path}`, + recommendation: `Move to ${android.expectedPath || expectedPath}`, + helpUrl: FIREBASE_HELP_URLS.androidSetup, + }); + } + + return issues; +} + +/** + * Generate iOS-specific issues. + */ +function generateIosIssues( + ios: FirebaseCredentialFile | null, + expectedPath: string, +): FirebaseIssue[] { + if (!ios) { + return [ + { + type: 'missing', + severity: 'error', + platform: 'ios', + description: 'GoogleService-Info.plist not found', + recommendation: `Download from Firebase Console and place in ${expectedPath || 'ios/'}`, + helpUrl: FIREBASE_HELP_URLS.downloadConfig, + }, + ]; + } + + const issues: FirebaseIssue[] = []; + + if (!ios.valid && ios.errors.length > 0) { + const parseError = ios.errors.find((e) => e.code === 'PARSE_ERROR'); + if (parseError) { + issues.push({ + type: 'parse_error', + severity: 'warning', // plist XML format is common, make it warning + platform: 'ios', + file: ios.path, + description: parseError.message, + recommendation: 'Ensure the file is valid. XML plist files are supported by Xcode.', + helpUrl: FIREBASE_HELP_URLS.iosSetup, + }); + } else { + issues.push({ + type: 'invalid', + severity: 'error', + platform: 'ios', + file: ios.path, + description: `Invalid GoogleService-Info.plist: ${ios.errors.map((e) => e.message).join(', ')}`, + recommendation: 'Re-download the file from Firebase Console', + helpUrl: FIREBASE_HELP_URLS.downloadConfig, + }); + } + } + + if (!ios.inExpectedLocation) { + issues.push({ + type: 'misplaced', + severity: 'warning', + platform: 'ios', + file: ios.path, + description: `GoogleService-Info.plist found in unexpected location: ${ios.path}`, + recommendation: `Move to ${ios.expectedPath || expectedPath}`, + helpUrl: FIREBASE_HELP_URLS.iosSetup, + }); + } + + return issues; +} + +/** + * Generate issues based on detection results. + */ +function generateIssues( + android: FirebaseCredentialFile | null, + ios: FirebaseCredentialFile | null, + platform: Platform, + expectedPaths: ExpectedPaths, +): FirebaseIssue[] { + const issues: FirebaseIssue[] = []; + + if (needsAndroidConfig(platform)) { + issues.push(...generateAndroidIssues(android, expectedPaths.android[0])); + } + + if (needsIosConfig(platform)) { + issues.push(...generateIosIssues(ios, expectedPaths.ios[0])); + } + + return issues; +} + +/** + * Detect Firebase configuration in a project. + * + * @param projectPath - Path to the project root + * @returns Detection result with credential files and issues + */ +export async function detectFirebaseConfig(projectPath: string): Promise { + const platform = await detectPlatform(projectPath); + const expectedPaths = getExpectedPaths(platform, projectPath); + + const android = await detectAndroidCredential(projectPath, expectedPaths.android); + const ios = await detectIosCredential(projectPath, expectedPaths.ios); + + const issues = generateIssues(android, ios, platform, expectedPaths); + + // Determine if Firebase is configured + // For unknown platform, check if at least one valid config file exists + const needsAndroid = + platform === 'android' || platform === 'react-native' || platform === 'flutter'; + const needsIos = platform === 'ios' || platform === 'react-native' || platform === 'flutter'; + + let configured: boolean; + if (platform === 'unknown') { + // For unknown platform, configured is true only if at least one valid config exists + configured = (android?.valid ?? false) || (ios?.valid ?? false); + } else { + const androidConfigured = !needsAndroid || (android?.valid ?? false); + const iosConfigured = !needsIos || (ios?.valid ?? false); + configured = androidConfigured && iosConfigured; + } + + return { + platform, + android, + ios, + configured, + issues, + projectPath, + }; +} diff --git a/src/lib/services/firebase/downloader.ts b/src/lib/services/firebase/downloader.ts new file mode 100644 index 0000000..938cbd4 --- /dev/null +++ b/src/lib/services/firebase/downloader.ts @@ -0,0 +1,197 @@ +/** + * Firebase config file downloader. + * + * Downloads google-services.json and GoogleService-Info.plist from Firebase. + * + * @module services/firebase/downloader + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { AndroidApp, FirebaseProject, IosApp } from './api'; +import { FirebaseApiClient } from './api'; +import { detectPlatform, getExpectedPaths } from './detector'; +import { GoogleAuthClient } from './oauth'; +import type { Platform } from './types'; + +/** + * Download options. + */ +export interface DownloadOptions { + /** + * Project path (directory to save files). + */ + projectPath: string; + + /** + * Firebase project ID (optional, will prompt if not provided). + */ + firebaseProjectId?: string; + + /** + * Android package name for auto-matching (optional). + */ + androidPackageName?: string; + + /** + * iOS bundle ID for auto-matching (optional). + */ + iosBundleId?: string; +} + +/** + * Download result. + */ +export interface DownloadResult { + success: boolean; + androidPath?: string; + iosPath?: string; + error?: string; +} + +/** + * Firebase config downloader service. + */ +export class FirebaseDownloader { + private authClient: GoogleAuthClient; + private apiClient: FirebaseApiClient | null = null; + + constructor() { + this.authClient = new GoogleAuthClient(); + } + + /** + * Check if OAuth is configured. + */ + isConfigured(): boolean { + return this.authClient.isConfigured(); + } + + /** + * Check if user is authenticated. + */ + async isAuthenticated(): Promise { + return this.authClient.isAuthenticated(); + } + + /** + * Authenticate with Google OAuth. + * + * @param openBrowser - Callback to open URL in browser + */ + async authenticate(openBrowser: (url: string) => void): Promise { + const result = await this.authClient.authenticate(openBrowser); + if (result.success) { + this.apiClient = new FirebaseApiClient(() => this.authClient.getAccessToken()); + } + return result.success; + } + + /** + * Ensure API client is initialized. + */ + private ensureApiClient(): FirebaseApiClient { + if (!this.apiClient) { + this.apiClient = new FirebaseApiClient(() => this.authClient.getAccessToken()); + } + return this.apiClient; + } + + /** + * List Firebase projects. + */ + async listProjects(): Promise { + const api = this.ensureApiClient(); + return api.listProjects(); + } + + /** + * List Android apps in a project. + */ + async listAndroidApps(projectId: string): Promise { + const api = this.ensureApiClient(); + return api.listAndroidApps(projectId); + } + + /** + * List iOS apps in a project. + */ + async listIosApps(projectId: string): Promise { + const api = this.ensureApiClient(); + return api.listIosApps(projectId); + } + + /** + * Find Android app by package name. + */ + findAppByPackageName(apps: AndroidApp[], packageName: string): AndroidApp | null { + return apps.find((app) => app.packageName === packageName) || null; + } + + /** + * Find iOS app by bundle ID. + */ + findAppByBundleId(apps: IosApp[], bundleId: string): IosApp | null { + return apps.find((app) => app.bundleId === bundleId) || null; + } + + /** + * Download and save Android config. + * + * @param projectId - Firebase project ID + * @param appId - Android app ID + * @param savePath - Path to save the config file + */ + async downloadAndroidConfig(projectId: string, appId: string, savePath: string): Promise { + const api = this.ensureApiClient(); + const config = await api.getAndroidConfig(projectId, appId); + + const dir = path.dirname(savePath); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(savePath, config, 'utf-8'); + } + + /** + * Download and save iOS config. + * + * @param projectId - Firebase project ID + * @param appId - iOS app ID + * @param savePath - Path to save the config file + */ + async downloadIosConfig(projectId: string, appId: string, savePath: string): Promise { + const api = this.ensureApiClient(); + const config = await api.getIosConfig(projectId, appId); + + const dir = path.dirname(savePath); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(savePath, config, 'utf-8'); + } + + /** + * Get expected save paths for config files. + */ + async getExpectedSavePaths( + projectPath: string, + ): Promise<{ android: string | null; ios: string | null; platform: Platform }> { + const platform = await detectPlatform(projectPath); + const paths = getExpectedPaths(platform, projectPath); + + const needsAndroid = + platform === 'android' || platform === 'react-native' || platform === 'flutter'; + const needsIos = platform === 'ios' || platform === 'react-native' || platform === 'flutter'; + + return { + android: needsAndroid ? path.join(projectPath, paths.android[0]) : null, + ios: needsIos ? path.join(projectPath, paths.ios[0]) : null, + platform, + }; + } + + /** + * Logout (clear stored tokens). + */ + async logout(): Promise { + await this.authClient.logout(); + this.apiClient = null; + } +} diff --git a/src/lib/services/firebase/firebase-service.ts b/src/lib/services/firebase/firebase-service.ts new file mode 100644 index 0000000..dbf16b9 --- /dev/null +++ b/src/lib/services/firebase/firebase-service.ts @@ -0,0 +1,283 @@ +/** + * Firebase configuration service. + * + * Main service for detecting, validating, and managing Firebase configuration. + * + * @module services/firebase/firebase-service + */ + +import { detectFirebaseConfig, detectPlatform, getExpectedPaths } from './detector'; +import type { + FirebaseDetectionResult, + FirebaseRecommendation, + FirebaseStatus, + GoogleServiceInfoPlist, + GoogleServicesJson, +} from './types'; +import { FIREBASE_HELP_URLS } from './types'; +import { extractProjectId, extractProjectIdFromPlist, validateProjectIdMatch } from './validator'; + +/** + * Firebase configuration service. + * + * Provides methods for detecting, validating, and managing Firebase configuration. + */ +export class FirebaseService { + private projectPath: string; + private cachedResult: FirebaseDetectionResult | null = null; + + /** + * Create a new FirebaseService instance. + * + * @param projectPath - Path to the project root directory + */ + constructor(projectPath: string) { + this.projectPath = projectPath; + } + + /** + * Detect Firebase configuration in the project. + * + * @param forceRefresh - Force re-detection even if cached + * @returns Detection result with credential files and issues + */ + async detect(forceRefresh = false): Promise { + if (!forceRefresh && this.cachedResult) { + return this.cachedResult; + } + + this.cachedResult = await detectFirebaseConfig(this.projectPath); + return this.cachedResult; + } + + /** + * Get the current Firebase configuration status. + * + * @returns Status summary + */ + async getStatus(): Promise { + const result = await this.detect(); + + const errorCount = result.issues.filter((i) => i.severity === 'error').length; + const warningCount = result.issues.filter((i) => i.severity === 'warning').length; + + const needsAndroid = + result.platform === 'android' || + result.platform === 'react-native' || + result.platform === 'flutter'; + const needsIos = + result.platform === 'ios' || + result.platform === 'react-native' || + result.platform === 'flutter'; + + const androidConfigured = !needsAndroid || (result.android?.valid ?? false); + const iosConfigured = !needsIos || (result.ios?.valid ?? false); + + let status: 'configured' | 'partial' | 'missing'; + if (androidConfigured && iosConfigured) { + status = 'configured'; + } else if (androidConfigured || iosConfigured) { + status = 'partial'; + } else { + status = 'missing'; + } + + return { + status, + androidConfigured, + iosConfigured, + issueCount: result.issues.length, + errorCount, + warningCount, + }; + } + + /** + * Get recommendations for fixing Firebase issues. + * + * @returns Sorted list of recommendations (highest priority first) + */ + async getRecommendations(): Promise { + const result = await this.detect(); + const recommendations: FirebaseRecommendation[] = []; + + for (const issue of result.issues) { + let priority: number; + let action: FirebaseRecommendation['action']; + let title: string; + + switch (issue.type) { + case 'missing': + priority = 1; + action = 'download'; + title = + issue.platform === 'android' + ? 'Download google-services.json' + : 'Download GoogleService-Info.plist'; + break; + case 'invalid': + case 'parse_error': + priority = 2; + action = 'fix'; + title = + issue.platform === 'android' + ? 'Fix google-services.json' + : 'Fix GoogleService-Info.plist'; + break; + case 'misplaced': + priority = 3; + action = 'move'; + title = + issue.platform === 'android' + ? 'Move google-services.json to correct location' + : 'Move GoogleService-Info.plist to correct location'; + break; + case 'mismatch': + priority = 4; + action = 'verify'; + title = 'Verify Firebase configuration'; + break; + default: + priority = 5; + action = 'verify'; + title = 'Review Firebase configuration'; + } + + recommendations.push({ + priority, + title, + description: issue.description, + action, + platform: issue.platform, + helpUrl: issue.helpUrl, + }); + } + + // Sort by priority + return recommendations.sort((a, b) => a.priority - b.priority); + } + + /** + * Get the expected credential file path for a platform. + * + * @param platform - Target platform + * @returns Expected file path + */ + async getExpectedPath(platform: 'android' | 'ios'): Promise { + const detectedPlatform = await detectPlatform(this.projectPath); + const paths = getExpectedPaths(detectedPlatform, this.projectPath); + const platformPaths = platform === 'android' ? paths.android : paths.ios; + return ( + platformPaths[0] || + (platform === 'android' ? 'android/app/google-services.json' : 'ios/GoogleService-Info.plist') + ); + } + + /** + * Get the Firebase project ID from detected configuration. + * + * @returns Project ID or null if not available + */ + async getProjectId(): Promise { + const result = await this.detect(); + + if (result.android?.valid && result.android.content) { + return extractProjectId(result.android.content as GoogleServicesJson); + } + + if (result.ios?.valid && result.ios.content) { + return extractProjectIdFromPlist(result.ios.content as GoogleServiceInfoPlist); + } + + return null; + } + + /** + * Check if project IDs match between Android and iOS configurations. + * + * @returns True if matching or only one platform is configured + */ + async hasMatchingProjectIds(): Promise { + const result = await this.detect(); + + if (!result.android?.valid || !result.ios?.valid) { + // Can't compare if one or both are missing/invalid + return true; + } + + const validation = validateProjectIdMatch( + result.android.content as GoogleServicesJson, + result.ios.content as GoogleServiceInfoPlist, + ); + + return validation.valid; + } + + /** + * Get a summary of the Firebase configuration for display. + * + * @returns Human-readable summary + */ + async getSummary(): Promise { + const result = await this.detect(); + const status = await this.getStatus(); + const lines: string[] = []; + + lines.push(`Platform: ${result.platform}`); + lines.push(`Status: ${status.status}`); + + if (result.android) { + const androidStatus = result.android.valid ? 'valid' : 'invalid'; + lines.push(`Android: ${androidStatus} (${result.android.path})`); + if (result.android.valid && result.android.content) { + const projectId = extractProjectId(result.android.content as GoogleServicesJson); + lines.push(` Project ID: ${projectId}`); + } + } else if ( + result.platform === 'android' || + result.platform === 'react-native' || + result.platform === 'flutter' + ) { + lines.push('Android: missing'); + } + + if (result.ios) { + const iosStatus = result.ios.valid ? 'valid' : 'invalid'; + lines.push(`iOS: ${iosStatus} (${result.ios.path})`); + if (result.ios.valid && result.ios.content) { + const projectId = extractProjectIdFromPlist(result.ios.content as GoogleServiceInfoPlist); + lines.push(` Project ID: ${projectId}`); + } + } else if ( + result.platform === 'ios' || + result.platform === 'react-native' || + result.platform === 'flutter' + ) { + lines.push('iOS: missing'); + } + + if (result.issues.length > 0) { + lines.push(''); + lines.push(`Issues: ${status.errorCount} errors, ${status.warningCount} warnings`); + } + + return lines.join('\n'); + } + + /** + * Clear the cached detection result. + */ + clearCache(): void { + this.cachedResult = null; + } + + /** + * Get help URL for a specific topic. + * + * @param topic - Help topic + * @returns Help URL + */ + static getHelpUrl(topic: keyof typeof FIREBASE_HELP_URLS): string { + return FIREBASE_HELP_URLS[topic]; + } +} diff --git a/src/lib/services/firebase/index.ts b/src/lib/services/firebase/index.ts new file mode 100644 index 0000000..4c3ea1d --- /dev/null +++ b/src/lib/services/firebase/index.ts @@ -0,0 +1,44 @@ +/** + * Firebase configuration service. + * + * Provides detection, validation, and management of Firebase configuration files. + * + * @module services/firebase + */ + +export type { AndroidApp, FirebaseProject, IosApp } from './api'; +// API module +export { FirebaseApiClient } from './api'; + +// Detection and validation +export { detectFirebaseConfig, detectPlatform, getExpectedPaths } from './detector'; +export type { DownloadOptions, DownloadResult } from './downloader'; +// Downloader +export { FirebaseDownloader } from './downloader'; +export { FirebaseService } from './firebase-service'; +export type { AuthResult, OAuthFlowState, OAuthFlowStatus, OAuthTokens } from './oauth'; + +// OAuth module +export { + GOOGLE_OAUTH_CONFIG, + GoogleAuthClient, + getOAuthConfigurationError, + isOAuthConfigured, + TokenStore, +} from './oauth'; +export { + GoogleServiceInfoPlistSchema, + GoogleServicesJsonSchema, + MinimalGoogleServiceInfoPlistSchema, + MinimalGoogleServicesJsonSchema, +} from './schemas'; +export * from './types'; +export { + extractProjectId, + extractProjectIdFromPlist, + validateBundleIdMatch, + validateGoogleServiceInfoPlist, + validateGoogleServicesJson, + validatePackageNameMatch, + validateProjectIdMatch, +} from './validator'; diff --git a/src/lib/services/firebase/oauth/auth-client.ts b/src/lib/services/firebase/oauth/auth-client.ts new file mode 100644 index 0000000..7b99be4 --- /dev/null +++ b/src/lib/services/firebase/oauth/auth-client.ts @@ -0,0 +1,278 @@ +/** + * Google OAuth client for Firebase authentication. + * + * Uses google-auth-library for OAuth 2.0 with PKCE support. + * + * @module services/firebase/oauth/auth-client + */ + +import crypto from 'node:crypto'; +import http from 'node:http'; +import { URL } from 'node:url'; +import { type CodeChallengeMethod, OAuth2Client } from 'google-auth-library'; +import { GOOGLE_OAUTH_CONFIG, isOAuthConfigured } from './config'; +import { TokenStore } from './token-store'; +import type { AuthResult, OAuthCallbackResult, OAuthTokens } from './types'; + +/** + * Generate a cryptographically random code verifier for PKCE. + */ +function generateCodeVerifier(): string { + return crypto.randomBytes(32).toString('base64url'); +} + +/** + * Generate a code challenge from the verifier for PKCE. + */ +function generateCodeChallenge(verifier: string): string { + return crypto.createHash('sha256').update(verifier).digest('base64url'); +} + +/** + * Google OAuth client for CLI authentication. + */ +export class GoogleAuthClient { + private client: OAuth2Client; + private tokenStore: TokenStore; + private codeVerifier: string | null = null; + private oauthState: string | null = null; + + constructor() { + this.client = new OAuth2Client({ + clientId: GOOGLE_OAUTH_CONFIG.clientId, + redirectUri: GOOGLE_OAUTH_CONFIG.redirectUri, + }); + this.tokenStore = new TokenStore(); + } + + /** + * Check if OAuth is configured. + */ + isConfigured(): boolean { + return isOAuthConfigured(); + } + + /** + * Check if user is already authenticated with valid tokens. + */ + async isAuthenticated(): Promise { + const tokens = await this.tokenStore.load(); + if (!tokens) return false; + + // If we have a refresh token, we can always refresh + if (this.tokenStore.hasRefreshToken(tokens)) { + return true; + } + + // Otherwise check if access token is still valid + return !this.tokenStore.isExpired(tokens); + } + + /** + * Generate authorization URL for browser-based authentication. + * + * @returns Authorization URL to open in browser + */ + generateAuthUrl(): string { + // Generate PKCE code verifier and challenge + this.codeVerifier = generateCodeVerifier(); + this.oauthState = crypto.randomBytes(16).toString('hex'); + const codeChallenge = generateCodeChallenge(this.codeVerifier); + + const url = this.client.generateAuthUrl({ + access_type: 'offline', + scope: [...GOOGLE_OAUTH_CONFIG.scopes], + code_challenge_method: 'S256' as CodeChallengeMethod, + code_challenge: codeChallenge, + state: this.oauthState, + prompt: 'consent', // Always show consent to get refresh token + }); + + return url; + } + + /** + * Wait for OAuth callback on local HTTP server. + * + * @returns Promise resolving to callback result with authorization code + */ + waitForCallback(): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url || '/', `http://localhost:${GOOGLE_OAUTH_CONFIG.callbackPort}`); + + if (url.pathname === '/oauth/callback') { + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state') || ''; + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(` + + +

❌ Authentication Failed

+

Error: ${error}

+

You can close this window.

+ + + `); + cleanup(); + reject(new Error(`OAuth error: ${error}`)); + return; + } + + // Validate OAuth state to prevent CSRF attacks + if (!this.oauthState || state !== this.oauthState) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(` + + +

❌ Authentication Failed

+

Invalid OAuth state.

+

You can close this window.

+ + + `); + cleanup(); + reject(new Error('OAuth state mismatch')); + return; + } + + if (!code) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(` + + +

❌ Authentication Failed

+

No authorization code received.

+

You can close this window.

+ + + `); + cleanup(); + reject(new Error('No authorization code received')); + return; + } + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + +

✅ Authentication Successful

+

You can close this window and return to the CLI.

+ + + `); + cleanup(); + this.oauthState = null; + resolve({ code, state }); + } else { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + } + }); + + const timeout = setTimeout(() => { + cleanup(); + reject(new Error('OAuth callback timeout')); + }, GOOGLE_OAUTH_CONFIG.timeoutMs); + + const cleanup = () => { + clearTimeout(timeout); + server.close(); + }; + + // Bind to localhost only for security + server.listen(GOOGLE_OAUTH_CONFIG.callbackPort, '127.0.0.1'); + + server.on('error', (err) => { + cleanup(); + reject(new Error(`Failed to start OAuth callback server: ${err.message}`)); + }); + }); + } + + /** + * Exchange authorization code for tokens. + * + * @param code - Authorization code from callback + */ + async exchangeCode(code: string): Promise { + if (!this.codeVerifier) { + throw new Error('PKCE code verifier not found. Call generateAuthUrl() first.'); + } + + const { tokens } = await this.client.getToken({ + code, + codeVerifier: this.codeVerifier, + redirect_uri: GOOGLE_OAUTH_CONFIG.redirectUri, + }); + + this.client.setCredentials(tokens); + await this.tokenStore.save(tokens as OAuthTokens); + this.codeVerifier = null; + } + + /** + * Get access token for API calls. + * Automatically refreshes if expired. + * + * @returns Access token string + */ + async getAccessToken(): Promise { + const tokens = await this.tokenStore.load(); + + if (!tokens) { + throw new Error('Not authenticated. Run OAuth flow first.'); + } + + this.client.setCredentials(tokens); + + // google-auth-library automatically refreshes expired tokens + const { token } = await this.client.getAccessToken(); + + if (!token) { + throw new Error('Failed to get access token'); + } + + // Save potentially refreshed tokens + const credentials = this.client.credentials; + if (credentials.access_token !== tokens.access_token) { + await this.tokenStore.save(credentials as OAuthTokens); + } + + return token; + } + + /** + * Run the full OAuth authentication flow. + * + * @param openBrowser - Callback to open URL in browser + * @returns Authentication result + */ + async authenticate(openBrowser: (url: string) => void): Promise { + try { + const authUrl = this.generateAuthUrl(); + openBrowser(authUrl); + + const { code } = await this.waitForCallback(); + await this.exchangeCode(code); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Clear stored tokens (logout). + */ + async logout(): Promise { + await this.tokenStore.clear(); + this.client.setCredentials({}); + } +} diff --git a/src/lib/services/firebase/oauth/config.ts b/src/lib/services/firebase/oauth/config.ts new file mode 100644 index 0000000..5150c37 --- /dev/null +++ b/src/lib/services/firebase/oauth/config.ts @@ -0,0 +1,82 @@ +/** + * OAuth configuration for Google authentication. + * + * @module services/firebase/oauth/config + */ + +/** + * Google OAuth configuration. + * + * Client ID is loaded from the `CLIX_GOOGLE_CLIENT_ID` environment variable. + * This is a Desktop/Native app OAuth client, which doesn't require a client secret. + */ +export const GOOGLE_OAUTH_CONFIG = { + /** + * OAuth Client ID. + * Can be overridden via CLIX_GOOGLE_CLIENT_ID environment variable. + * Default is Clix's official OAuth client (Desktop app). + */ + clientId: + process.env.CLIX_GOOGLE_CLIENT_ID || + '187255663323-w4iy9mdaxrv6i3d0oqpdb3nhgqx6dt.apps.googleusercontent.com', + + /** + * Google OAuth 2.0 authorization endpoint. + */ + authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', + + /** + * Google OAuth 2.0 token endpoint. + */ + tokenEndpoint: 'https://oauth2.googleapis.com/token', + + /** + * OAuth scopes required for Firebase Management API. + * Using readonly scope for listing projects/apps and downloading configs. + */ + scopes: ['https://www.googleapis.com/auth/firebase.readonly'], + + /** + * Local redirect URI for OAuth callback. + * The CLI will start a temporary HTTP server on this port to receive the callback. + */ + redirectUri: 'http://localhost:9005/oauth/callback', + + /** + * Port for the local OAuth callback server. + */ + callbackPort: 9005, + + /** + * Timeout for OAuth flow in milliseconds (5 minutes). + */ + timeoutMs: 5 * 60 * 1000, +} as const; + +/** + * Check if OAuth is configured (Client ID is set). + * + * @returns True if OAuth client ID is available (default or from environment variable) + */ +export function isOAuthConfigured(): boolean { + return !!GOOGLE_OAUTH_CONFIG.clientId; +} + +/** + * Get error message for missing OAuth configuration. + * + * @returns User-friendly error message with setup instructions + */ +export function getOAuthConfigurationError(): string { + return `OAuth client ID is not configured. + +Clix uses a default OAuth client, but you can use your own: +1. Create OAuth Client ID at https://console.cloud.google.com/apis/credentials + - Application type: Desktop app + - Add redirect URI: http://localhost:9005/oauth/callback +2. Set the environment variable: + export CLIX_GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com" + +You can also manually download Firebase config files from: +https://console.firebase.google.com/`; +} diff --git a/src/lib/services/firebase/oauth/index.ts b/src/lib/services/firebase/oauth/index.ts new file mode 100644 index 0000000..8aa0159 --- /dev/null +++ b/src/lib/services/firebase/oauth/index.ts @@ -0,0 +1,16 @@ +/** + * OAuth module for Firebase authentication. + * + * @module services/firebase/oauth + */ + +export { GoogleAuthClient } from './auth-client'; +export { GOOGLE_OAUTH_CONFIG, getOAuthConfigurationError, isOAuthConfigured } from './config'; +export { TokenStore } from './token-store'; +export type { + AuthResult, + OAuthCallbackResult, + OAuthFlowState, + OAuthFlowStatus, + OAuthTokens, +} from './types'; diff --git a/src/lib/services/firebase/oauth/token-store.ts b/src/lib/services/firebase/oauth/token-store.ts new file mode 100644 index 0000000..27de74f --- /dev/null +++ b/src/lib/services/firebase/oauth/token-store.ts @@ -0,0 +1,105 @@ +/** + * Token storage for OAuth credentials. + * + * Stores OAuth tokens in the XDG config directory (~/.config/clix/). + * + * @module services/firebase/oauth/token-store + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { xdg } from '@/lib/utils/xdg'; +import type { OAuthTokens } from './types'; + +const TOKEN_FILE_NAME = 'firebase-tokens.json'; + +/** + * Token store for persisting OAuth tokens. + */ +export class TokenStore { + private tokenPath: string; + + constructor() { + this.tokenPath = path.join(xdg.config(), TOKEN_FILE_NAME); + } + + /** + * Load tokens from storage. + * + * @returns Stored tokens or null if not found + */ + async load(): Promise { + try { + const data = await fs.readFile(this.tokenPath, 'utf-8'); + return JSON.parse(data) as OAuthTokens; + } catch { + return null; + } + } + + /** + * Save tokens to storage. + * + * @param tokens - OAuth tokens to save + */ + async save(tokens: OAuthTokens): Promise { + const dir = path.dirname(this.tokenPath); + // Create directory with restricted permissions (owner only) + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + // Write token file with restricted permissions (owner read/write only) + await fs.writeFile(this.tokenPath, JSON.stringify(tokens, null, 2), { + encoding: 'utf-8', + mode: 0o600, + }); + } + + /** + * Clear stored tokens. + */ + async clear(): Promise { + try { + await fs.unlink(this.tokenPath); + } catch { + // Ignore if file doesn't exist + } + } + + /** + * Check if tokens exist in storage. + * + * @returns True if tokens file exists + */ + async exists(): Promise { + try { + await fs.access(this.tokenPath); + return true; + } catch { + return false; + } + } + + /** + * Check if tokens are expired. + * + * @param tokens - Tokens to check + * @returns True if tokens are expired or will expire within 5 minutes + */ + isExpired(tokens: OAuthTokens): boolean { + if (!tokens.expiry_date) { + return false; // No expiry info, assume valid + } + // Consider expired if less than 5 minutes remaining + const bufferMs = 5 * 60 * 1000; + return Date.now() >= tokens.expiry_date - bufferMs; + } + + /** + * Check if we have a valid refresh token. + * + * @param tokens - Tokens to check + * @returns True if refresh token exists + */ + hasRefreshToken(tokens: OAuthTokens): boolean { + return !!tokens.refresh_token; + } +} diff --git a/src/lib/services/firebase/oauth/types.ts b/src/lib/services/firebase/oauth/types.ts new file mode 100644 index 0000000..4c89a19 --- /dev/null +++ b/src/lib/services/firebase/oauth/types.ts @@ -0,0 +1,55 @@ +/** + * OAuth types for Firebase authentication. + * + * @module services/firebase/oauth/types + */ + +import type { Credentials } from 'google-auth-library'; + +/** + * OAuth tokens stored locally. + */ +export interface OAuthTokens extends Credentials { + access_token?: string | null; + refresh_token?: string | null; + scope?: string; + token_type?: string | null; + expiry_date?: number | null; +} + +/** + * OAuth authentication result. + */ +export interface AuthResult { + success: boolean; + tokens?: OAuthTokens; + error?: string; +} + +/** + * OAuth callback result from the redirect. + */ +export interface OAuthCallbackResult { + code: string; + state: string; +} + +/** + * OAuth flow state. + */ +export type OAuthFlowState = + | 'idle' + | 'waiting_for_browser' + | 'waiting_for_callback' + | 'exchanging_tokens' + | 'authenticated' + | 'error'; + +/** + * OAuth flow status for UI display. + */ +export interface OAuthFlowStatus { + state: OAuthFlowState; + message?: string; + error?: string; +} diff --git a/src/lib/services/firebase/schemas.ts b/src/lib/services/firebase/schemas.ts new file mode 100644 index 0000000..075d201 --- /dev/null +++ b/src/lib/services/firebase/schemas.ts @@ -0,0 +1,146 @@ +/** + * Zod schemas for Firebase credential file validation. + * + * @module services/firebase/schemas + */ + +import { z } from 'zod'; + +/** + * Schema for Android client info within google-services.json. + */ +const AndroidClientInfoSchema = z.object({ + package_name: z + .string() + .min(1, 'Package name is required') + .regex(/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/, 'Invalid Android package name format'), +}); + +/** + * Schema for client info within google-services.json. + */ +const ClientInfoSchema = z.object({ + mobilesdk_app_id: z + .string() + .min(1, 'Mobile SDK app ID is required') + .regex(/^\d+:\d+:android:[a-f0-9]+$/, 'Invalid Mobile SDK app ID format'), + android_client_info: AndroidClientInfoSchema, +}); + +/** + * Schema for API key within google-services.json. + */ +const ApiKeySchema = z.object({ + current_key: z.string().min(1, 'API key is required'), +}); + +/** + * Schema for OAuth client within google-services.json. + */ +const OAuthClientSchema = z.object({ + client_id: z.string().min(1), + client_type: z.number(), +}); + +/** + * Schema for services within google-services.json client. + */ +const ServicesSchema = z + .object({ + appinvite_service: z + .object({ + other_platform_oauth_client: z.array(OAuthClientSchema).optional(), + }) + .optional(), + }) + .optional(); + +/** + * Schema for a client entry within google-services.json. + */ +const ClientSchema = z.object({ + client_info: ClientInfoSchema, + api_key: z.array(ApiKeySchema).min(1, 'At least one API key is required'), + oauth_client: z.array(OAuthClientSchema).optional(), + services: ServicesSchema, +}); + +/** + * Schema for project info within google-services.json. + */ +const ProjectInfoSchema = z.object({ + project_number: z.string().min(1, 'Project number is required'), + project_id: z.string().min(1, 'Project ID is required'), + storage_bucket: z.string(), +}); + +/** + * Full schema for google-services.json (Android). + * Validates the structure of Firebase configuration file for Android. + */ +export const GoogleServicesJsonSchema = z.object({ + project_info: ProjectInfoSchema, + client: z.array(ClientSchema).min(1, 'At least one client configuration is required'), + configuration_version: z.string().optional(), +}); + +/** + * Schema for GoogleService-Info.plist (iOS). + * Validates the structure of Firebase configuration file for iOS. + */ +export const GoogleServiceInfoPlistSchema = z.object({ + API_KEY: z.string().min(1, 'API_KEY is required'), + GCM_SENDER_ID: z + .string() + .min(1, 'GCM_SENDER_ID is required') + .regex(/^\d+$/, 'GCM_SENDER_ID must be numeric'), + GOOGLE_APP_ID: z + .string() + .min(1, 'GOOGLE_APP_ID is required') + .regex(/^\d+:\d+:ios:[a-f0-9]+$/, 'Invalid GOOGLE_APP_ID format'), + PROJECT_ID: z.string().min(1, 'PROJECT_ID is required'), + BUNDLE_ID: z + .string() + .min(1, 'BUNDLE_ID is required') + .regex(/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/i, 'Invalid bundle ID format'), + CLIENT_ID: z.string().optional(), + REVERSED_CLIENT_ID: z.string().optional(), + STORAGE_BUCKET: z.string().optional(), + DATABASE_URL: z.string().optional(), + PLIST_VERSION: z.string().optional(), + IS_ADS_ENABLED: z.boolean().optional(), + IS_ANALYTICS_ENABLED: z.boolean().optional(), + IS_APPINVITE_ENABLED: z.boolean().optional(), + IS_GCM_ENABLED: z.boolean().optional(), + IS_SIGNIN_ENABLED: z.boolean().optional(), +}); + +/** + * Minimal schema for quick validation of google-services.json. + * Used for initial detection before full validation. + */ +export const MinimalGoogleServicesJsonSchema = z.object({ + project_info: z.object({ + project_id: z.string().min(1), + }), + client: z.array(z.unknown()).min(1), +}); + +/** + * Minimal schema for quick validation of GoogleService-Info.plist. + * Used for initial detection before full validation. + */ +export const MinimalGoogleServiceInfoPlistSchema = z.object({ + PROJECT_ID: z.string().min(1), + GOOGLE_APP_ID: z.string().min(1), +}); + +/** + * Type inference for validated google-services.json. + */ +export type ValidatedGoogleServicesJson = z.infer; + +/** + * Type inference for validated GoogleService-Info.plist. + */ +export type ValidatedGoogleServiceInfoPlist = z.infer; diff --git a/src/lib/services/firebase/types.ts b/src/lib/services/firebase/types.ts new file mode 100644 index 0000000..96692a4 --- /dev/null +++ b/src/lib/services/firebase/types.ts @@ -0,0 +1,248 @@ +/** + * Firebase configuration types and interfaces. + * + * @module services/firebase/types + */ + +/** + * Supported mobile platforms. + */ +export type Platform = 'ios' | 'android' | 'react-native' | 'flutter' | 'unknown'; + +/** + * Firebase credential file types. + */ +export type CredentialFileType = 'google-services' | 'google-service-info'; + +/** + * Issue severity levels. + */ +export type IssueSeverity = 'error' | 'warning' | 'info'; + +/** + * Issue types for Firebase configuration. + */ +export type IssueType = 'missing' | 'invalid' | 'misplaced' | 'mismatch' | 'parse_error'; + +/** + * Google Services JSON structure (Android). + * Reference: https://firebase.google.com/docs/android/setup + */ +export interface GoogleServicesJson { + project_info: { + project_number: string; + project_id: string; + storage_bucket: string; + }; + client: Array<{ + client_info: { + mobilesdk_app_id: string; + android_client_info: { + package_name: string; + }; + }; + api_key: Array<{ current_key: string }>; + oauth_client?: Array<{ + client_id: string; + client_type: number; + }>; + services?: { + appinvite_service?: { + other_platform_oauth_client?: Array<{ + client_id: string; + client_type: number; + }>; + }; + }; + }>; + configuration_version?: string; +} + +/** + * Google Service Info Plist structure (iOS). + * Reference: https://firebase.google.com/docs/ios/setup + */ +export interface GoogleServiceInfoPlist { + API_KEY: string; + GCM_SENDER_ID: string; + GOOGLE_APP_ID: string; + PROJECT_ID: string; + BUNDLE_ID: string; + CLIENT_ID?: string; + REVERSED_CLIENT_ID?: string; + STORAGE_BUCKET?: string; + DATABASE_URL?: string; + PLIST_VERSION?: string; + IS_ADS_ENABLED?: boolean; + IS_ANALYTICS_ENABLED?: boolean; + IS_APPINVITE_ENABLED?: boolean; + IS_GCM_ENABLED?: boolean; + IS_SIGNIN_ENABLED?: boolean; +} + +/** + * Validation error details. + */ +export interface ValidationError { + path: string; + message: string; + code?: string; +} + +/** + * Result of validating a credential file. + */ +export interface ValidationResult { + valid: boolean; + errors: ValidationError[]; + data?: GoogleServicesJson | GoogleServiceInfoPlist; +} + +/** + * Firebase credential file information. + */ +export interface FirebaseCredentialFile { + /** File path relative to project root */ + path: string; + /** Absolute file path */ + absolutePath: string; + /** Target platform */ + platform: 'android' | 'ios'; + /** Credential file type */ + type: CredentialFileType; + /** Whether file exists */ + exists: boolean; + /** Whether file is valid */ + valid: boolean; + /** Validation errors if any */ + errors: ValidationError[]; + /** Parsed content if valid */ + content?: GoogleServicesJson | GoogleServiceInfoPlist; + /** Whether file is in expected location */ + inExpectedLocation: boolean; + /** Expected location if different from actual */ + expectedPath?: string; +} + +/** + * Firebase configuration issue. + */ +export interface FirebaseIssue { + /** Issue type */ + type: IssueType; + /** Issue severity */ + severity: IssueSeverity; + /** Affected platform */ + platform: 'android' | 'ios'; + /** Related file path */ + file?: string; + /** Issue description */ + description: string; + /** Recommended action */ + recommendation: string; + /** Help URL for more information */ + helpUrl?: string; +} + +/** + * Result of Firebase configuration detection. + */ +export interface FirebaseDetectionResult { + /** Detected project platform */ + platform: Platform; + /** Android credential file info */ + android: FirebaseCredentialFile | null; + /** iOS credential file info */ + ios: FirebaseCredentialFile | null; + /** Whether Firebase is fully configured */ + configured: boolean; + /** Configuration issues found */ + issues: FirebaseIssue[]; + /** Project root path */ + projectPath: string; +} + +/** + * Expected credential file paths for each platform. + */ +export interface ExpectedPaths { + android: string[]; + ios: string[]; +} + +/** + * Firebase status summary. + */ +export interface FirebaseStatus { + /** Overall configuration status */ + status: 'configured' | 'partial' | 'missing'; + /** Android configuration status */ + androidConfigured: boolean; + /** iOS configuration status */ + iosConfigured: boolean; + /** Number of issues */ + issueCount: number; + /** Number of errors */ + errorCount: number; + /** Number of warnings */ + warningCount: number; +} + +/** + * Recommendation for fixing Firebase issues. + */ +export interface FirebaseRecommendation { + /** Priority order (lower = higher priority) */ + priority: number; + /** Recommendation title */ + title: string; + /** Detailed description */ + description: string; + /** Action to take */ + action: 'download' | 'move' | 'fix' | 'verify'; + /** Affected platform */ + platform: 'android' | 'ios'; + /** Help URL */ + helpUrl?: string; +} + +/** + * Firebase help URLs. + */ +export const FIREBASE_HELP_URLS = { + androidSetup: 'https://firebase.google.com/docs/android/setup', + iosSetup: 'https://firebase.google.com/docs/ios/setup', + console: 'https://console.firebase.google.com/', + downloadConfig: 'https://support.google.com/firebase/answer/7015592', + reactNativeSetup: 'https://rnfirebase.io/', + flutterSetup: 'https://firebase.google.com/docs/flutter/setup', +} as const; + +/** + * Credential action types for the wizard menu. + */ +export type CredentialAction = + | { type: 'redetect' } + | { type: 'redetect_platform'; platform: 'android' | 'ios' } + | { type: 'validate'; platform: 'android' | 'ios' } + | { type: 'help'; topic: keyof typeof FIREBASE_HELP_URLS } + | { type: 'download' } + | { type: 'skip' } + | { type: 'done' }; + +/** + * Wizard phase states. + */ +export type WizardPhase = 'detecting' | 'status' | 'menu' | 'validating' | 'error' | 'complete'; + +/** + * Result from Firebase setup wizard. + */ +export interface FirebaseSetupResult { + /** Whether setup was completed */ + completed: boolean; + /** Whether setup was skipped */ + skipped: boolean; + /** Final detection result */ + detection: FirebaseDetectionResult | null; +} diff --git a/src/lib/services/firebase/validator.ts b/src/lib/services/firebase/validator.ts new file mode 100644 index 0000000..86f284e --- /dev/null +++ b/src/lib/services/firebase/validator.ts @@ -0,0 +1,273 @@ +/** + * Firebase credential file validation. + * + * Validates Firebase configuration files against Zod schemas. + * + * @module services/firebase/validator + */ + +import type { ZodError, ZodSchema } from 'zod'; +import { + GoogleServiceInfoPlistSchema, + GoogleServicesJsonSchema, + MinimalGoogleServiceInfoPlistSchema, + MinimalGoogleServicesJsonSchema, +} from './schemas'; +import type { + GoogleServiceInfoPlist, + GoogleServicesJson, + ValidationError, + ValidationResult, +} from './types'; + +/** + * Convert Zod errors to validation errors. + */ +function zodErrorsToValidationErrors(zodError: ZodError): ValidationError[] { + return zodError.issues.map((issue) => ({ + path: issue.path.join('.') || 'root', + message: issue.message, + code: issue.code, + })); +} + +/** + * Validate data against a Zod schema. + */ +function validateWithSchema( + data: unknown, + schema: ZodSchema, +): { valid: boolean; errors: ValidationError[]; data?: T } { + const result = schema.safeParse(data); + + if (result.success) { + return { valid: true, errors: [], data: result.data }; + } + + return { + valid: false, + errors: zodErrorsToValidationErrors(result.error), + }; +} + +/** + * Validate google-services.json content. + * + * @param content - Parsed JSON content + * @returns Validation result with errors if invalid + */ +export function validateGoogleServicesJson(content: unknown): ValidationResult { + // First do a quick check to see if it's the right type of file + const minimalCheck = validateWithSchema(content, MinimalGoogleServicesJsonSchema); + + if (!minimalCheck.valid) { + // Check if it might be a GoogleService-Info.plist instead + const plistCheck = validateWithSchema(content, MinimalGoogleServiceInfoPlistSchema); + if (plistCheck.valid) { + return { + valid: false, + errors: [ + { + path: 'root', + message: + 'This appears to be a GoogleService-Info.plist (iOS) file, not a google-services.json (Android) file', + code: 'WRONG_FILE_TYPE', + }, + ], + }; + } + + return { + valid: false, + errors: [ + { + path: 'root', + message: 'File does not appear to be a valid Firebase configuration file', + code: 'INVALID_FORMAT', + }, + ...minimalCheck.errors, + ], + }; + } + + // Full validation + const fullResult = validateWithSchema(content, GoogleServicesJsonSchema); + + if (fullResult.valid) { + return { + valid: true, + errors: [], + data: fullResult.data as GoogleServicesJson, + }; + } + + return { + valid: false, + errors: fullResult.errors, + }; +} + +/** + * Validate GoogleService-Info.plist content. + * + * @param content - Parsed plist content (as JSON object) + * @returns Validation result with errors if invalid + */ +export function validateGoogleServiceInfoPlist(content: unknown): ValidationResult { + // First do a quick check to see if it's the right type of file + const minimalCheck = validateWithSchema(content, MinimalGoogleServiceInfoPlistSchema); + + if (!minimalCheck.valid) { + // Check if it might be a google-services.json instead + const jsonCheck = validateWithSchema(content, MinimalGoogleServicesJsonSchema); + if (jsonCheck.valid) { + return { + valid: false, + errors: [ + { + path: 'root', + message: + 'This appears to be a google-services.json (Android) file, not a GoogleService-Info.plist (iOS) file', + code: 'WRONG_FILE_TYPE', + }, + ], + }; + } + + return { + valid: false, + errors: [ + { + path: 'root', + message: 'File does not appear to be a valid Firebase configuration file', + code: 'INVALID_FORMAT', + }, + ...minimalCheck.errors, + ], + }; + } + + // Full validation + const fullResult = validateWithSchema(content, GoogleServiceInfoPlistSchema); + + if (fullResult.valid) { + return { + valid: true, + errors: [], + data: fullResult.data as GoogleServiceInfoPlist, + }; + } + + return { + valid: false, + errors: fullResult.errors, + }; +} + +/** + * Validate that the package name in google-services.json matches the expected package. + * + * @param googleServices - Validated google-services.json content + * @param expectedPackageName - Expected Android package name + * @returns Validation result + */ +export function validatePackageNameMatch( + googleServices: GoogleServicesJson, + expectedPackageName: string, +): ValidationResult { + const packageNames = googleServices.client.map( + (client) => client.client_info.android_client_info.package_name, + ); + + if (packageNames.includes(expectedPackageName)) { + return { valid: true, errors: [] }; + } + + return { + valid: false, + errors: [ + { + path: 'client.client_info.android_client_info.package_name', + message: `Package name mismatch. Expected "${expectedPackageName}", found: ${packageNames.join(', ')}`, + code: 'PACKAGE_MISMATCH', + }, + ], + }; +} + +/** + * Validate that the bundle ID in GoogleService-Info.plist matches the expected bundle ID. + * + * @param serviceInfo - Validated GoogleService-Info.plist content + * @param expectedBundleId - Expected iOS bundle ID + * @returns Validation result + */ +export function validateBundleIdMatch( + serviceInfo: GoogleServiceInfoPlist, + expectedBundleId: string, +): ValidationResult { + if (serviceInfo.BUNDLE_ID === expectedBundleId) { + return { valid: true, errors: [] }; + } + + return { + valid: false, + errors: [ + { + path: 'BUNDLE_ID', + message: `Bundle ID mismatch. Expected "${expectedBundleId}", found: "${serviceInfo.BUNDLE_ID}"`, + code: 'BUNDLE_MISMATCH', + }, + ], + }; +} + +/** + * Extract project ID from google-services.json. + * + * @param content - google-services.json content + * @returns Project ID or undefined if not found + */ +export function extractProjectId(content: GoogleServicesJson): string { + return content.project_info.project_id; +} + +/** + * Extract project ID from GoogleService-Info.plist. + * + * @param content - GoogleService-Info.plist content + * @returns Project ID or undefined if not found + */ +export function extractProjectIdFromPlist(content: GoogleServiceInfoPlist): string { + return content.PROJECT_ID; +} + +/** + * Validate that both Android and iOS files point to the same Firebase project. + * + * @param googleServices - Validated google-services.json content + * @param serviceInfo - Validated GoogleService-Info.plist content + * @returns Validation result + */ +export function validateProjectIdMatch( + googleServices: GoogleServicesJson, + serviceInfo: GoogleServiceInfoPlist, +): ValidationResult { + const androidProjectId = extractProjectId(googleServices); + const iosProjectId = extractProjectIdFromPlist(serviceInfo); + + if (androidProjectId === iosProjectId) { + return { valid: true, errors: [] }; + } + + return { + valid: false, + errors: [ + { + path: 'project_id', + message: `Project ID mismatch between platforms. Android: "${androidProjectId}", iOS: "${iosProjectId}"`, + code: 'PROJECT_MISMATCH', + }, + ], + }; +} diff --git a/src/lib/skills/doctor/SKILL.md b/src/lib/skills/doctor/SKILL.md index 1bd5951..85108d7 100644 --- a/src/lib/skills/doctor/SKILL.md +++ b/src/lib/skills/doctor/SKILL.md @@ -27,7 +27,11 @@ Analyze the project and output a diagnostic JSON report: "apiKeyConfigured": true | false, "pushPermissions": true | false, "entitlements": true | false, - "firebaseConfig": true | false + "firebaseConfig": true | false, + "firebaseAndroid": true | false, + "firebaseIos": true | false, + "firebasePackageMatch": true | false, + "firebaseBundleMatch": true | false }, "nextSteps": ["Step 1", "Step 2"] } @@ -55,6 +59,30 @@ Analyze the project and output a diagnostic JSON report: - Android: Check AndroidManifest.xml for FCM service - Check for google-services.json (Android) or GoogleService-Info.plist (iOS) +### Firebase Configuration Check (Detailed) + +**Android (google-services.json):** +- Check file presence in expected locations: + - Standard Android: `app/google-services.json` + - React Native/Flutter: `android/app/google-services.json` +- Validate JSON structure against Firebase schema +- Verify `project_info.project_id` exists +- Verify `client[].client_info.android_client_info.package_name` matches AndroidManifest.xml +- Report if file found in wrong location (e.g., project root) + +**iOS (GoogleService-Info.plist):** +- Check file presence in expected locations: + - React Native: `ios/GoogleService-Info.plist` + - Flutter: `ios/Runner/GoogleService-Info.plist` + - Native iOS: `/GoogleService-Info.plist` +- Validate plist structure (API_KEY, GCM_SENDER_ID, GOOGLE_APP_ID, PROJECT_ID, BUNDLE_ID) +- Verify BUNDLE_ID matches Xcode project bundle identifier +- Report if file found in wrong location + +**Cross-Platform Validation:** +- For React Native/Flutter projects, verify both Android and iOS configs exist +- Verify PROJECT_ID matches between platforms + ### Common Issues to Detect - Missing SDK dependency - Missing or invalid API key @@ -62,5 +90,12 @@ Analyze the project and output a diagnostic JSON report: - Missing capabilities/entitlements - Outdated SDK version - Incomplete Firebase/APNs setup +- Firebase config file missing +- Firebase config file in wrong location +- Firebase config file invalid (malformed JSON/plist) +- Firebase package name / bundle ID mismatch +- Firebase project ID mismatch between platforms Output the JSON diagnostic, then provide a brief summary with actionable recommendations. + +Use `/firebase` command to interactively check and configure Firebase credentials. diff --git a/src/lib/skills/install/SKILL.md b/src/lib/skills/install/SKILL.md index 5628c12..854c490 100644 --- a/src/lib/skills/install/SKILL.md +++ b/src/lib/skills/install/SKILL.md @@ -95,11 +95,12 @@ First, detect the dependency manager being used: **Android:** - Modify MainActivity or Application class - Update AndroidManifest.xml with permissions -- Note: Firebase setup may require manual steps +- Verify Firebase configuration (see step 6) **Flutter:** - Modify main.dart to initialize SDK - Update platform-specific files as needed +- Verify Firebase configuration (see step 6) ### 4. Use Placeholders for Secrets @@ -112,6 +113,32 @@ Execute necessary commands: - `cd ios && pod install` for iOS dependencies - `flutter pub get` for Flutter +### 6. Verify Firebase Configuration + +For push notifications to work, Firebase must be properly configured: + +**Android (google-services.json):** +- Expected locations: + - Standard Android: `app/google-services.json` + - React Native/Flutter: `android/app/google-services.json` +- Download from Firebase Console > Project Settings > Your apps > Android app +- Verify package name matches your AndroidManifest.xml + +**iOS (GoogleService-Info.plist):** +- Expected locations: + - React Native: `ios/GoogleService-Info.plist` + - Flutter: `ios/Runner/GoogleService-Info.plist` + - Native iOS: `/GoogleService-Info.plist` +- Download from Firebase Console > Project Settings > Your apps > iOS app +- Verify bundle ID matches your Xcode project + +**Validation:** +- Check if files exist in correct locations +- Verify JSON/plist structure is valid +- Confirm project IDs match between platforms (for cross-platform apps) + +Use `/firebase` command in interactive mode to check and configure Firebase credentials. + ## Automation Rules ✅ **DO:** diff --git a/src/ui/chat/ChatApp.tsx b/src/ui/chat/ChatApp.tsx index 5e4efc2..b7dbc40 100644 --- a/src/ui/chat/ChatApp.tsx +++ b/src/ui/chat/ChatApp.tsx @@ -5,6 +5,7 @@ import type { AgentInfo } from '../../lib/agents'; import type { InstallationMethod, UpdateCheckResult } from '../../lib/services/update-service'; import { AgentSelector } from '../components/AgentSelector'; import { DebugPrompt } from '../components/DebugPrompt'; +import { FirebaseWizard } from '../components/FirebaseWizard'; import { MCPInstallSelector } from '../components/MCPInstallSelector'; import { SessionSelector } from '../components/SessionSelector'; import { TransferSelector } from '../components/TransferSelector'; @@ -217,6 +218,13 @@ const ChatAppInner: React.FC {overlays.activeOverlay === 'debug' && ( )} + {overlays.activeOverlay === 'firebase' && ( + + )} {/* Input (hidden when overlay is active) */} {!isOverlayActive && ( diff --git a/src/ui/chat/hooks/useCommandHandler.ts b/src/ui/chat/hooks/useCommandHandler.ts index 31677e5..f1c1d78 100644 --- a/src/ui/chat/hooks/useCommandHandler.ts +++ b/src/ui/chat/hooks/useCommandHandler.ts @@ -45,6 +45,7 @@ interface UseCommandHandlerOptions { | 'showResumeSelector' | 'showMCPInstallSelector' | 'showDebugPrompt' + | 'showFirebaseWizard' >; } @@ -84,6 +85,7 @@ export function useCommandHandler(options: UseCommandHandlerOptions) { showResumeSelector, showMCPInstallSelector, showDebugPrompt, + showFirebaseWizard, } = overlays; const handleSlashCommand = useCallback( @@ -153,6 +155,10 @@ export function useCommandHandler(options: UseCommandHandlerOptions) { showDebugPrompt(); return; + case 'firebase': + showFirebaseWizard(); + return; + case 'update': handleUpdateCommand(addSystemMessage); return; @@ -186,6 +192,7 @@ export function useCommandHandler(options: UseCommandHandlerOptions) { showResumeSelector, showMCPInstallSelector, showDebugPrompt, + showFirebaseWizard, onTransferWithAgent, onExit, exit, diff --git a/src/ui/chat/hooks/useOverlays.ts b/src/ui/chat/hooks/useOverlays.ts index 0ea778a..d233014 100644 --- a/src/ui/chat/hooks/useOverlays.ts +++ b/src/ui/chat/hooks/useOverlays.ts @@ -17,7 +17,7 @@ import { } from '../../../lib/services/mcp-install-service'; import type { useChatActions } from './useChatActions'; -export type OverlayType = 'agent' | 'transfer' | 'resume' | 'mcp' | 'debug' | null; +export type OverlayType = 'agent' | 'transfer' | 'resume' | 'mcp' | 'debug' | 'firebase' | null; interface UseOverlaysOptions { currentAgent: AgentInfo | null; @@ -104,6 +104,7 @@ export function useOverlays(options: UseOverlaysOptions) { }, []); const showDebugPrompt = useCallback(() => setActiveOverlay('debug'), []); + const showFirebaseWizard = useCallback(() => setActiveOverlay('firebase'), []); const hideOverlay = useCallback(() => setActiveOverlay(null), []); // Agent selector handlers @@ -221,6 +222,24 @@ export function useOverlays(options: UseOverlaysOptions) { [executeDebugSession], ); + // Firebase wizard handlers + const handleFirebaseComplete = useCallback( + (result: { completed?: boolean; skipped?: boolean }) => { + setActiveOverlay(null); + if (result.skipped) { + addSystemMessage('Firebase setup skipped'); + } else if (result.completed) { + addSystemMessage('Firebase configuration complete'); + } + }, + [addSystemMessage], + ); + + const handleFirebaseCancel = useCallback(() => { + setActiveOverlay(null); + addSystemMessage('Firebase setup cancelled'); + }, [addSystemMessage]); + return { activeOverlay, hideOverlay, @@ -255,5 +274,10 @@ export function useOverlays(options: UseOverlaysOptions) { // Debug prompt showDebugPrompt, handleDebugPromptSubmit, + + // Firebase wizard + showFirebaseWizard, + handleFirebaseComplete, + handleFirebaseCancel, }; } diff --git a/src/ui/components/FirebaseStatusDisplay.tsx b/src/ui/components/FirebaseStatusDisplay.tsx new file mode 100644 index 0000000..2126e9a --- /dev/null +++ b/src/ui/components/FirebaseStatusDisplay.tsx @@ -0,0 +1,257 @@ +import { Box, Text } from 'ink'; +import type React from 'react'; +import type { + FirebaseCredentialFile, + FirebaseDetectionResult, + FirebaseIssue, + GoogleServiceInfoPlist, + GoogleServicesJson, +} from '@/lib/services/firebase'; +import { extractProjectId, extractProjectIdFromPlist } from '@/lib/services/firebase'; + +interface FirebaseStatusDisplayProps { + result: FirebaseDetectionResult; + showDetails?: boolean; + compact?: boolean; +} + +/** + * Status icon based on validity. + */ +function StatusIcon({ valid, exists }: { valid: boolean; exists: boolean }): React.ReactElement { + if (!exists) { + return ( + + ✗ + + ); + } + if (valid) { + return ( + + ✓ + + ); + } + return ( + + ! + + ); +} + +/** + * Display credential file status. + */ +function CredentialStatus({ + credential, + label, + showDetails, +}: { + credential: FirebaseCredentialFile | null; + label: string; + showDetails?: boolean; +}): React.ReactElement { + if (!credential) { + return ( + + + + ✗ + + {label}: + not found + + + ); + } + + const statusText = credential.valid ? 'valid' : 'invalid'; + const statusColor = credential.valid ? 'green' : 'red'; + + return ( + + + + {label}: + {statusText} + + {showDetails && ( + <> + + Location: {credential.path} + + {credential.valid && credential.content && ( + + + Project ID:{' '} + {credential.platform === 'android' + ? extractProjectId(credential.content as GoogleServicesJson) + : extractProjectIdFromPlist(credential.content as GoogleServiceInfoPlist)} + + + )} + {!credential.inExpectedLocation && credential.expectedPath && ( + + → Expected: {credential.expectedPath} + + )} + {!credential.valid && credential.errors.length > 0 && ( + + {credential.errors.slice(0, 3).map((error) => ( + + • {error.message} + + ))} + {credential.errors.length > 3 && ( + ...and {credential.errors.length - 3} more errors + )} + + )} + + )} + + ); +} + +/** + * Display issue list. + */ +function IssueList({ issues }: { issues: FirebaseIssue[] }): React.ReactElement | null { + if (issues.length === 0) { + return null; + } + + const errors = issues.filter((i) => i.severity === 'error'); + const warnings = issues.filter((i) => i.severity === 'warning'); + const infos = issues.filter((i) => i.severity === 'info'); + + return ( + + Issues: + {errors.map((issue) => ( + + + {issue.description} + + ))} + {warnings.map((issue) => ( + + ! + {issue.description} + + ))} + {infos.map((issue) => ( + + + {issue.description} + + ))} + + ); +} + +/** + * Check if platform needs Android config. + */ +function platformNeedsAndroid(platform: string): boolean { + return platform === 'android' || platform === 'react-native' || platform === 'flutter'; +} + +/** + * Check if platform needs iOS config. + */ +function platformNeedsIos(platform: string): boolean { + return platform === 'ios' || platform === 'react-native' || platform === 'flutter'; +} + +/** + * Get status symbol for credential. + */ +function getStatusSymbol( + credential: FirebaseCredentialFile | null | undefined, + needed: boolean, +): string { + if (!needed) return 'n/a'; + if (!credential) return '✗'; + return credential.valid ? '✓' : '!'; +} + +/** + * Get status color for symbol. + */ +function getStatusColor(status: string): string { + if (status === '✓') return 'green'; + if (status === '!') return 'yellow'; + return 'red'; +} + +/** + * Compact status display. + */ +function CompactStatus({ result }: { result: FirebaseDetectionResult }): React.ReactElement { + const needsAndroid = platformNeedsAndroid(result.platform); + const needsIos = platformNeedsIos(result.platform); + const androidStatus = getStatusSymbol(result.android, needsAndroid); + const iosStatus = getStatusSymbol(result.ios, needsIos); + + return ( + + Firebase: + Android {androidStatus} + + iOS {iosStatus} + + ); +} + +/** + * Display Firebase configuration status. + */ +export const FirebaseStatusDisplay: React.FC = ({ + result, + showDetails = true, + compact = false, +}) => { + if (compact) { + return ; + } + + const needsAndroid = platformNeedsAndroid(result.platform); + const needsIos = platformNeedsIos(result.platform); + + return ( + + + Firebase Configuration + ({result.platform}) + + + {needsAndroid && ( + + )} + + {needsIos && ( + + )} + + {showDetails && } + + {result.configured && ( + + + ✓ Firebase is configured + + + )} + + ); +}; diff --git a/src/ui/components/FirebaseWizard.tsx b/src/ui/components/FirebaseWizard.tsx new file mode 100644 index 0000000..f61568d --- /dev/null +++ b/src/ui/components/FirebaseWizard.tsx @@ -0,0 +1,978 @@ +import { spawn } from 'node:child_process'; +import { Box, Text, useInput } from 'ink'; +import SelectInput from 'ink-select-input'; +import Spinner from 'ink-spinner'; +import type React from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + type AndroidApp, + type CredentialAction, + FIREBASE_HELP_URLS, + type FirebaseDetectionResult, + FirebaseDownloader, + type FirebaseProject, + FirebaseService, + type FirebaseSetupResult, + type IosApp, + isOAuthConfigured, + type WizardPhase, +} from '@/lib/services/firebase'; +import { FirebaseStatusDisplay } from './FirebaseStatusDisplay'; +import { GenericSelector, type SelectorItem } from './GenericSelector'; + +interface FirebaseWizardProps { + projectPath: string; + onComplete: (result: FirebaseSetupResult) => void; + onCancel?: () => void; +} + +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'; + +/** + * Build menu items based on detection result. + */ +function buildMenuItems(result: FirebaseDetectionResult): MenuAction[] { + const items: MenuAction[] = []; + + const needsAndroid = + result.platform === 'android' || + result.platform === 'react-native' || + result.platform === 'flutter'; + const needsIos = + result.platform === 'ios' || + result.platform === 'react-native' || + result.platform === 'flutter'; + + const hasMissingConfigs = + (needsAndroid && !result.android?.valid) || (needsIos && !result.ios?.valid); + + // Download from Firebase option (if OAuth is configured and configs are missing) + if (hasMissingConfigs && isOAuthConfigured()) { + items.push({ + id: 'download', + label: '⬇ Download from Firebase', + description: 'Authenticate with Google and download config files', + action: { type: 'download' }, + }); + } + + // 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' }, + }); + } + + // 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' }, + }); + } + items.push({ + id: 'help-ios', + label: 'Help: Download GoogleService-Info.plist', + action: { type: 'help', topic: 'downloadConfig' }, + }); + } + + // General actions + items.push({ + id: 'redetect-all', + label: 'Re-detect all Firebase files', + action: { type: 'redetect' }, + }); + + items.push({ + id: 'skip', + label: 'Skip Firebase setup', + description: 'Continue without Firebase configuration', + action: { type: 'skip' }, + }); + + if (result.configured) { + items.push({ + id: 'done', + label: 'Done', + description: 'Firebase is configured', + action: { type: 'done' }, + }); + } + + return items; +} + +/** + * Open URL in default browser. + * Uses spawn with argument array to prevent shell injection. + */ +function openBrowser(url: string): void { + const platform = process.platform; + + let command: string; + let args: string[]; + + if (platform === 'darwin') { + command = 'open'; + args = [url]; + } else if (platform === 'win32') { + // Windows 'start' requires empty title as first arg for URLs + command = 'cmd'; + args = ['/c', 'start', '""', url]; + } else { + command = 'xdg-open'; + args = [url]; + } + + spawn(command, args, { detached: true, stdio: 'ignore' }).unref(); +} + +/** + * Authenticating phase component. + */ +function AuthenticatingPhase(): React.ReactElement { + return ( + + + Firebase Authentication + + + + + + Opening browser for Google authentication... + + + Complete the authentication in your browser. + + + ); +} + +/** + * Project selector component. + */ +function ProjectSelector({ + projects, + onSelect, + onCancel, +}: { + projects: FirebaseProject[]; + onSelect: (project: FirebaseProject) => void; + onCancel: () => void; +}): React.ReactElement { + const items = projects.map((p) => ({ + label: p.displayName || p.projectId, + value: p, + })); + + useInput((_input, key) => { + if (key.escape) { + onCancel(); + } + }); + + return ( + + + Select Firebase Project + + onSelect(item.value)} /> + + ↑↓ navigate · Enter select · Esc cancel + + + ); +} + +/** + * App selector component. + */ +function AppSelector({ + apps, + platform, + onSelect, + onCancel, +}: { + apps: (AndroidApp | IosApp)[]; + platform: 'android' | 'ios'; + onSelect: (app: AndroidApp | IosApp) => void; + onCancel: () => void; +}): React.ReactElement { + const items = apps.map((app) => ({ + label: + app.displayName || + (platform === 'android' ? (app as AndroidApp).packageName : (app as IosApp).bundleId), + value: app, + })); + + useInput((_input, key) => { + if (key.escape) { + onCancel(); + } + }); + + const title = platform === 'android' ? 'Select Android App' : 'Select iOS App'; + + return ( + + + {title} + + onSelect(item.value)} /> + + ↑↓ navigate · Enter select · Esc cancel + + + ); +} + +/** + * Downloading phase component. + */ +function DownloadingPhase({ + platform, +}: { + platform: 'android' | 'ios' | 'both'; +}): React.ReactElement { + const message = + platform === 'both' + ? 'Downloading config files...' + : platform === 'android' + ? 'Downloading google-services.json...' + : 'Downloading GoogleService-Info.plist...'; + + return ( + + + Firebase Download + + + + + + {message} + + + ); +} + +/** + * Detecting phase component. + */ +function DetectingPhase(): React.ReactElement { + return ( + + + Firebase Configuration + + + + + + Detecting Firebase credentials... + + + ); +} + +/** + * Status phase component. + */ +function StatusPhase({ + result, + onContinue, + onSkip, +}: { + result: FirebaseDetectionResult; + onContinue: () => void; + onSkip: () => void; +}): React.ReactElement { + useInput((_input, key) => { + if (key.return) { + onContinue(); + } else if (key.escape) { + onSkip(); + } + }); + + return ( + + + + Press Enter to configure, Esc to skip + + + ); +} + +/** + * Menu phase component. + */ +function MenuPhase({ + result, + onAction, + onCancel, +}: { + result: FirebaseDetectionResult; + onAction: (action: CredentialAction) => void; + onCancel: () => void; +}): React.ReactElement { + const items = buildMenuItems(result); + + const handleSelect = useCallback( + (item: MenuAction) => { + onAction(item.action); + }, + [onAction], + ); + + return ( + + ); +} + +/** + * Validating phase component. + */ +function ValidatingPhase({ platform }: { platform: 'android' | 'ios' }): React.ReactElement { + const fileName = platform === 'android' ? 'google-services.json' : 'GoogleService-Info.plist'; + + return ( + + + Firebase Configuration + + + + + + Validating {fileName}... + + + ); +} + +/** + * Error phase component. + */ +function ErrorPhase({ + error, + onRetry, + onSkip, +}: { + error: string; + onRetry: () => void; + onSkip: () => void; +}): React.ReactElement { + useInput((_input, key) => { + if (key.return) { + onRetry(); + } else if (key.escape) { + onSkip(); + } + }); + + return ( + + + + Firebase Configuration Error + + + + ✗ {error} + + + Press Enter to retry, Esc to skip + + + ); +} + +/** + * Complete phase component. + */ +function CompletePhase({ + result, + skipped, +}: { + result: FirebaseDetectionResult | null; + skipped: boolean; +}): React.ReactElement { + if (skipped) { + return ( + + + + ! Firebase setup skipped + + + + You can run /firebase later to configure Firebase. + + + ); + } + + return ( + + + + ✓ Firebase Configuration Complete + + + {result && } + + ); +} + +/** + * Firebase setup wizard component. + * + * Guides users through Firebase configuration detection and validation. + */ +export const FirebaseWizard: React.FC = ({ + projectPath, + onComplete, + onCancel, +}) => { + const [phase, setPhase] = useState('detecting'); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [validatingPlatform, setValidatingPlatform] = useState<'android' | 'ios' | null>(null); + const [skipped, setSkipped] = useState(false); + + // Download flow state + const [downloader] = useState(() => new FirebaseDownloader()); + const [projects, setProjects] = useState([]); + const [selectedProject, setSelectedProject] = useState(null); + const [androidApps, setAndroidApps] = useState([]); + const [iosApps, setIosApps] = useState([]); + const [selectedAndroidApp, setSelectedAndroidApp] = useState(null); + const [downloadingPlatform, setDownloadingPlatform] = useState<'android' | 'ios' | 'both'>( + 'both', + ); + + const [service] = useState(() => new FirebaseService(projectPath)); + + // Initial detection + useEffect(() => { + const detect = async () => { + try { + const detectionResult = await service.detect(); + setResult(detectionResult); + setPhase('status'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Detection failed'); + setPhase('error'); + } + }; + + if (phase === 'detecting') { + detect(); + } + }, [phase, service]); + + const handleContinue = useCallback(() => { + setPhase('menu'); + }, []); + + const handleSkip = useCallback(() => { + setSkipped(true); + setPhase('complete'); + onComplete({ + completed: false, + skipped: true, + detection: result, + }); + }, [onComplete, result]); + + // Helper to determine which platforms need config files + const getPlatformNeeds = useCallback(() => { + const needsAndroid = + result?.platform === 'android' || + result?.platform === 'react-native' || + result?.platform === 'flutter'; + const needsIos = + result?.platform === 'ios' || + result?.platform === 'react-native' || + result?.platform === 'flutter'; + const needsAndroidConfig = needsAndroid && !result?.android?.valid; + const needsIosConfig = needsIos && !result?.ios?.valid; + return { needsAndroid, needsIos, needsAndroidConfig, needsIosConfig }; + }, [result]); + + // Download config files - defined first as it's called by other handlers via ref + const handleDownloadConfigs = useCallback( + async (project: FirebaseProject, androidApp: AndroidApp | null, iosApp: IosApp | null) => { + if (androidApp && iosApp) { + setDownloadingPlatform('both'); + } else if (androidApp) { + setDownloadingPlatform('android'); + } else { + setDownloadingPlatform('ios'); + } + setPhase('downloading'); + + try { + const paths = await downloader.getExpectedSavePaths(projectPath); + + if (androidApp && paths.android) { + await downloader.downloadAndroidConfig( + project.projectId, + androidApp.appId, + paths.android, + ); + } + + if (iosApp && paths.ios) { + await downloader.downloadIosConfig(project.projectId, iosApp.appId, paths.ios); + } + + // Re-detect to verify + service.clearCache(); + const newResult = await service.detect(); + setResult(newResult); + setPhase('status'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Download failed'); + setPhase('error'); + } + }, + [projectPath, downloader, service], + ); + + // Use ref to avoid circular dependencies + const downloadConfigsRef = useRef(handleDownloadConfigs); + downloadConfigsRef.current = handleDownloadConfigs; + + // Handle iOS app selection + const handleIosAppSelect = useCallback( + (app: IosApp, project: FirebaseProject) => { + downloadConfigsRef.current(project, selectedAndroidApp, app); + }, + [selectedAndroidApp], + ); + + // Use ref for iOS app select handler + const iosAppSelectRef = useRef(handleIosAppSelect); + iosAppSelectRef.current = handleIosAppSelect; + + // Handle Android app selection + const handleAndroidAppSelect = useCallback( + async (app: AndroidApp, project: FirebaseProject, needsIos: boolean) => { + setSelectedAndroidApp(app); + + if (!needsIos) { + downloadConfigsRef.current(project, app, null); + return; + } + + // Also need iOS config + try { + const apps = await downloader.listIosApps(project.projectId); + setIosApps(apps); + + if (apps.length === 0) { + // No iOS apps, just download Android + downloadConfigsRef.current(project, app, null); + } else if (apps.length === 1) { + downloadConfigsRef.current(project, app, apps[0]); + } else { + setPhase('select_ios_app'); + } + } catch { + // Failed to get iOS apps, just download Android + downloadConfigsRef.current(project, app, null); + } + }, + [downloader], + ); + + // Use ref for Android app select handler + const androidAppSelectRef = useRef(handleAndroidAppSelect); + androidAppSelectRef.current = handleAndroidAppSelect; + + // Fetch and handle Android apps for a project + const fetchAndHandleAndroidApps = useCallback( + async (project: FirebaseProject, needsIosConfig: boolean) => { + const apps = await downloader.listAndroidApps(project.projectId); + setAndroidApps(apps); + + if (apps.length === 0) { + return false; // No Android apps found + } + + if (apps.length === 1) { + androidAppSelectRef.current(apps[0], project, needsIosConfig); + } else { + setPhase('select_android_app'); + } + return true; + }, + [downloader], + ); + + // Fetch and handle iOS apps for a project + const fetchAndHandleIosApps = useCallback( + async (project: FirebaseProject) => { + const apps = await downloader.listIosApps(project.projectId); + setIosApps(apps); + + if (apps.length === 0) { + return false; // No iOS apps found + } + + if (apps.length === 1) { + iosAppSelectRef.current(apps[0], project); + } else { + setPhase('select_ios_app'); + } + return true; + }, + [downloader], + ); + + // Handle project selection + const handleProjectSelect = useCallback( + async (project: FirebaseProject) => { + setSelectedProject(project); + const { needsAndroidConfig, needsIosConfig } = getPlatformNeeds(); + + try { + if (needsAndroidConfig) { + const hasAndroidApps = await fetchAndHandleAndroidApps(project, needsIosConfig); + if (!hasAndroidApps) { + // No Android apps, try iOS if needed + if (needsIosConfig) { + const hasIosApps = await fetchAndHandleIosApps(project); + if (!hasIosApps) { + setError('No apps found in this Firebase project.'); + setPhase('error'); + } + } else { + setError('No Android apps found in this Firebase project.'); + setPhase('error'); + } + } + } else if (needsIosConfig) { + const hasIosApps = await fetchAndHandleIosApps(project); + if (!hasIosApps) { + setError('No iOS apps found in this Firebase project.'); + setPhase('error'); + } + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch apps'); + setPhase('error'); + } + }, + [getPlatformNeeds, fetchAndHandleAndroidApps, fetchAndHandleIosApps], + ); + + // Use ref for project select handler + const projectSelectRef = useRef(handleProjectSelect); + projectSelectRef.current = handleProjectSelect; + + // Handle download authentication + const handleDownload = useCallback(async () => { + setPhase('authenticating'); + + try { + // Check if already authenticated + const isAuth = await downloader.isAuthenticated(); + + if (!isAuth) { + // Start OAuth flow + const success = await downloader.authenticate(openBrowser); + if (!success) { + setError('Authentication failed. Please try again.'); + setPhase('error'); + return; + } + } + + // Fetch projects + const fetchedProjects = await downloader.listProjects(); + if (fetchedProjects.length === 0) { + setError('No Firebase projects found for this account.'); + setPhase('error'); + return; + } + + setProjects(fetchedProjects); + + // Auto-select if only one project + if (fetchedProjects.length === 1) { + projectSelectRef.current(fetchedProjects[0]); + } else { + setPhase('select_project'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Authentication failed'); + setPhase('error'); + } + }, [downloader]); + + const handleAction = useCallback( + async (action: CredentialAction) => { + switch (action.type) { + case 'download': + await handleDownload(); + break; + + case 'redetect': + service.clearCache(); + setPhase('detecting'); + break; + + case 'redetect_platform': + service.clearCache(); + setValidatingPlatform(action.platform); + setPhase('detecting'); + break; + + case 'validate': + setValidatingPlatform(action.platform); + setPhase('validating'); + // Re-detect to validate + try { + const newResult = await service.detect(true); + setResult(newResult); + setPhase('status'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Validation failed'); + setPhase('error'); + } + break; + + case 'help': { + const url = FIREBASE_HELP_URLS[action.topic]; + openBrowser(url); + break; + } + + case 'skip': + handleSkip(); + break; + + case 'done': + setPhase('complete'); + onComplete({ + completed: true, + skipped: false, + detection: result, + }); + break; + } + }, + [service, handleSkip, handleDownload, onComplete, result], + ); + + const handleRetry = useCallback(() => { + setError(null); + setPhase('detecting'); + }, []); + + const handleCancel = useCallback(() => { + if (onCancel) { + onCancel(); + } else { + handleSkip(); + } + }, [onCancel, handleSkip]); + + switch (phase) { + case 'detecting': + return ; + + case 'status': + if (!result) { + return ; + } + return ; + + case 'menu': + if (!result) { + return ; + } + return ; + + case 'validating': + return ; + + case 'authenticating': + return ; + + case 'select_project': + return ( + + ); + + case 'select_android_app': + return ( + + selectedProject && + handleAndroidAppSelect( + app as AndroidApp, + selectedProject, + (result?.platform === 'ios' || + result?.platform === 'react-native' || + result?.platform === 'flutter') && + !result?.ios?.valid, + ) + } + onCancel={handleCancel} + /> + ); + + case 'select_ios_app': + return ( + selectedProject && handleIosAppSelect(app as IosApp, selectedProject)} + onCancel={handleCancel} + /> + ); + + case 'downloading': + return ; + + case 'error': + return ( + + ); + + case 'complete': + return ; + + default: + return ; + } +};