diff --git a/BOUNTY.md b/BOUNTY.md index f88f4a5c09..ab08e12408 100644 --- a/BOUNTY.md +++ b/BOUNTY.md @@ -25,6 +25,9 @@ Anyone from the community can review the pull request and leave comments. +Review are rewarded with a tip of $20 when requested on merged pull request. +AI review does not qualify. + ## What is a good review? Check code pattern repetition, and things that can be done better. diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 01d6499035..0000000000 --- a/SECURITY.md +++ /dev/null @@ -1,34 +0,0 @@ -# Security - -Thanks for helping keep Capgo safe. - -## Report a vulnerability for this repository - -Do not use Discord, GitHub Issues, or any public forum. - -Open a private advisory here: -https://github.com/Cap-go/capgo.app/security/advisories/new - -Before you file, use the Capgo advisory checklist: -https://github.com/Cap-go/.github/blob/main/ADVISORY_TEMPLATE.md - -## Org policy (canonical) - -Reporting requirements, out-of-scope rules, what happens after you report, embargo, and bounty payout gates live in the Cap-go org security policy: -https://github.com/Cap-go/.github/blob/main/SECURITY.md - -Public researcher pages: -- https://capgo.app/security/ -- https://capgo.app/bug-bounty/ - -## Quick out-of-scope reminders - -Reports in these classes are closed (see org policy and https://capgo.app/security/ for the full list): - -- Unauthenticated `channel_self` set, and designed no-API-key behavior for `/updates` and `/stats` -- Uploader mislabeling encryption on `external_url` bundles -- Duplicates, already-fixed-on-`main` without a new exploit path, incomplete drafts - -## Bounty - -Capgo pays eligible bounties only after the fix is **released** and you have **verified** the fix. Linking or opening a PR alone is not enough. See https://capgo.app/bug-bounty/. diff --git a/bun.lock b/bun.lock index 8943914566..3f46dd4e45 100644 --- a/bun.lock +++ b/bun.lock @@ -206,13 +206,13 @@ "vue-tsc": "3.3.11", "wrangler": "^4.113.0", "yaml": "^2.9.0", - "zod": "4.5.4", + "zod": "^4.4.3", "zod-compiler": "^1.15.0", }, }, "cli": { "name": "@capgo/cli", - "version": "8.53.0", + "version": "8.47.1", "bin": { "capgo": "dist/index.js", }, @@ -235,7 +235,7 @@ "rrweb-snapshot": "^2.1.1", "string-width": "^8.2.2", "typescript": "6.0.3", - "zod": "^4.5.4", + "zod": "^4.4.3", }, "devDependencies": { "@antfu/eslint-config": "^9.2.0", @@ -260,7 +260,6 @@ "@types/ws": "^8.18.1", "@typescript/native-preview": "7.0.0-dev.20260707.2", "@vercel/ncc": "^0.44.1", - "@vue/compiler-dom": "3.5.40", "@xterm/headless": "^6.0.0", "adm-zip": "^0.6.0", "ci-info": "^4.4.0", @@ -286,7 +285,7 @@ }, "packages/capacitor-notifications": { "name": "@capgo/capacitor-notifications", - "version": "0.1.15", + "version": "0.1.13", "devDependencies": { "@capacitor/android": "^8.4.2", "@capacitor/ios": "^8.4.2", @@ -2805,7 +2804,7 @@ "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], - "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-compiler": ["zod-compiler@1.15.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.16.0", "get-tsconfig": "^4.14.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "picomatch": "^4.0.4", "unplugin": "^3.0.0" }, "peerDependencies": { "@swc/core": ">=1.3.0", "zod": "^4.0.0" }, "optionalPeers": ["@swc/core"], "bin": { "zod-compiler": "dist/cli/index.js" } }, "sha512-wdPJxfow9p4SfCG0hLvnAZOjzseB+FGJViS/6Alc9nAg4dCdDQMcG5kCNdeLeB//wGE0E+r9AEYs1xi/1DZdZg=="], @@ -2839,8 +2838,6 @@ "@capacitor/cli/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], - "@capgo/cli/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], - "@codspeed/core/find-up": ["find-up@6.3.0", "", { "dependencies": { "locate-path": "^7.1.0", "path-exists": "^5.0.0" } }, "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw=="], "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], @@ -2899,12 +2896,6 @@ "@keyv/bigmap/keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], - "@modelcontextprotocol/client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - - "@modelcontextprotocol/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - - "@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@oxc-parser/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], "@oxc-parser/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], @@ -3123,8 +3114,6 @@ "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - "knip/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "make-asynchronous/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 4d67934075..59fa874a8c 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -48,13 +48,3 @@ To reduce CI failures, run the relevant local checks after finishing a task. - Do not treat backend E2E as a blocker to add in this repo unless the task specifically requires coordinating with the Capgo repo. This is critical to prevent hardcoded build paths or MCP regressions from reaching customers. - -## Security (do not regress) - -Canonical researcher policy: https://github.com/Cap-go/.github/blob/main/SECURITY.md and https://capgo.app/security/. - -- Do **not** put GHSA ids or unpublished advisory/PoC text in public PRs, issues, or changelogs. -- Zip / bundle extract and write paths: canonicalize and keep writes inside the intended root. Do not follow symlinks out of the target directory. -- Treat project-controlled config (`localApi`, `localSupa`, app/id paths, custom endpoints) as untrusted for filesystem and network side effects. -- Prefer containment checks before delete or overwrite of paths derived from user/project input. -- Report and fix CLI security issues via private advisories: https://github.com/Cap-go/capgo.app/security/advisories/new diff --git a/cli/README.md b/cli/README.md index 3534164619..f0f812cc15 100644 --- a/cli/README.md +++ b/cli/README.md @@ -70,12 +70,6 @@ For an app that is already configured, upload a new bundle with: npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production ``` -Cordova projects without `capacitor.config.*`: - -```bash -npx @capgo/cli@latest bundle upload com.example.app --mode cordova --path www --channel production -``` - ## CI Upload Example ```bash @@ -148,7 +142,6 @@ Capgo continues to load the root config while writing only the selected source. - [Add](#app-add) - [Delete](#app-delete) - [List](#app-list) - - [Todo](#app-todo) - [Debug](#app-debug) - [Setting](#app-setting) - [Set](#app-set) @@ -163,7 +156,7 @@ Capgo continues to load the root config while writing only the selected source. - [Create](#key-create) - [Delete_old](#key-delete_old) - 👤 [Account](#account) - - [Whoami](#account-whoami) + - [Id](#account-id) - 🔹 [Organization](#organization) - [List](#organization-list) - [Add](#organization-add) @@ -391,7 +384,6 @@ npx @capgo/cli@latest bundle upload Version must be > 0.0.0 and unique. Deleted versions cannot be reused for security. External option: Store only a URL link (useful for apps >200MB or privacy requirements). Capgo never inspects external content. Add encryption for trustless security. -Cordova example: npx @capgo/cli@latest bundle upload com.example.app --mode cordova --path www --channel production **Example:** @@ -404,8 +396,7 @@ npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel prod | Param | Type | Description | | -------------- | ------------- | -------------------- | | **-a** | string | API key to link to your account | -| **--mode** | string | Project framework mode. Use cordova for Cordova apps without capacitor.config (webDir defaults to www) | -| **-p** | string | Path of the folder to upload, if not provided it will use the webDir set in capacitor.config (or www with --mode cordova) | +| **-p** | string | Path of the folder to upload, if not provided it will use the webDir set in capacitor.config | | **-c** | string | Channel to link to. Use commas for multiple channels, for example production,beta | | **--rollout** | string | Set the uploaded bundle as this channel's rollout target at a percentage from 0 to 100 | | **--rollout-percentage-bps** | string | Set the uploaded bundle rollout percentage in basis points from 0 to 10000 | @@ -752,31 +743,6 @@ npx @capgo/cli@latest app list | **--supa-host** | string | Custom Supabase host URL (for self-hosting or Capgo development) | | **--supa-anon** | string | Custom Supabase anon key (for self-hosting) | -### 🔹 **Todo** - -**Alias:** `todoList` - -```bash -npx @capgo/cli@latest app todo -``` - -📋 Show your app's onboarding todo list with done, skipped, and pending tasks. -Uses the same live progress checks as the Capgo dashboard. The app ID can be inferred from your Capacitor project. - -**Example:** - -```bash -npx @capgo/cli@latest app todo com.example.app -``` - -**Options:** - -| Param | Type | Description | -| -------------- | ------------- | -------------------- | -| **-a** | string | API key to link to your account | -| **--supa-host** | string | Custom Supabase host URL (for self-hosting or Capgo development) | -| **--supa-anon** | string | Custom Supabase anon key (for self-hosting) | - ### 🐞 **Debug** ```bash @@ -1119,20 +1085,18 @@ npx @capgo/cli@latest key delete_old 👤 Manage your Capgo account details and retrieve information for support or collaboration. -### 🔹 **Whoami** - -**Alias:** `id` +### 🔹 **Id** ```bash -npx @capgo/cli@latest account whoami +npx @capgo/cli@latest account id ``` -🪪 Retrieve your account ID and email address. +🪪 Retrieve your account ID, safe to share for collaboration or support purposes in Discord or other platforms. **Example:** ```bash -npx @capgo/cli@latest account whoami +npx @capgo/cli@latest account id ``` **Options:** diff --git a/cli/SECURITY.md b/cli/SECURITY.md deleted file mode 100644 index 4502a2d89f..0000000000 --- a/cli/SECURITY.md +++ /dev/null @@ -1,38 +0,0 @@ -# Security - -Thanks for helping keep Capgo and `@capgo/cli` safe. - -The live CLI source lives in this monorepo under `cli/` (`@capgo/cli` on npm). The standalone `Cap-go/CLI` repository is archived. - -## Report a vulnerability for this repository - -Do not use Discord, GitHub Issues, or any public forum. - -Open a private advisory on the live monorepo (preferred): -https://github.com/Cap-go/capgo.app/security/advisories/new - -Before you file, follow the Capgo reporting checklist in the org security policy: -https://github.com/Cap-go/.github/blob/main/SECURITY.md - -## Org policy (canonical) - -Reporting requirements, out-of-scope rules, what happens after you report, embargo, and bounty payout gates live in the Cap-go org security policy: -https://github.com/Cap-go/.github/blob/main/SECURITY.md - -Public researcher pages: -- https://capgo.app/security/ -- https://capgo.app/bug-bounty/ - -Paid open-source bounty eligibility is listed on https://capgo.app/bug-bounty/ (primarily the Capgo landing/website and `@capgo/capacitor-updater`). Always report CLI security issues privately here even when a cash bounty does not apply. - -## Quick out-of-scope reminders - -Reports in these classes are closed (see org policy and https://capgo.app/security/ for the full list): - -- Unauthenticated `channel_self` set, and designed no-API-key behavior for `/updates` and `/stats` -- Uploader mislabeling encryption on `external_url` bundles -- Duplicates, already-fixed-on-`main` without a new exploit path, incomplete drafts - -## Bounty - -When a bounty applies, Capgo pays only after the fix is **released** and you have **verified** the fix. Linking or opening a PR alone is not enough. See https://capgo.app/bug-bounty/. diff --git a/cli/build.mjs b/cli/build.mjs index 3f20f11b9f..a90cec6ce9 100644 --- a/cli/build.mjs +++ b/cli/build.mjs @@ -311,10 +311,10 @@ const fixCapacitorCliDirname = { // Build CLI const buildCLI = Bun.build({ - entrypoints: ['src/index.ts', 'src/onboarding-worker.ts', 'src/notify-app-ready-worker.ts', 'src/updater-installed-worker.ts'], + entrypoints: ['src/index.ts'], target: 'node', outdir: 'dist', - external: [...EXTERNAL_PACKAGES, 'typescript'], + external: EXTERNAL_PACKAGES, sourcemap: env.NODE_ENV === 'development' ? 'linked' : 'none', minify: true, // Keep env access runtime-only unless explicitly defined below. diff --git a/cli/package.json b/cli/package.json index 259c38ce20..ee0fb4d684 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@capgo/cli", "type": "module", - "version": "8.64.1", + "version": "8.51.2", "description": "A CLI to upload to capgo servers", "author": "Martin martin@capgo.app", "license": "Apache 2.0", @@ -64,7 +64,6 @@ "lint:fix": "oxlint --config ../.oxlintrc.json --fix src", "check-posix-paths": "node test/check-posix-paths.js", "generate-docs": "node dist/index.js generate-docs README.md", - "test:notify-app-ready-background": "bun test ./test/test-notify-app-ready-background.mjs ./test/test-background-check-shutdown.mjs", "test:bundle": "bun test/test-bundle.mjs", "test:bundle-validation": "bun test test/bundle/", "test:prescan": "bun test test/prescan/", @@ -90,7 +89,6 @@ "test:ci-prompts": "bun test/test-ci-prompts.mjs", "test:ci-secrets": "bun test/test-ci-secrets.mjs", "test:android-onboarding-progress": "bun test/test-android-onboarding-progress.mjs", - "test:android-keystore-action": "bun test/test-android-keystore-action.mjs", "test:onboarding-telemetry": "bun test/test-onboarding-telemetry.mjs", "test:v2-event-migration": "bun test/test-v2-event-migration.mjs", "test:analytics": "bun test/test-analytics.mjs", @@ -101,7 +99,6 @@ "test:analytics-org-resolver": "bun test/test-analytics-org-resolver.mjs", "test:supabase-perf": "bun test/test-supabase-perf.mjs", "test:preview-qr": "bun test/test-preview-qr.mjs", - "test:bundle-pages": "bun test test/test-bundle-pages.test.ts", "test:mcp-analytics": "bun test/test-mcp-analytics.mjs", "test:mcp-instructions": "bun test/test-mcp-instructions.mjs", "test:mcp-stdout-guard": "bun test/test-mcp-stdout-guard.mjs", @@ -116,9 +113,7 @@ "test:mcp-build-tools": "bun test/test-mcp-build-tools.mjs", "test:app-created-source": "bun test/test-app-created-source.mjs", "test:app-add-exists": "bun test/test-app-add-exists.mjs", - "test:app-add-getting-started": "bun test/test-app-add-getting-started.mjs", "test:app-list-output-text": "bun test/test-app-list-output-text.mjs", - "test:app-todo": "bun test/test-app-todo.mjs", "test:doctor-analytics": "bun test/test-doctor-analytics.mjs", "test:posthog-exception": "bun test/test-posthog-exception.mjs", "test:cli-recovery": "bun test/test-cli-recovery.mjs", @@ -135,12 +130,10 @@ "test:init-replay": "bun test/test-init-replay.mjs", "test:init-telemetry": "bun test/test-init-telemetry.mjs", "test:capacitor-config-target": "bun test/test-capacitor-config-target.mjs", - "test:builder-app-id-config": "bun test/test-builder-app-id-config.mjs", "test:capacitor-config-typescript7": "bun test/test-capacitor-config-typescript7.mjs", "test:capacitor-config-native-import": "bun test/test-capacitor-config-native-import.mjs", "test:cli-user-error-config": "bun test/test-cli-user-error-config.mjs", - "test:cordova-upload-mode": "bun test/test-cordova-upload-mode.mjs", - "test:init-monorepo-targeting": "bun run test:capacitor-config-target && bun run test:builder-app-id-config && bun run test:capacitor-config-typescript7 && bun run test:capacitor-config-native-import && bun run test:cli-user-error-config && bun test/test-init-monorepo-targeting.mjs", + "test:init-monorepo-targeting": "bun run test:capacitor-config-target && bun run test:capacitor-config-typescript7 && bun run test:capacitor-config-native-import && bun run test:cli-user-error-config && bun test/test-init-monorepo-targeting.mjs", "test:prompt-preferences": "bun test/test-prompt-preferences.mjs", "test:esm-sdk": "node test/test-sdk-esm.mjs", "test:auth-session": "bun test/test-auth-session.mjs", @@ -170,8 +163,6 @@ "test:frame-fit-ios-shared": "bun test/test-frame-fit-ios-shared.mjs", "test:ios-confirm-app-id": "bun test/test-ios-confirm-app-id.mjs", "test:ios-create-new": "bun test/test-ios-create-new.mjs", - "test:ios-certificate-action": "bun test/test-ios-certificate-action.mjs", - "test:ios-credential-action": "bun test/test-ios-credential-action.mjs", "test:ios-e2e": "bun test/test-ios-e2e.mjs", "test:ios-flow-contract": "bun test/test-ios-flow-contract.mjs", "test:ios-import-discovery": "bun test/test-ios-import-discovery.mjs", @@ -190,11 +181,9 @@ "test:android-version": "bun test/test-android-version.mjs", "test:platform-flow-contract": "bun test/test-platform-flow-contract.mjs", "test:tail-engine-shared": "bun test/test-tail-engine-shared.mjs", - "test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:notify-app-ready-background && bun run test:bundle-validation && bun run test:functional && bun run test:semver && bun run test:auto-bump-version && bun run test:auto-bump-ai-diff && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:cordova-upload-mode && bun run test:fail-on-incompatible && bun run test:native-dependencies && bun run test:package-json-guard && bun run test:credentials && bun run test:credentials-export && bun run test:ios-provisioning-map && bun run test:ios-provisioning-command && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:cli-help && bun run test:build-cache-payload && bun run test:build-cancellation && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:android-keystore-action && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:cli-headers && bun run test:min-cli-version && bun run test:authenticated-command-invocation && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:bundle-pages && bun run test:app-set-options && bun run test:mcp-analytics && bun run test:mcp-instructions && bun run test:mcp-live-update-onboarding && bun run test:mcp-stdout-guard && bun run test:mcp-platform-select && bun run test:mcp-explain-scopes && bun run test:mcp-oauth-reopen && bun run test:mcp-broker-oauth && bun run test:mcp-broker-session && bun run test:mcp-credentials-manage && bun run test:mcp-resume-prompt && bun run test:mcp-build-job && bun run test:mcp-build-tools && bun run test:app-created-source && bun run test:app-add-exists && bun run test:app-add-getting-started && bun run test:app-list-output-text && bun run test:app-todo && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:cli-recovery && bun run test:create-supabase-client && bun run test:build-platform-selection && bun run test:builder-project-discovery && bun run test:builder-app-selection && bun run test:builder-login-gate && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-monorepo-targeting && bun run test:init-app-conflict && bun run test:channel-list && bun run test:channel-add-exists && bun run test:wait-log && bun run test:init-guardrails && bun run test:init-upload-recovery && bun run test:init-replay && bun run test:init-telemetry && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:mcp-no-key-handshake && bun run test:auth-session && bun run test:version-detection && bun run test:platform-paths && bun run test:project-type-detection && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:asc-key-protocol && bun run test:apple-api-import-helpers && bun run test:apple-api-verify-key && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:cicd-failure-help && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-upload-prompt && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt && bun run test:apple-api-cert-create && bun run test:android-tail-engine && bun run test:android-tail-render && bun run test:android-tail-routing && bun run test:dev-gate-stripped && bun run test:frame-fit-ios-shared && bun run test:ios-confirm-app-id && bun run test:ios-create-new && bun run test:ios-certificate-action && bun run test:ios-credential-action && bun run test:ios-e2e && bun run test:ios-flow-contract && bun run test:ios-import-discovery && bun run test:ios-import-export && bun run test:ios-import-pickers && bun run test:ios-import-recovery && bun run test:ios-recovery && bun run test:ios-resume && bun run test:ios-tail-handoff && bun run test:ios-tui-render && bun run test:p8-error && bun run test:ios-tui-routing && bun run test:ios-updater-sync-validation && bun run test:ios-verify-app && bun run test:ios-marketing-version && bun run test:android-version && bun run test:platform-flow-contract && bun run test:tail-engine-shared && bun run test:prescan && bun run test:android-reporting-api && bun run test:android-gcp && bun run test:android-app-verification && bun run test:android-rename && bun run test:appflow-auth && bun run test:appflow-api-map && bun run test:appflow-validate && bun run test:appflow-flow && bun run test:appflow-gapfill && bun run test:appflow-engine && bun run test:appflow-tail && bun run test:appflow-fetch && bun run test:appflow-sa-decode && bun run test:app-permission-helper && bun run test:2fa-compliance-network && bun run test:organization-set-api-host && bun run test:trial-warning && bun run test:plan-validation", + "test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:bundle-validation && bun run test:functional && bun run test:semver && bun run test:auto-bump-version && bun run test:auto-bump-ai-diff && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:native-dependencies && bun run test:package-json-guard && bun run test:credentials && bun run test:credentials-export && bun run test:ios-provisioning-map && bun run test:ios-provisioning-command && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:cli-help && bun run test:build-cache-payload && bun run test:build-cancellation && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:cli-headers && bun run test:min-cli-version && bun run test:authenticated-command-invocation && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:app-set-options && bun run test:mcp-analytics && bun run test:mcp-instructions && bun run test:mcp-live-update-onboarding && bun run test:mcp-stdout-guard && bun run test:mcp-platform-select && bun run test:mcp-explain-scopes && bun run test:mcp-oauth-reopen && bun run test:mcp-broker-oauth && bun run test:mcp-broker-session && bun run test:mcp-credentials-manage && bun run test:mcp-resume-prompt && bun run test:mcp-build-job && bun run test:mcp-build-tools && bun run test:app-created-source && bun run test:app-add-exists && bun run test:app-list-output-text && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:cli-recovery && bun run test:create-supabase-client && bun run test:build-platform-selection && bun run test:builder-project-discovery && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-monorepo-targeting && bun run test:init-app-conflict && bun run test:channel-list && bun run test:channel-add-exists && bun run test:wait-log && bun run test:init-guardrails && bun run test:init-upload-recovery && bun run test:init-replay && bun run test:init-telemetry && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:mcp-no-key-handshake && bun run test:auth-session && bun run test:version-detection && bun run test:platform-paths && bun run test:project-type-detection && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:asc-key-protocol && bun run test:apple-api-import-helpers && bun run test:apple-api-verify-key && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:cicd-failure-help && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-upload-prompt && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt && bun run test:apple-api-cert-create && bun run test:android-tail-engine && bun run test:android-tail-render && bun run test:android-tail-routing && bun run test:dev-gate-stripped && bun run test:frame-fit-ios-shared && bun run test:ios-confirm-app-id && bun run test:ios-create-new && bun run test:ios-e2e && bun run test:ios-flow-contract && bun run test:ios-import-discovery && bun run test:ios-import-export && bun run test:ios-import-pickers && bun run test:ios-import-recovery && bun run test:ios-recovery && bun run test:ios-resume && bun run test:ios-tail-handoff && bun run test:ios-tui-render && bun run test:p8-error && bun run test:ios-tui-routing && bun run test:ios-updater-sync-validation && bun run test:ios-verify-app && bun run test:ios-marketing-version && bun run test:android-version && bun run test:platform-flow-contract && bun run test:tail-engine-shared && bun run test:prescan && bun run test:android-reporting-api && bun run test:android-gcp && bun run test:android-app-verification && bun run test:android-rename && bun run test:appflow-auth && bun run test:appflow-api-map && bun run test:appflow-validate && bun run test:appflow-flow && bun run test:appflow-gapfill && bun run test:appflow-engine && bun run test:appflow-tail && bun run test:appflow-fetch && bun run test:appflow-sa-decode && bun run test:app-permission-helper && bun run test:2fa-compliance-network && bun run test:organization-set-api-host && bun run test:trial-warning && bun run test:plan-validation", "test:build-platform-selection": "bun test/test-build-platform-selection.mjs", "test:builder-project-discovery": "bun test/test-builder-project-discovery.mjs", - "test:builder-app-selection": "bun test/test-builder-app-selection.mjs", - "test:builder-login-gate": "bun test/test-builder-login-gate.mjs", "test:ai-log-capture": "bun test/test-ai-log-capture.mjs", "test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs", "test:cicd-failure-help": "bun test/test-cicd-failure-help.mjs", @@ -265,7 +254,7 @@ "rrweb-snapshot": "^2.1.1", "string-width": "^8.2.2", "typescript": "6.0.3", - "zod": "^4.5.4" + "zod": "^4.4.3" }, "optionalDependencies": { "@capgo/cli-helper-darwin-arm64": "^1.1.1", @@ -295,7 +284,6 @@ "@types/ws": "^8.18.1", "@typescript/native-preview": "7.0.0-dev.20260707.2", "@vercel/ncc": "^0.44.1", - "@vue/compiler-dom": "3.5.40", "@xterm/headless": "^6.0.0", "adm-zip": "^0.6.0", "ci-info": "^4.4.0", diff --git a/cli/skills/organization-management/SKILL.md b/cli/skills/organization-management/SKILL.md index ef4db20535..ebd0470895 100644 --- a/cli/skills/organization-management/SKILL.md +++ b/cli/skills/organization-management/SKILL.md @@ -9,11 +9,10 @@ Use this skill for account and organization administration commands. ## Account command -### `account whoami` +### `account id` -- Example: `npx @capgo/cli@latest account whoami` -- Alias: `account id`. -- Displays the account ID and email associated with the API key. +- Example: `npx @capgo/cli@latest account id` +- Use to retrieve an account ID that is safe to share for collaboration or support. - Key option: - `-a, --apikey ` diff --git a/cli/skills/release-management/SKILL.md b/cli/skills/release-management/SKILL.md index 9a4a0456b3..33c9590e08 100644 --- a/cli/skills/release-management/SKILL.md +++ b/cli/skills/release-management/SKILL.md @@ -38,7 +38,6 @@ Use this skill for OTA update workflows in Capgo Cloud. - Alias: `u` - Example: `npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production,beta` -- Cordova example: `npx @capgo/cli@latest bundle upload com.example.app --mode cordova --path www --channel production` - Progressive rollout example: `npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production --rollout 10` - Advance an existing rollout: `npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production --rollout-advance` - Key behavior: @@ -53,7 +52,6 @@ Use this skill for OTA update workflows in Capgo Cloud. - Use `--qr-preview` to print a terminal QR code for the uploaded bundle after a successful upload. App preview must be enabled first. - Use `--send-update-notification` to queue native update-check notifications for channels whose linked bundle changed. Native notifications and push update notifications must be enabled for the app. - Important options: - - `--mode ` (`cordova` for Cordova apps without `capacitor.config`; webDir defaults to `www`) - `-p, --path ` - `-c, --channel ` - `--rollout ` diff --git a/cli/skills/usage/SKILL.md b/cli/skills/usage/SKILL.md index c5cc8bf3c2..cc4a8d5f73 100644 --- a/cli/skills/usage/SKILL.md +++ b/cli/skills/usage/SKILL.md @@ -19,13 +19,6 @@ TanStack Intent skills should stay focused and under the validator line limit, s - Prefer `npx @capgo/cli@latest ...` in user-facing examples in this repo. - Many commands can infer `appId` and related config from the current Capacitor project. -- Commands inside an identifiable Capacitor project can automatically complete the Add Integration Code onboarding task when a source call to `CapacitorUpdater.notifyAppReady()` is detected. Detection is best effort and may be abandoned when the command exits. It does not confirm runtime readiness. -- A separate background check can complete Install Updater Plugin when `@capgo/capacitor-updater` is declared in the selected app's package.json and installed locally, including hoisted or symlinked workspace dependencies. Missing dependencies leave existing progress untouched; detection may be abandoned when the command exits. -- After supported interactive app, bundle, channel, organization, and key commands, plus `build request`, `login`, `doctor`, and `get-qr`, finish, pending background checks share a wait of up to five seconds. The CLI prints a waiting message and can exit sooner after the checks finish; pressing Ctrl-C during the wait exits immediately. JSON, output-text, quiet, CI, piped, `init`, `build init`, and MCP runs do not add this wait or message. `account whoami` (and its `account id` alias) and `bundle releaseType` also exit without waiting. -- With analytics enabled, source scans emit `scan_started` and `scan_ended` events in the `notify-app-ready` channel with a shared `attempt_id`. The ended event includes scan duration, result, and todo-report outcome. An abandoned scan may have no ended event. -- With analytics enabled, displaying the waiting message emits `background_checks_wait_started` in the `cli-usage` channel. Its event properties include the command path, pending check count, grace period, and pending `scan_attempt_ids`. Telemetry shares the five-second wait budget and respects `CAPGO_DISABLE_TELEMETRY` and `CAPGO_DISABLE_POSTHOG`. -- Updater installation checks use the same scan events and attempt pairing in the `updater-installed` channel, with a separate attempt ID from the source scan. Telemetry opt-out does not prevent either onboarding check. -- Background onboarding requests trust the default Capgo API origin. For a custom API, explicitly select the self-host with `--supa-host` and `--supa-anon` where supported, or list trusted URL origins (including scheme and port) in `CAPGO_TRUSTED_API_ORIGINS`, separated by commas. Remote hosts require HTTPS; HTTP is permitted only for trusted loopback origins. Untrusted destinations and redirects are skipped without sending credentials to another host. - Shared public flags commonly include `-a, --apikey ` and `--verbose` on commands that support verbose output. - `--capacitor-config ` is a global option for dynamic monorepos: Capacitor still loads the active root config, while config-writing commands update the selected app-specific source file. On `mcp`, the target remains active for the server lifetime so config-writing MCP tools use the same source. @@ -47,7 +40,6 @@ TanStack Intent skills should stay focused and under the validator line limit, s - `app add [appId]`: create an app in Capgo Cloud. - `app list`: list apps under the current account. Pass `--filter-by-org-id ` to list only apps from one organization, `--show-org` to include organization names, and `--show-org-id` to include organization IDs. The CLI warns that the filter can hide other accessible apps. Use `npx @capgo/cli@latest app list --output-text` for plain status text with an embedded CSV app table and no interactive terminal formatting. - `app delete [appId]`: remove an app. -- `app todo [appId]` (alias: `app todoList`): show the versioned onboarding checklist with done, skipped, and pending tasks, using the same live progress checks as the dashboard. Supports legacy v3 flat steps and v4 OTA steps under `setup.steps.ota` when `setup.ota_todo_list_version` is the string `"1"`. Skipped tasks count toward completed progress. Live checks can refresh saved OTA milestones; a warning means saved progress is shown for checks that failed. Requires `app.read`; additional checks depend on the key's read permissions. Omit the app ID to infer it from the current Capacitor project. Example: `npx @capgo/cli@latest app todo com.example.app`. Supports `-a, --apikey`, `--supa-host`, and `--supa-anon`. - `app set [appId]`: update app settings such as name, icon, retention, metadata exposure, and preview access with `--preview` or `--no-preview`. - `app setting [path]`: update Capacitor config values programmatically. - `app debug [appId]`: listen for live-update debug events, optionally for one device. @@ -96,7 +88,7 @@ Load `skills/native-builds/SKILL.md` when working with: Load `skills/organization-management/SKILL.md` when working with: -- `account whoami` (alias: `account id`) +- `account id` - `organization list`, `organization add`, `organization members`, `organization set`, `organization delete` - deprecated `organisation` aliases diff --git a/cli/src/analytics/track.ts b/cli/src/analytics/track.ts index 89ea572166..5e91c5d417 100644 --- a/cli/src/analytics/track.ts +++ b/cli/src/analytics/track.ts @@ -3,7 +3,6 @@ import { env } from 'node:process' import pack from '../../package.json' import { isTruthyEnvValue } from '../posthog' import { findSavedKeySilent, getAppId, getConfig, sendEvent } from '../utils' -import { getBuilderAppId } from '../build/app-id' import { resolveOwnerOrgId } from './org-resolver' import { categorizeCliError, categorizeHttpStatus } from './error-category' import { deriveSupabaseOperation, setSupabaseCallRecorder, SLOW_THRESHOLD_MS, withSupabaseSource } from './supabase-perf' @@ -52,20 +51,14 @@ export async function flushAnalytics(timeoutMs = 2000): Promise { // Keyed by apikey so events for different accounts never reuse another's org. const cachedContextByApiKey = new Map>() -export function isBuilderInvocation(commandPath: string): boolean { - return commandPath === 'build' || commandPath.startsWith('build ') - || /^mcp:(?:capgo_builder_|start_capgo_builder_|start_capgo_build$|capgo_build_|cancel_capgo_build$)/u.test(commandPath) -} - -export function resolveTrackingContext(apikey: string, signal?: AbortSignal, builder = false): Promise<{ appId?: string, orgId?: string }> { - const cacheKey = `${apikey}\0${builder ? 'builder' : 'default'}` - const cached = cachedContextByApiKey.get(cacheKey) +export function resolveTrackingContext(apikey: string, signal?: AbortSignal): Promise<{ appId?: string, orgId?: string }> { + const cached = cachedContextByApiKey.get(apikey) if (cached) return cached const promise = (async () => { try { const extConfig = await getConfig(true).catch(() => undefined) - const appId = (builder ? getBuilderAppId(undefined, extConfig?.config) : getAppId('', extConfig?.config)) || undefined + const appId = getAppId('', extConfig?.config) || undefined if (!appId) return {} const orgId = await resolveOwnerOrgId(apikey, appId, {}, signal) @@ -75,7 +68,7 @@ export function resolveTrackingContext(apikey: string, signal?: AbortSignal, bui return {} } })() - cachedContextByApiKey.set(cacheKey, promise) + cachedContextByApiKey.set(apikey, promise) return promise } @@ -88,9 +81,7 @@ export interface TrackEventInput { appId?: string /** Explicit key; falls back to the saved key. No key => no event. */ apikey?: string - timestamp?: Date tags?: Record - nonPersonTags?: Record } /** @@ -117,8 +108,7 @@ export function trackEvent(input: TrackEventInput): Promise { let appId = input.appId let orgId = input.orgId if (appId === undefined && orgId === undefined) { - const commandPath = mcpCommandPathStore.getStore() ?? (typeof input.tags?.command_path === 'string' ? input.tags.command_path : currentCommandPath) - const ctx = await resolveTrackingContext(apikey, controller.signal, isBuilderInvocation(commandPath)) + const ctx = await resolveTrackingContext(apikey, controller.signal) appId = ctx.appId orgId = ctx.orgId } @@ -132,10 +122,8 @@ export function trackEvent(input: TrackEventInput): Promise { channel: input.channel, event: input.event, tracking_version: 2, - ...(input.timestamp ? { timestamp: input.timestamp } : {}), ...(orgId ? { org_id: orgId } : {}), tags, - ...(input.nonPersonTags ? { nonPersonTags: input.nonPersonTags } : {}), }, false, controller.signal).catch(() => {}) } catch { @@ -209,10 +197,10 @@ function emitCommandInvoked(commandPath: string, ctx: CommandContext, apikey?: s }) } -export function trackCommandInvoked(commandPath: string, ctx: CommandContext, apikey?: string): void { +export function trackCommandInvoked(commandPath: string, ctx: CommandContext): void { commandStartedAt = Date.now() currentCommandPath = commandPath - emitCommandInvoked(commandPath, ctx, apikey) + emitCommandInvoked(commandPath, ctx) } export function deferCommandInvocation(commandPath: string, ctx: CommandContext): void { diff --git a/cli/src/api/versions.ts b/cli/src/api/versions.ts index 1c4c848cc2..965f22798c 100644 --- a/cli/src/api/versions.ts +++ b/cli/src/api/versions.ts @@ -10,8 +10,6 @@ interface VersionOptions { apikey?: string supaHost?: string supaAnon?: string - /** Injectable for unit tests; defaults to invokeCapgoCliApi. */ - invoke?: typeof invokeCapgoCliApi } interface DeleteSpecificVersionOptions extends VersionOptions { @@ -32,13 +30,12 @@ async function isEmptyBundleListError(error: unknown) { return payload?.error === 'cannot_get_bundle' && payload?.message === 'Cannot get bundle' } -async function fetchBundlePages(appid: string, options: CapgoHttpOptions & Pick) { - const invoke = options.invoke ?? invokeCapgoCliApi +async function fetchBundlePages(appid: string, options: CapgoHttpOptions) { const all: Database['public']['Tables']['app_versions']['Row'][] = [] let page = 0 while (true) { const params = new URLSearchParams({ app_id: appid, page: String(page) }) - const { data, error } = await invoke( + const { data, error } = await invokeCapgoCliApi( `bundle?${params.toString()}`, { apikey: options.apikey, @@ -49,8 +46,8 @@ async function fetchBundlePages(appid: string, options: CapgoHttpOptions & Pick< }, ) if (error) { - if (await isEmptyBundleListError(error)) - return all + if (page === 0 && await isEmptyBundleListError(error)) + return [] throw error } const batch = Array.isArray(data) ? data : [] @@ -169,7 +166,6 @@ export async function getActiveAppVersions( silent, supaHost: options.supaHost, supaAnon: options.supaAnon, - invoke: options.invoke, }) } catch (vError) { diff --git a/cli/src/app/add.ts b/cli/src/app/add.ts index 5ee5ac897d..b0bb9dabc1 100644 --- a/cli/src/app/add.ts +++ b/cli/src/app/add.ts @@ -18,10 +18,8 @@ import { formatError, getAppId, getCapgoCliHttpStatus, - defaultHostWeb, getConfig, getContentType, - getLocalConfig, getOrganizationWithPermission, invokeCapgoCliApi, resolveCapgoPublicApiHost, @@ -29,33 +27,6 @@ import { sendEvent, } from '../utils' -function normalizeConsoleHost(hostWeb: string): string { - return hostWeb.endsWith('/') ? hostWeb.slice(0, -1) : hostWeb -} - -export function appGettingStartedUrl(appId: string, hostWeb = defaultHostWeb): string { - return `${normalizeConsoleHost(hostWeb)}/app/${appId}/getting-started` -} - -export function formatAppGettingStartedMessage(appId: string, hostWeb = defaultHostWeb): string { - return `Continue setup at ${appGettingStartedUrl(appId, hostWeb)}` -} - -export function shouldPrintAppGettingStartedUrl(hostWeb: string, usesCustomSupabase: boolean): boolean { - return !usesCustomSupabase || normalizeConsoleHost(hostWeb) !== defaultHostWeb -} - -export async function resolveAppGettingStartedMessage( - appId: string, - options: { supaHost?: string, supaAnon?: string } = {}, -): Promise { - const localConfig = await getLocalConfig(true) - const usesCustomSupabase = Boolean(options.supaHost || localConfig.supaHost) - if (!shouldPrintAppGettingStartedUrl(localConfig.hostWeb, usesCustomSupabase)) - return null - return formatAppGettingStartedMessage(appId, localConfig.hostWeb) -} - export const reverseDomainRegex = /^[a-z0-9]+(\.[\w-]+)+$/i function ensureOptions(appId: string, options: AppOptions, silent: boolean) { @@ -442,7 +413,7 @@ export async function addAppInternal( const message = formatError(ownershipError) if (!silent) log.error(`Could not add app ${message}`) - throw new CliUserError(`Could not add app ${message}`) + throw new Error(`Could not add app ${message}`) } if (duplicateOutcome === 'duplicate_owned') { @@ -452,13 +423,13 @@ export async function addAppInternal( const takenMessage = `App ID ${appId} already exists` if (!silent) log.error(`Could not add app: ${takenMessage}`) - throw new CliUserError(`Could not add app: ${takenMessage}`) + throw new Error(`Could not add app: ${takenMessage}`) } else { const message = formatError(error) if (!silent) log.error(`Could not add app ${message}`) - throw new CliUserError(`Could not add app ${message}`) + throw new Error(`Could not add app ${message}`) } } @@ -487,12 +458,8 @@ export async function addAppInternal( if (!silent) { if (appAlreadyExists) log.success(`App ${appId} already exists in Capgo`) - else { + else log.success(`App ${appId} added to Capgo`) - const gettingStartedMessage = await resolveAppGettingStartedMessage(appId, options) - if (gettingStartedMessage) - log.info(gettingStartedMessage) - } log.info(`This app is accessible to all members of your organization based on their permissions`) log.info(`Next step: upload a bundle with "npx @capgo/cli bundle upload ${appId}"`) outro('Done ✅') diff --git a/cli/src/app/todo.ts b/cli/src/app/todo.ts deleted file mode 100644 index d9d37cf242..0000000000 --- a/cli/src/app/todo.ts +++ /dev/null @@ -1,282 +0,0 @@ -import type { OptionsBase } from '../schemas/base' -import { env, stdin, stdout } from 'node:process' -import { intro, log, outro, spinner } from '@clack/prompts' -import { check2FAComplianceForApp } from '../api/app' -import { getPendingOnboardingChecks } from '../onboarding/background-workers' -import { CliUserError } from '../shared/cli-user-error' -import { createSupabaseClient, findSavedKey, formatCapgoCliInvokeError, getAppId, getCapgoCliHttpStatus, getConfig, invokeCapgoCliApi } from '../utils' - -const V2_STEP_IDS = [ - 'login_cli_mcp', 'add_channel', 'add_updater', 'add_code', 'add_encryption', - 'select_platform', 'build_project', 'run_device', 'add_code_change', - 'upload_bundle', 'test_update', 'completion', -] as const - -const V3_STEP_IDS = [ - 'login_cli_mcp', 'add_channel', 'add_updater', 'add_code', - 'run_device', 'upload_bundle', 'test_update', -] as const - -const STEP_TITLES = { - add_app: 'Add your app', - login_cli_mcp: 'Log in to the Capgo CLI/MCP', - add_channel: 'Create a channel', - add_updater: 'Install updater plugin', - add_code: 'Add integration code', - add_encryption: 'Setup encryption', - select_platform: 'Select platform', - build_project: 'Build your project', - run_device: 'Run on device', - add_code_change: 'Make a test change', - upload_bundle: 'Upload bundle', - test_update: 'Test update on device', - completion: 'Completion', -} - -const V3_STEP_TITLES: Record = { - login_cli_mcp: 'Start guided setup', - add_channel: 'Create a channel', - add_updater: 'Install Capgo Updater', - add_code: 'Add the app-ready code', - run_device: 'Run your app on a device', - upload_bundle: 'Publish your first update', - test_update: 'Deliver an update to a device', -} - -const V3_NEXT_STEP_HELP: Record = { - login_cli_mcp: { - action: 'Run a Capgo CLI command or start the MCP guided setup as the app creator.', - doneWhen: 'Capgo records that CLI or MCP activity for the app creator.', - }, - add_channel: { - action: 'Create a channel for this app to receive live updates.', - doneWhen: 'Capgo finds a channel for this app.', - }, - add_updater: { - action: 'Install @capgo/capacitor-updater in your app project.', - doneWhen: 'The CLI finds the dependency declared and installed, then reports it to Capgo.', - }, - add_code: { - action: 'Call CapacitorUpdater.notifyAppReady() once your app is ready after an update.', - doneWhen: 'The CLI finds that call in your app source and reports it to Capgo.', - }, - run_device: { - action: 'Build and open the app on a device or simulator with Capgo installed.', - doneWhen: 'Capgo sees a device connect to this app.', - }, - upload_bundle: { - action: 'Build and upload your first live update bundle.', - doneWhen: 'Capgo finds a published bundle for this app.', - }, - test_update: { - action: 'Assign the update to a channel, then reopen the app on a device.', - doneWhen: 'Capgo records a device applying an uploaded version.', - }, -} - -export interface AppTodoProgress { - onboarding: unknown - hasChannel?: boolean - checkErrors?: string[] -} - -export const TODO_BACKGROUND_WAIT_MS = 10_000 - -export async function waitForTodoBackgroundChecks( - checks: readonly Promise[], - onCountdown?: (remainingSeconds: number) => void, - timeoutMs = TODO_BACKGROUND_WAIT_MS, -): Promise { - if (!checks.length) - return true - - const deadline = Date.now() + timeoutMs - let timer: ReturnType | undefined - let countdown: ReturnType | undefined - let remaining = Math.ceil(timeoutMs / 1_000) - onCountdown?.(remaining) - if (onCountdown) { - countdown = setInterval(() => { - const next = Math.max(1, Math.ceil((deadline - Date.now()) / 1_000)) - if (next !== remaining) { - remaining = next - onCountdown(next) - } - }, 1_000) - } - - try { - return await Promise.race([ - Promise.allSettled(checks).then(() => true), - new Promise((resolve) => { - // Keep the process alive while the background workers remain unreferenced. - timer = setTimeout(() => resolve(false), timeoutMs) - }), - ]) - } - finally { - if (timer) - clearTimeout(timer) - if (countdown) - clearInterval(countdown) - } -} - -function asRecord(value: unknown): Record { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : {} -} - -export function getAppTodoSteps(progress: AppTodoProgress) { - const raw = asRecord(progress.onboarding) - const setup = raw.setup !== null && typeof raw.setup === 'object' && !Array.isArray(raw.setup) - ? asRecord(raw.setup) - : raw - const version = typeof setup.todo_list_version === 'number' && Number.isSafeInteger(setup.todo_list_version) && setup.todo_list_version > 0 - ? setup.todo_list_version - : 2 - const otaV1 = version === 4 && setup.ota_todo_list_version === '1' - // TODO(2027-03-19): Remove v3 flat-step compatibility after existing apps migrate. - const ids: readonly (keyof typeof STEP_TITLES)[] = version === 3 || otaV1 - ? V3_STEP_IDS - : version === 4 ? [] : version === 1 ? ['add_app', ...V2_STEP_IDS.slice(1)] : V2_STEP_IDS - const reportedSteps = otaV1 ? asRecord(asRecord(setup.steps).ota) : asRecord(setup.steps) - const steps = ids.map((id) => { - const reportedStatus = asRecord(reportedSteps[id]).status - let status: 'done' | 'skipped' | 'pending' = reportedStatus === 'done' || reportedStatus === 'skipped' ? reportedStatus : 'pending' - // Match the frontend's live channel override, including deleted channels. - if (id === 'add_channel' && typeof progress.hasChannel === 'boolean') - status = progress.hasChannel ? 'done' : 'pending' - const title = version === 3 || otaV1 ? V3_STEP_TITLES[id as keyof typeof V3_STEP_TITLES] : STEP_TITLES[id] - return { id, title, status } - }) - return { version, steps } -} - -export function formatAppTodoList(appId: string, progress: AppTodoProgress, options: { color?: boolean } = {}): string { - const { version, steps } = getAppTodoSteps(progress) - if (version === 4 && steps.length === 0) - return `App: ${appId} — Todo list v4\nThis CLI does not support this OTA checklist version.` - const done = steps.filter(step => step.status === 'done').length - const skipped = steps.filter(step => step.status === 'skipped').length - const markers = { done: '[x] Done', skipped: '[-] Skipped', pending: '[ ] Pending' } - const colors = { done: '32', skipped: '2', pending: '33' } - const colorize = (value: string, code: string) => options.color ? `\u001B[${code}m${value}\u001B[0m` : value - const next = version === 3 || version === 4 ? steps.find(step => step.status === 'pending') : undefined - const help = next ? V3_NEXT_STEP_HELP[next.id as typeof V3_STEP_IDS[number]] : undefined - return [ - colorize(`App: ${appId} — Todo list v${version}`, '1'), - `${done + skipped}/${steps.length} completed (${done} done, ${skipped} skipped, ${steps.length - done - skipped} pending)`, - '', - ...steps.map(step => `${colorize(markers[step.status], colors[step.status])}: ${step.title}`), - ...(next && help ? [ - '', - colorize(`Next step: ${next.title}`, '1;36'), - ` ${help.action}`, - ` Done when: ${help.doneWhen}`, - ' Run this command again to recheck progress.', - ] : []), - ].join('\n') -} - -export async function readAppTodoProgress(appId: string, options: OptionsBase): Promise { - const { data, error } = await invokeCapgoCliApi('private/onboarding_progress', { - ...options, - body: { appId, N: 0, initial: true }, - signal: AbortSignal.timeout(15_000), - }) - if (error) { - const status = getCapgoCliHttpStatus(error) - if (status === 401 || status === 403) { - throw new CliUserError('Cannot access app todo list. Check that your API key is valid and has app.read permission for this app.', { - appId, requiredPermissionKey: 'app.read', - }) - } - if (status === 404) - throw new CliUserError('App not found.', { appId }) - throw new Error(`Cannot read app todo list: ${await formatCapgoCliInvokeError(error)}`, { cause: error }) - } - if (!data || !Object.hasOwn(data, 'onboarding')) - throw new Error('Cannot read app todo list: invalid progress response') - return data -} - -export async function appTodo(appId: string | undefined, options: Partial) { - // Snapshot before the first API read so a check finishing during that read still triggers a refresh. - const backgroundChecks = [...getPendingOnboardingChecks().values()].map(check => check.completion) - intro('App todo list') - const apikey = options.apikey || findSavedKey() - if (!apikey) { - const message = 'Missing API key. Provide --apikey or log in.' - log.error(message) - throw new CliUserError(message) - } - if (!appId) - appId = getAppId(undefined, (await getConfig()).config) - if (!appId) { - const message = 'Missing appId. Provide an app ID or run this command in a Capacitor project.' - log.error(message) - throw new CliUserError(message) - } - - const supabase = await createSupabaseClient(apikey, options.supaHost, options.supaAnon) - const loading = stdin.isTTY && stdout.isTTY ? spinner() : null - if (loading) - loading.start('Loading the todo list') - else - log.info('Loading the todo list') - - let progress: AppTodoProgress - try { - await check2FAComplianceForApp(supabase, appId) - progress = await readAppTodoProgress(appId, { ...options, apikey }) - loading?.stop('Todo list loaded') - } - catch (error) { - loading?.stop('Could not load todo list') - if (error instanceof CliUserError) - log.error(error.message) - throw error - } - const { version, steps } = getAppTodoSteps(progress) - const checks = version === 3 || (version === 4 && steps.length > 0) ? backgroundChecks : [] - if (checks.length) { - const waiting = stdin.isTTY && stdout.isTTY ? spinner() : null - const waitMessage = (seconds: number) => `Waiting ${seconds} seconds for background TODO list checks to finish` - if (!waiting) - log.info(waitMessage(10)) - let started = false - const finished = await waitForTodoBackgroundChecks(checks, waiting - ? (seconds) => { - if (started) - waiting.message(waitMessage(seconds)) - else { - waiting.start(waitMessage(seconds)) - started = true - } - } - : undefined) - waiting?.stop(finished ? 'Background TODO list checks finished' : 'Finished waiting for background TODO list checks') - - const refreshing = stdin.isTTY && stdout.isTTY ? spinner() : null - if (refreshing) - refreshing.start('Refreshing the todo list') - else - log.info('Refreshing the todo list') - try { - progress = await readAppTodoProgress(appId, { ...options, apikey }) - refreshing?.stop('Todo list refreshed') - } - catch (error) { - refreshing?.stop('Could not refresh todo list') - if (error instanceof CliUserError) - log.error(error.message) - throw error - } - } - if (progress.checkErrors?.length) - log.warn('Some live progress checks failed. Showing saved progress for those tasks; try again to refresh them.') - log.info(formatAppTodoList(appId, progress, { color: !!stdout.isTTY && env.NO_COLOR === undefined })) - outro('Done ✅') -} diff --git a/cli/src/build/app-id.ts b/cli/src/build/app-id.ts deleted file mode 100644 index eaf6f3618b..0000000000 --- a/cli/src/build/app-id.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { CapacitorConfig } from '../config' -import { CliUserError } from '../shared/cli-user-error' -import { getAppId } from '../utils' - -export function hasBuilderAppIdField(config: CapacitorConfig | undefined): boolean { - const builderConfig: unknown = config?.plugins?.CapgoBuilder - return builderConfig !== null && typeof builderConfig === 'object' && Object.hasOwn(builderConfig, 'capgoBuilderAppId') -} - -export function getConfiguredBuilderAppId(config: CapacitorConfig | undefined): string | undefined { - const builderConfig: unknown = config?.plugins?.CapgoBuilder - if (hasBuilderAppIdField(config)) { - const value: unknown = (builderConfig as Record).capgoBuilderAppId - if (typeof value !== 'string' || !value.trim()) - throw new CliUserError('Invalid Capacitor config: plugins.CapgoBuilder.capgoBuilderAppId must be a non-empty string') - return value.trim() - } - return undefined -} - -/** Resolve the Capgo app key for Builder commands only. */ -export function getBuilderAppId( - explicitAppId: string | undefined, - config: CapacitorConfig | undefined, - legacyDefault: 'updater' | 'native' = 'updater', - explicitMode: 'truthy' | 'defined' = 'truthy', -): string | undefined { - if (explicitMode === 'defined' ? explicitAppId !== undefined : Boolean(explicitAppId)) - return explicitAppId - const configuredAppId = getConfiguredBuilderAppId(config) - if (configuredAppId) - return configuredAppId - return legacyDefault === 'native' ? config?.appId : getAppId(undefined, config) -} diff --git a/cli/src/build/credentials-command.ts b/cli/src/build/credentials-command.ts index 149af5541b..07aeadad59 100644 --- a/cli/src/build/credentials-command.ts +++ b/cli/src/build/credentials-command.ts @@ -4,8 +4,7 @@ import { resolve } from 'node:path' import { cwd, exit } from 'node:process' import { log } from '@clack/prompts' import { trackEvent } from '../analytics/track' -import { findSavedKey, getConfig, getOrganizationId, sendEvent } from '../utils' -import { getBuilderAppId } from './app-id' +import { findSavedKey, getAppId, getConfig, getOrganizationId, sendEvent } from '../utils' import { clearSavedCredentials, convertFilesToCredentials, @@ -187,7 +186,7 @@ export async function saveCredentialsCommand(options: SaveCredentialsOptions): P // Try to infer appId from capacitor.config if not provided const extConfig = await getConfig() - const appId = getBuilderAppId(options.appId, extConfig?.config) + const appId = getAppId(options.appId, extConfig?.config) if (!appId) { log.error('❌ App ID is required.') @@ -568,7 +567,7 @@ export async function listCredentialsCommand(options?: { appId?: string, local?: // Try to infer appId from capacitor.config if not provided const extConfig = await getConfig() - const inferredAppId = getBuilderAppId(options?.appId, extConfig?.config) + const inferredAppId = options?.appId || getAppId(undefined, extConfig?.config) // If specific appId is provided or inferred, only show that one const appsToShow = inferredAppId ? [inferredAppId] : allAppIds @@ -660,7 +659,7 @@ export async function clearCredentialsCommand(options: { appId?: string, platfor try { // Try to infer appId from capacitor.config if not explicitly provided const extConfig = await getConfig() - const appId = getBuilderAppId(options.appId, extConfig?.config) + const appId = options.appId || getAppId(undefined, extConfig?.config) const credentialsPath = options.local ? getLocalCredentialsPath() : getGlobalCredentialsPath() if (appId && options.platform) { @@ -750,7 +749,7 @@ export async function updateCredentialsCommand(options: SaveCredentialsOptions): // Try to infer appId from capacitor.config if not provided const extConfig = await getConfig() - const appId = getBuilderAppId(options.appId, extConfig?.config) + const appId = getAppId(options.appId, extConfig?.config) if (!appId) { log.error('❌ App ID is required.') @@ -995,7 +994,7 @@ export async function migrateCredentialsCommand(options: { appId?: string, platf // Try to infer appId from capacitor.config if not provided const extConfig = await getConfig() - const appId = getBuilderAppId(options.appId, extConfig?.config) + const appId = getAppId(options.appId, extConfig?.config) if (!appId) { log.error('❌ App ID is required.') diff --git a/cli/src/build/credentials-manage.ts b/cli/src/build/credentials-manage.ts index 69120cb69e..a500aae279 100644 --- a/cli/src/build/credentials-manage.ts +++ b/cli/src/build/credentials-manage.ts @@ -17,8 +17,7 @@ import { } from '../init/prompts' import { clearInitLogs, setInitScreen, stopInitInkSession } from '../init/runtime' import { trackEvent } from '../analytics/track' -import { getConfig } from '../utils' -import { getBuilderAppId, getConfiguredBuilderAppId } from './app-id' +import { getAppId, getConfig } from '../utils' import { clearSavedCredentials, getGlobalCredentialsPath, @@ -380,13 +379,12 @@ export async function manageCredentialsCommand(options: ManageCredentialsOptions return } - const detected = options.appId ? undefined : await detectAppIdFromCapacitor(entries) - const targetAppId = options.appId ?? detected?.appId + const targetAppId = options.appId ?? (await detectAppIdFromCapacitor(entries)) let detectedFromCapacitor = false if (targetAppId) { const filtered = entries.filter(entry => entry.appId === targetAppId) - if (filtered.length === 0 && (options.appId || detected?.configured)) { - pCancel(`No credentials found for app ${targetAppId}.`) + if (filtered.length === 0 && options.appId) { + pCancel(`No credentials found for app ${options.appId}.`) return } if (filtered.length > 0) { @@ -577,20 +575,17 @@ function describeFieldRow(row: FieldRow): string[] { return lines } -async function detectAppIdFromCapacitor(entries: AppEntry[]): Promise<{ appId: string | undefined, configured: boolean } | undefined> { +async function detectAppIdFromCapacitor(entries: AppEntry[]): Promise { if (entries.length === 0) return undefined - let extConfig: Awaited> | undefined try { - extConfig = await getConfig() + const extConfig = await getConfig() + const inferred = getAppId(undefined, extConfig?.config) + return inferred || undefined } catch { return undefined } - return { - appId: getBuilderAppId(undefined, extConfig?.config) || undefined, - configured: getConfiguredBuilderAppId(extConfig?.config) !== undefined, - } } async function loadEntries(localOnly?: boolean): Promise { diff --git a/cli/src/build/ios-provisioning-command.ts b/cli/src/build/ios-provisioning-command.ts index a216a0a73f..c5bf8099c3 100644 --- a/cli/src/build/ios-provisioning-command.ts +++ b/cli/src/build/ios-provisioning-command.ts @@ -6,8 +6,7 @@ import { existsSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { cwd, exit } from 'node:process' import { confirm, isCancel, log } from '@clack/prompts' -import { canPromptInteractively, formatError, getConfig } from '../utils' -import { getBuilderAppId } from './app-id' +import { canPromptInteractively, formatError, getAppId, getConfig } from '../utils' import { loadSavedCredentials, updateSavedCredentials } from './credentials' import { decodeCredentialBase64 } from './credentials-base64' import { resolveCredentialsStore } from './credentials-store-selection' @@ -253,7 +252,7 @@ export async function runIosProvisioningCommand(options: IosProvisioningOptions, async function loadDefaultProject(): Promise { const { config } = await getConfig(true) - const appId = getBuilderAppId(undefined, config) + const appId = getAppId(undefined, config) if (!appId) throw new Error('The Capacitor project does not define an app id') diff --git a/cli/src/build/needed.ts b/cli/src/build/needed.ts index dc52e94bd1..bcc7250a66 100644 --- a/cli/src/build/needed.ts +++ b/cli/src/build/needed.ts @@ -14,11 +14,11 @@ import { createSupabaseClient, findSavedKey, formatError, + getAppId, getCompatibilityDetails, getConfig, isCompatible, } from '../utils' -import { getBuilderAppId } from './app-id' type VersionChangeType = 'major' | 'minor' | 'patch' | 'prerelease' | 'changed' | 'same' | 'new' | 'removed' @@ -260,7 +260,7 @@ export async function getBuildNeeded( configError = error } - const resolvedAppId = getBuilderAppId(appId, extConfig?.config) + const resolvedAppId = getAppId(appId, extConfig?.config) if (!resolvedAppId) { if (configError instanceof Error) throw configError diff --git a/cli/src/build/onboarding/android/ui/app.tsx b/cli/src/build/onboarding/android/ui/app.tsx index 50d0362cae..1cfd86d235 100644 --- a/cli/src/build/onboarding/android/ui/app.tsx +++ b/cli/src/build/onboarding/android/ui/app.tsx @@ -160,7 +160,6 @@ import { deleteAndroidProgress, getAndroidResumeStep, hasAnyOAuthProgress, loadA import { ANDROID_STEP_PROGRESS, getAndroidPhaseLabel } from '../types.js' import type { AndroidEffectDeps, AndroidInput } from '../flow.js' import { applyAndroidInput, runAndroidEffect } from '../flow.js' -import { trackAndroidKeystorePreparationFailure, trackPreparedAndroidKeystore } from './keystore-action.js' interface LogEntry { text: string, color?: string } @@ -472,7 +471,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir }, [appId, resolvedOrgId, step], ) - const reportedKeystoreSuccessesRef = useRef(new Set()) const [retryCount, setRetryCount] = useState(0) const [retryStep, setRetryStep] = useState(null) @@ -1528,7 +1526,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const keyPw = resolvedKeyPw setKeystoreKeyPassword(keyPw) addLog('✔ Key password set') - let keystorePrepared = false try { const bytes = await readFile(keystoreExistingPath) if (cancelled) @@ -1540,21 +1537,13 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir isGenerated: false, } // _keystoreBase64 / keystoreReady persisted below — no React mirrors (Plan 3.3). - const saved = await persist((p) => ({ + await persist((p) => ({ ...p, keystoreKeyPassword: keyPw, _keystoreBase64: base64, serviceAccountForkSeen: true, completedSteps: { ...p.completedSteps, keystoreReady: ready }, })) - keystorePrepared = trackPreparedAndroidKeystore( - saved, - 'imported', - resolution === 'probed-same' ? 'verified' : 'not_checked', - journeyId, - trackAction, - reportedKeystoreSuccessesRef.current, - ) addLog(`✔ Keystore loaded — ${keystoreExistingPath}`) // Smart-route: skip phases already complete (e.g. on resume into // this step after a legacy progress file already had OAuth steps @@ -1572,18 +1561,8 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir setStep('service-account-method-select') } catch (err) { - if (!cancelled) { - if (!keystorePrepared) { - trackAndroidKeystorePreparationFailure( - 'imported', - resolution === 'probed-same' ? 'verified' : 'not_checked', - 'import_failed', - journeyId, - trackAction, - ) - } + if (!cancelled) handleError(err, 'keystore-existing-path') - } } })() } @@ -1951,7 +1930,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir signal: abort.signal, } - let keystorePrepared = false try { // Run the engine against the freshest persisted progress. Plan 3.1 made // disk progress the source of truth for in-session sequencing; the prior @@ -1968,17 +1946,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const t = result.transient const np = result.progress - if (step === 'keystore-generating') { - keystorePrepared = trackPreparedAndroidKeystore( - np, - 'generated', - 'generated_with_keystore', - journeyId, - trackAction, - reportedKeystoreSuccessesRef.current, - ) - } - // ── Apply transient runtime data to render state ────────────────────── if (t?.detectedPackageIds !== undefined) setDetectedPackageIds(t.detectedPackageIds) @@ -2051,15 +2018,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir catch (err) { if (cancelled) return - if (step === 'keystore-generating' && !keystorePrepared) { - trackAndroidKeystorePreparationFailure( - 'generated', - 'generated_with_keystore', - 'generate_failed', - journeyId, - trackAction, - ) - } // MissingScopesError on google-sign-in is handled INSIDE the engine // (returns next: 'google-sign-in'); any other throw routes through the // same retry/error UX the original effects used. @@ -2783,7 +2741,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir setKeystoreKeyPassword(keyPw) addLog('✔ Key password set') ;(async () => { - let keystorePrepared = false try { const bytes = await readFile(keystoreExistingPath) const base64 = bytes.toString('base64') @@ -2793,21 +2750,13 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir isGenerated: false, } // _keystoreBase64 / keystoreReady persisted below — no React mirrors (Plan 3.3). - const saved = await persist((p) => ({ + await persist((p) => ({ ...p, keystoreKeyPassword: keyPw, _keystoreBase64: base64, serviceAccountForkSeen: true, completedSteps: { ...p.completedSteps, keystoreReady: ready }, })) - keystorePrepared = trackPreparedAndroidKeystore( - saved, - 'imported', - 'not_checked', - journeyId, - trackAction, - reportedKeystoreSuccessesRef.current, - ) addLog(`✔ Keystore loaded — ${keystoreExistingPath}`) // Smart-route: same pattern as the auto-probe branch above. // If the user has any OAuth-side progress (legacy resume or @@ -2821,15 +2770,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir setStep('service-account-method-select') } catch (err) { - if (!keystorePrepared) { - trackAndroidKeystorePreparationFailure( - 'imported', - 'not_checked', - 'import_failed', - journeyId, - trackAction, - ) - } handleError(err, 'keystore-existing-path') } })() diff --git a/cli/src/build/onboarding/android/ui/keystore-action.ts b/cli/src/build/onboarding/android/ui/keystore-action.ts deleted file mode 100644 index 337ffd6688..0000000000 --- a/cli/src/build/onboarding/android/ui/keystore-action.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { AndroidOnboardingProgress, AndroidOnboardingStep } from '../types.js' -import type { PreparationTrackAction } from '../../ui/preparation-action.js' -import { emitPreparationAction, emitPreparationSuccessOnce } from '../../ui/preparation-action.js' - -export type AndroidKeystoreSource = 'generated' | 'imported' -export type AndroidKeyPasswordStatus = 'verified' | 'generated_with_keystore' | 'not_checked' -export type AndroidKeystoreFailureReason = 'generate_failed' | 'import_failed' - -type TrackAction = PreparationTrackAction - -function hasSavedKeystore(progress: AndroidOnboardingProgress, source: AndroidKeystoreSource): boolean { - const ready = progress.completedSteps.keystoreReady - return Boolean( - ready - && ready.isGenerated === (source === 'generated') - && progress._keystoreBase64 - && progress.keystoreAlias - && progress.keystoreStorePassword - && progress.keystoreKeyPassword, - ) -} - -export function trackPreparedAndroidKeystore( - progress: AndroidOnboardingProgress, - source: AndroidKeystoreSource, - keyPassword: AndroidKeyPasswordStatus, - journeyId: string, - trackAction: TrackAction, - reportedSuccesses: Set, -): boolean { - if (!hasSavedKeystore(progress, source)) - return false - - emitPreparationSuccessOnce(reportedSuccesses, source, trackAction, { - action: 'keystore_prepared', - attemptId: journeyId, - source, - step: source === 'generated' ? 'keystore-generating' : 'keystore-existing-key-password', - tags: { key_password: keyPassword }, - }) - return true -} - -export function trackAndroidKeystorePreparationFailure( - source: AndroidKeystoreSource, - keyPassword: AndroidKeyPasswordStatus, - reason: AndroidKeystoreFailureReason, - journeyId: string, - trackAction: TrackAction, -): void { - emitPreparationAction(trackAction, { - action: 'keystore_preparation_failed', - attemptId: journeyId, - source, - step: source === 'generated' ? 'keystore-generating' : 'keystore-existing-key-password', - tags: { reason, key_password: keyPassword }, - }) -} diff --git a/cli/src/build/onboarding/app-selection.ts b/cli/src/build/onboarding/app-selection.ts deleted file mode 100644 index 7f4bfb5263..0000000000 --- a/cli/src/build/onboarding/app-selection.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { CapacitorConfig } from '../../config' -import open from 'open' -import { getBuilderAppId, getConfiguredBuilderAppId } from '../app-id.js' -import { writeConfig } from '../../config/index.js' -import { consoleWebUrl, createSupabaseClient, formatCapgoCliInvokeError, getCapgoCliHttpStatus, getConfigForWrite, invokeCapgoCliApi } from '../../utils.js' - -export interface BuilderVisibleApp { - app_id: string - name: string | null - need_onboarding?: boolean | null -} - -export interface BuilderAppApiOptions { - supaHost?: string - supaAnon?: string -} - -export type AppSelectionErrorCode = 'list' | 'read' | 'missing' | 'build' | 'permission' | 'config' | 'api' - -export class AppSelectionError extends Error { - constructor(public readonly code: AppSelectionErrorCode, message: string, options?: ErrorOptions) { - super(message, options) - this.name = 'AppSelectionError' - } -} - -export function getAppSelectionSuggestion(config: CapacitorConfig): { appId: string, source: 'builder' | 'capacitor' } { - const builderAppId = getConfiguredBuilderAppId(config) - if (builderAppId) - return { appId: builderAppId, source: 'builder' } - if (typeof config.appId !== 'string' || !config.appId.trim()) - throw new AppSelectionError('config', 'Set appId in your Capacitor config before starting Builder onboarding.') - return { appId: config.appId.trim(), source: 'capacitor' } -} - -function commonDomainSegments(a: string, b: string): number { - const left = a.toLowerCase().split('.') - const right = b.toLowerCase().split('.') - let index = 0 - while (index < left.length && index < right.length && left[index] === right[index]) - index++ - return index -} - -function editDistance(a: string, b: string): number { - let previous = Array.from({ length: b.length + 1 }, (_, index) => index) - for (let i = 1; i <= a.length; i++) { - const current = [i] - for (let j = 1; j <= b.length; j++) - current[j] = Math.min(current[j - 1]! + 1, previous[j]! + 1, previous[j - 1]! + Number(a[i - 1] !== b[j - 1])) - previous = current - } - return previous[b.length]! -} - -export function rankVisibleApps(apps: T[], suggestedId: string): T[] { - const target = suggestedId.toLowerCase() - const scored = apps.map((app) => { - const id = app.app_id.toLowerCase() - return { - app, - prefix: commonDomainSegments(id, target), - similarity: 1 - editDistance(id, target) / Math.max(id.length, target.length, 1), - } - }) - return scored.sort((a, b) => b.prefix - a.prefix || b.similarity - a.similarity || a.app.app_id.localeCompare(b.app.app_id)).map(item => item.app) -} - -export async function listVisibleBuilderApps( - apikey: string, - options: BuilderAppApiOptions = {}, - request: typeof invokeCapgoCliApi = invokeCapgoCliApi, -): Promise { - const apps: BuilderVisibleApp[] = [] - for (let page = 0; ; page++) { - const { data, error } = await request(`app?page=${page}`, { - apikey, - method: 'GET', - body: undefined, - supaHost: options.supaHost, - supaAnon: options.supaAnon, - }) - if (error) - throw new AppSelectionError('list', `Could not load apps: ${await formatCapgoCliInvokeError(error)}`, { cause: error }) - if (!Array.isArray(data)) - throw new AppSelectionError('list', 'Capgo returned an invalid app list. Please retry.') - apps.push(...data) - if (data.length < 50) - return apps - } -} - -export async function verifyBuilderApp( - apikey: string, - appId: string, - options: BuilderAppApiOptions = {}, - dependencies: { request?: typeof invokeCapgoCliApi, createClient?: typeof createSupabaseClient } = {}, -): Promise { - const { data, error } = await (dependencies.request ?? invokeCapgoCliApi)(`app/${encodeURIComponent(appId)}`, { - apikey, - method: 'GET', - body: undefined, - supaHost: options.supaHost, - supaAnon: options.supaAnon, - }) - if (error) { - const status = getCapgoCliHttpStatus(error) - if (status === 401 || status === 403) - throw new AppSelectionError('read', `This API key needs app.read permission for ${appId}.`, { cause: error }) - if (status === 404) - throw new AppSelectionError('missing', `${appId} is no longer available. Check the app list again.`, { cause: error }) - throw new AppSelectionError('api', `Could not check app access: ${await formatCapgoCliInvokeError(error)}`, { cause: error }) - } - if (!data || data.app_id !== appId) - throw new AppSelectionError('missing', `${appId} is no longer available. Check the app list again.`) - - let supabase: Awaited> - try { - supabase = await (dependencies.createClient ?? createSupabaseClient)(apikey, options.supaHost, options.supaAnon, true) - } - catch (error) { - throw new AppSelectionError('api', 'Could not connect to Capgo to check build permission. Please retry.', { cause: error }) - } - const { data: canBuild, error: permissionError } = await supabase.rpc('cli_check_permission' as any, { - apikey, - permission_key: 'app.build_native', - org_id: null, - app_id: appId, - channel_id: null, - }) - if (permissionError) - throw new AppSelectionError('permission', 'Could not check app.build_native permission. Please retry.', { cause: permissionError }) - if (!canBuild) - throw new AppSelectionError('build', `This API key needs app.build_native permission for ${appId}.`) -} - -export async function persistBuilderAppSelection(appId: string): Promise { - try { - const extConfig = await getConfigForWrite(true) - if (getBuilderAppId(undefined, extConfig.config) === appId) - return false - extConfig.config.plugins ??= {} - extConfig.config.plugins.CapgoBuilder ??= {} - extConfig.config.plugins.CapgoBuilder.capgoBuilderAppId = appId - await writeConfig('CapgoBuilder', extConfig) - return true - } - catch (error) { - throw new AppSelectionError('config', 'Could not save the selected Builder app ID to your Capacitor config.', { cause: error }) - } -} - -export const builderAppCreationUrl = consoleWebUrl('/app/new') - -export async function openBuilderAppCreation(): Promise { - try { - await open(builderAppCreationUrl) - return true - } - catch { - return false - } -} - -export interface BuilderAppSelectionServices { - list: (apikey: string) => Promise - verify: (apikey: string, appId: string) => Promise - persist: (appId: string) => Promise - openDashboard: () => Promise - dashboardUrl: string -} - -export function createBuilderAppSelectionServices(options: BuilderAppApiOptions = {}): BuilderAppSelectionServices { - const dashboardAvailable = !options.supaHost && !options.supaAnon - return { - list: key => listVisibleBuilderApps(key, options), - verify: (key, appId) => verifyBuilderApp(key, appId, options), - persist: persistBuilderAppSelection, - openDashboard: dashboardAvailable ? openBuilderAppCreation : async () => false, - dashboardUrl: dashboardAvailable ? builderAppCreationUrl : '', - } -} diff --git a/cli/src/build/onboarding/appflow/flow.ts b/cli/src/build/onboarding/appflow/flow.ts index 744a3d1023..9ac4b74ff9 100644 --- a/cli/src/build/onboarding/appflow/flow.ts +++ b/cli/src/build/onboarding/appflow/flow.ts @@ -599,7 +599,7 @@ export function applyAppflowInput(step: AppflowStep, progress: AppflowProgress, return { ...base, p8IssuerId: (input.text ?? input.value ?? '').trim() } case 'handoff-build': // On 'build', the Appflow API work is done — switch progress.appId from the - // Appflow hex id to the resolved Capgo Builder app id so the + // Appflow hex id to the Capgo app id (the Capacitor config appId) so the // build/credential tail targets the real Capgo app, not the Appflow id. return { ...base, diff --git a/cli/src/build/onboarding/appflow/types.ts b/cli/src/build/onboarding/appflow/types.ts index 953357a0f6..db76a889e1 100644 --- a/cli/src/build/onboarding/appflow/types.ts +++ b/cli/src/build/onboarding/appflow/types.ts @@ -53,7 +53,7 @@ export interface AppflowProgress { appflowAccount?: string orgSlug?: string appId?: string // the SELECTED Appflow app id (hex), used for the Appflow API only - capgoAppId?: string // the resolved Capgo Builder app id, used for the build + credential store + capgoAppId?: string // the Capgo app id (Capacitor config appId), used for the build + credential store appSlug?: string ios?: Record // mapped Capgo iOS creds collected so far android?: Record // mapped Capgo Android creds collected so far diff --git a/cli/src/build/onboarding/command.ts b/cli/src/build/onboarding/command.ts index 05d9c9ee78..f70a41ef65 100644 --- a/cli/src/build/onboarding/command.ts +++ b/cli/src/build/onboarding/command.ts @@ -6,13 +6,11 @@ import { log } from '@clack/prompts' import { render } from 'ink' import React from 'react' import { resolveOwnerOrgId } from '../../analytics/org-resolver.js' -import { flushDeferredCommandInvocation, trackEvent } from '../../analytics/track.js' -import { getConfig } from '../../utils.js' -import { createBuilderAppSelectionServices, getAppSelectionSuggestion } from './app-selection.js' +import { trackEvent } from '../../analytics/track.js' +import { findSavedKeySilent, getAppId, getConfig } from '../../utils.js' import { appendInternalLog, startInternalLog } from '../../support/internal-log.js' import { newBuilderJourneyId } from './journey.js' -import { createBuilderLoginServices, resolveBuilderCandidateKey } from './login.js' -import { trackBuilderOnboardingAppSelection, trackBuilderOnboardingCancelled, trackBuilderOnboardingLogin } from './telemetry.js' +import { trackBuilderOnboardingCancelled } from './telemetry.js' import { isMacOS, probeGuidedHelper } from './asc-key/helper.js' import { ASC_KEY_CHANNEL } from './asc-key/protocol.js' import { getPlatformDirFromCapacitorConfig } from '../platform-paths.js' @@ -25,7 +23,6 @@ import { discoverCapacitorProjects, hasCapacitorConfig } from './project-discove import { selectCapacitorProject } from './project-selection.js' import type { BuilderProjectPrompts } from './project-selection.js' import type { OnboardingResult } from './types.js' -import type { AppSelectionEvent } from './ui/app-selection-gate.js' export interface OnboardingBuilderOptions { analytics?: boolean apikey?: string @@ -219,11 +216,11 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions // Detect app ID and platform directories from capacitor.config.ts let appId: string | undefined - let suggestedSource: 'builder' | 'capacitor' = 'capacitor' // `iosBundleIdInitial` is the iOS-side default — the top-level // `config.appId` (what `cap sync` writes into PRODUCT_BUNDLE_IDENTIFIER). - // This is distinct from `appId` above, which resolves the Capgo Builder key. - // The iOS onboarding flow uses these for different purposes — + // This is distinct from `appId` above, which `getAppId` resolves to the + // CapacitorUpdater plugin override when present (e.g. a Capgo dev-tunnel + // suffix). The iOS onboarding flow uses these for different purposes — // never collapse them — see the AppProps doc-block in ui/app.tsx. let iosBundleIdInitial: string | undefined let iosDir = 'ios' @@ -258,16 +255,7 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions process.exit(1) } - try { - const suggestion = getAppSelectionSuggestion(extConfig.config) - appId = suggestion.appId - suggestedSource = suggestion.source - } - catch (error) { - await stopInk(projectDiscoveryInk) - log.error(error instanceof Error ? error.message : String(error)) - process.exit(1) - } + appId = getAppId(undefined, extConfig.config) iosBundleIdInitial = extConfig.config.appId iosDir = getPlatformDirFromCapacitorConfig(extConfig.config, 'ios') androidDir = getPlatformDirFromCapacitorConfig(extConfig.config, 'android') @@ -289,7 +277,6 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions // resolved Capgo lookup key. Mismatch detection will still surface the // pbxproj/plist values; the user can pick the right one from there. const iosBundleIdForOnboarding = iosBundleIdInitial || appId - const appflowPackageName = iosBundleIdForOnboarding const initialPlatform = resolveInitialPlatform(options, iosDir, androidDir) @@ -342,11 +329,7 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions // handoff each get exactly one. const journeyId = newBuilderJourneyId() const analyticsEnabled = options.enableSelfUpdate === true && options.analytics !== false - const candidateApiKey = resolveBuilderCandidateKey(options.apikey) - const loginServices = createBuilderLoginServices({ supaHost: options.supaHost, supaAnon: options.supaAnon }) - const appSelectionServices = createBuilderAppSelectionServices({ supaHost: options.supaHost, supaAnon: options.supaAnon }) - let authenticatedApiKey: string | undefined - const replayApikey = candidateApiKey + const replayApikey = options.apikey?.trim() || findSavedKeySilent() const buildReplayUrl = resolveSupabaseReplayUrl(options.supaHost) const buildReplay = startInitReplay({ analyticsEnabled, @@ -371,16 +354,15 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions let lastStep: string | undefined const onboardingTree = React.createElement(OnboardingShell, { appId, - suggestedSource, - appSelectionServices, - // Keep the native iOS bundle ID separate from the Capgo app selected - // in the wizard. See the AppProps doc-block in ui/app.tsx for the split. + // Threaded through to the iOS OnboardingApp so it can use the iOS + // bundle id (config.appId) for Apple-side operations while keeping + // `appId` (the Capgo lookup key, which may include a dev-tunnel + // suffix via plugins.CapacitorUpdater.appId) for Capgo SaaS calls. + // See the AppProps doc-block in ui/app.tsx for the split. iosBundleIdInitial: iosBundleIdForOnboarding, - appflowPackageName, iosDir, androidDir, - apikey: candidateApiKey, - loginServices, + apikey: options.apikey, supaHost: options.supaHost, supaAnon: options.supaAnon, journeyId, @@ -399,35 +381,6 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions onResult: (r: OnboardingResult) => { result = r }, - onAuthenticated: (key, metadata) => { - authenticatedApiKey = key - flushDeferredCommandInvocation(key) - if (metadata.method) { - void trackBuilderOnboardingLogin({ - apikey: key, - appId: appId!, - journeyId, - method: metadata.method, - retryCount: metadata.retryCount, - durationMs: metadata.durationMs, - }) - } - }, - onAppSelected: (chosenId: string) => { - if (appId !== chosenId) - appendInternalLog(`build init: selected Capgo app ${chosenId} instead of ${appId}`) - appId = chosenId - }, - onAppSelectionEvent: (event: AppSelectionEvent) => { - if (!authenticatedApiKey || options.analytics === false) - return - void trackBuilderOnboardingAppSelection({ - apikey: authenticatedApiKey, - appId: appId!, - journeyId, - ...event, - }) - }, onBeforeExit: finishBuildReplay, }) const ink = projectDiscoveryInk ?? render(onboardingTree, { alternateScreen: true }) @@ -493,7 +446,7 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions // user has already quit. On timeout we abort the org lookup and skip the // event — losing one best-effort quit beacon is preferable to a hang. if (result.outcome === 'cancelled') { - const apikey = authenticatedApiKey + const apikey = options.apikey?.trim() || findSavedKeySilent() if (apikey) { const timeoutMs = 1500 const controller = new AbortController() diff --git a/cli/src/build/onboarding/ios/flow.ts b/cli/src/build/onboarding/ios/flow.ts index 4751d89e72..e6c35aef8b 100644 --- a/cli/src/build/onboarding/ios/flow.ts +++ b/cli/src/build/onboarding/ios/flow.ts @@ -175,8 +175,6 @@ export interface IosStepCtx { duplicateProfiles?: IosDuplicateProfile[] /** Existing Apple certs offered for revocation when the cert limit is hit. */ existingCerts?: AscDistributionCert[] - /** Preserves the limit outcome if the follow-up certificate lookup fails. */ - certificateLimitReached?: boolean /** The user's revoke selection (cert-limit-prompt → revoking-certificate). */ certToRevoke?: AscDistributionCert @@ -200,10 +198,6 @@ export interface IosStepCtx { teamId?: string /** Keychain export password (import-exporting). Transient only. */ importedP12Password?: string - /** Set only after a Keychain .p12 export succeeds; consumed after credentials are saved. */ - keychainP12Exported?: boolean - /** Safe outcome flag; export errors themselves never enter action telemetry. */ - keychainP12ExportFailed?: boolean // ── .p8 validation buffer (ephemeral during input-p8-path) ─────────────── /** Buffer of .p8 file content during validation (only the PATH is persisted). */ @@ -2448,17 +2442,9 @@ export async function runIosEffect( if (err instanceof CertificateLimitError) { // Offer the existing certs for revocation. Prefer the certs carried on // the error; fall back to a fresh list via listCertificates. - let existingCerts = err.certificates - if (!existingCerts?.length) { - try { - existingCerts = (await deps.listCertificates?.()) ?? [] - } - catch (lookupError) { - const msg = lookupError instanceof Error ? lookupError.message : String(lookupError) - deps.onLog?.(`✖ ${msg}`, 'red') - return iosError(progress, msg, step, { certificateLimitReached: true }) - } - } + const existingCerts = err.certificates?.length + ? err.certificates + : (await deps.listCertificates?.()) ?? [] return { progress, next: 'cert-limit-prompt', transient: { existingCerts } } } deps.onLog?.(`✖ ${err instanceof Error ? err.message : String(err)}`, 'red') @@ -3276,16 +3262,8 @@ export async function runIosEffect( // can't help; only Restart/Exit are offered (no retryStep). return iosError(progress, msg) } - let exported: ExportedP12 - try { - exported = await deps.exportP12FromKeychain!(chosenIdentity.sha1) - } - catch (err) { - const msg = err instanceof Error ? err.message : String(err) - deps.onLog?.(`✖ ${msg}`, 'red') - return iosError(progress, msg, 'import-exporting', { keychainP12ExportFailed: true }) - } try { + const exported = await deps.exportP12FromKeychain!(chosenIdentity.sha1) // Synthesize a CertificateData record. Apple-API-only fields (certificateId) // stay empty for an imported cert (app.tsx:1639); expiry comes from the // chosen profile, team id from the identity. @@ -3318,7 +3296,6 @@ export async function runIosEffect( certData, profileData, importedP12Password: exported.passphrase, - keychainP12Exported: true, ...(chosenIdentity.teamId ? { teamId: chosenIdentity.teamId } : {}), }, } diff --git a/cli/src/build/onboarding/login.ts b/cli/src/build/onboarding/login.ts deleted file mode 100644 index dec81fbd26..0000000000 --- a/cli/src/build/onboarding/login.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { BrowserLoginSession } from '../../init/browser-login.js' -import { validateAndSaveKey } from '../../auth/session.js' -import { beginBrowserLogin, completeBrowserLogin } from '../../init/browser-login.js' -import { resolveAccountEmail } from '../../user/whoami.js' -import { createSupabaseClient, findSavedKeySilent, resolveUserIdFromApiKey } from '../../utils.js' - -export interface BuilderLoginOptions { - supaHost?: string - supaAnon?: string -} - -export interface BuilderLoginServices { - browserAvailable: boolean - validateExisting: (key: string) => Promise - getAccountEmail: (key: string) => Promise - savePasted: (key: string) => Promise - beginBrowser: (onUrl: (url: string) => void) => Promise - completeBrowser: (session: BrowserLoginSession, key: string) => Promise -} - -export function resolveBuilderCandidateKey(explicitKey?: string): string | undefined { - return explicitKey?.trim() || findSavedKeySilent() -} - -export function createBuilderLoginServices(options: BuilderLoginOptions = {}): BuilderLoginServices { - const saveOptions = { local: false, supaHost: options.supaHost, supaAnon: options.supaAnon } - return { - browserAvailable: !options.supaHost && !options.supaAnon, - validateExisting: async (key) => { - const client = await createSupabaseClient(key, options.supaHost, options.supaAnon, true) - await resolveUserIdFromApiKey(client, key, true) - }, - getAccountEmail: async (key) => { - const client = await createSupabaseClient(key, options.supaHost, options.supaAnon, true) - return resolveAccountEmail(client) - }, - savePasted: async (key) => { - await validateAndSaveKey(key, saveOptions) - }, - beginBrowser: onUrl => beginBrowserLogin(onUrl), - completeBrowser: async (session, key) => { - await completeBrowserLogin(session, key, saveOptions) - }, - } -} diff --git a/cli/src/build/onboarding/mcp/engine.ts b/cli/src/build/onboarding/mcp/engine.ts index 465f8c9d5f..27f2c4297b 100644 --- a/cli/src/build/onboarding/mcp/engine.ts +++ b/cli/src/build/onboarding/mcp/engine.ts @@ -2111,7 +2111,7 @@ export async function decideAppflow( if (!progress.capgoAppId) progress = { ...progress, capgoAppId: appId } - const flowDeps = buildAppflowEffectDeps({ appId, packageName: await deps.getNativeAppId?.() ?? facts.appId }) + const flowDeps = buildAppflowEffectDeps({ appId, packageName: facts.appId }) // Carries the most-recent AUTO effect's `transient` (e.g. the org/app/cert/dist // option lists, or validation results) so the interactive step it transitions @@ -3413,8 +3413,6 @@ export interface EngineDeps { cwd: string hasSavedKey: () => boolean getAppId: () => Promise - /** Native package/bundle id, separate from the Capgo Builder app key. */ - getNativeAppId?: () => Promise detectPlatforms: () => Promise isAppRegistered: (appId: string) => Promise loadProgress: (appId: string) => Promise diff --git a/cli/src/build/onboarding/mcp/onboarding-tools.ts b/cli/src/build/onboarding/mcp/onboarding-tools.ts index cba6fb0e94..1ae6e9b8ee 100644 --- a/cli/src/build/onboarding/mcp/onboarding-tools.ts +++ b/cli/src/build/onboarding/mcp/onboarding-tools.ts @@ -12,7 +12,6 @@ import type { McpRegistrar } from '../../../mcp/registrar.js' import { findBuildCommandForProjectType, findProjectType, findSavedKeySilent, getAppId, getConfig, getPackageScripts } from '../../../utils.js' import { findPackageManagerType } from '@capgo/find-package-manager' import { loadSavedCredentials, updateSavedCredentials } from '../../credentials.js' -import { getBuilderAppId, getConfiguredBuilderAppId } from '../../app-id.js' import { getPlatformDirFromCapacitorConfig } from '../../platform-paths.js' import type { AndroidEffectDeps } from '../android/flow.js' import type { IosEffectDeps } from '../ios/flow.js' @@ -219,9 +218,7 @@ function buildIosEffectDeps(cwd: string, getAppIdFn: () => Promise Promise CapgoSDK): EngineDeps { const cwd = process.cwd() const getAppIdClosure = async (): Promise => { - let ext: Awaited> | undefined try { - ext = await getConfig(true) + const ext = await getConfig(true) + return getAppId(undefined, ext?.config) } catch { return undefined } - return getBuilderAppId(undefined, ext?.config) } return { cwd, hasSavedKey: () => Boolean(findSavedKeySilent()), getAppId: getAppIdClosure, - getNativeAppId: async () => { - let ext: Awaited> | undefined - try { - ext = await getConfig(true) - } - catch { - return undefined - } - return getConfiguredBuilderAppId(ext?.config) ? ext?.config?.appId : undefined - }, detectPlatforms: async () => { const out: Platform[] = [] try { diff --git a/cli/src/build/onboarding/project-discovery.ts b/cli/src/build/onboarding/project-discovery.ts index 2ef0969f6d..6f35109ac2 100644 --- a/cli/src/build/onboarding/project-discovery.ts +++ b/cli/src/build/onboarding/project-discovery.ts @@ -107,7 +107,7 @@ function readStaticString(source: string, start: number): StaticStringToken | un return { end: source.length } } -function readStaticAppId(source: string, field = 'appId'): string | undefined { +function readStaticAppId(source: string): string | undefined { const values = new Set() let index = 0 while (index < source.length) { @@ -125,7 +125,7 @@ function readStaticAppId(source: string, field = 'appId'): string | undefined { keyEnd = index + (identifier?.length ?? 1) } - if (key === field) { + if (key === 'appId') { const colon = skipTrivia(source, keyEnd) if (source[colon] === ':') { const valueStart = skipTrivia(source, colon + 1) @@ -147,14 +147,11 @@ function readCapacitorAppId(directory: string): string | undefined { try { const source = readFileSync(configPath, 'utf8') if (configPath.endsWith('.json')) { - const config = JSON.parse(source) as { appId?: unknown, plugins?: { CapgoBuilder?: { capgoBuilderAppId?: unknown } } } - const builderAppId = config.plugins?.CapgoBuilder?.capgoBuilderAppId - if (typeof builderAppId === 'string' && builderAppId.trim()) - return builderAppId.trim() + const config = JSON.parse(source) as { appId?: unknown } const appId = typeof config.appId === 'string' ? config.appId.trim() : '' return appId || undefined } - return readStaticAppId(source, 'capgoBuilderAppId') ?? readStaticAppId(source) + return readStaticAppId(source) } catch { // Discovery remains best-effort. The selected project's normal config load diff --git a/cli/src/build/onboarding/telemetry.ts b/cli/src/build/onboarding/telemetry.ts index 1f8e36d0b1..cd628d131c 100644 --- a/cli/src/build/onboarding/telemetry.ts +++ b/cli/src/build/onboarding/telemetry.ts @@ -1,7 +1,5 @@ import type { AndroidOnboardingErrorCategory, AndroidOnboardingStep } from './android/types.js' import type { OnboardingErrorCategory, OnboardingStep, Platform } from './types.js' -import type { AppSelectionEvent } from './ui/app-selection-gate.js' -import { trackEvent } from '../../analytics/track.js' import { sendEvent } from '../../utils.js' import { getActiveCliReplaySessionId } from '../../init/replay.js' import { mapAndroidOnboardingError, mapIosOnboardingError } from './error-categories.js' @@ -12,47 +10,6 @@ function addReplaySessionTag(tags: Record, replaySessionId?: str tags.$session_id = sessionId } -export interface TrackBuilderOnboardingLoginInput { - apikey: string - appId: string - journeyId: string - method: 'browser' | 'paste' - retryCount: number - durationMs: number -} - -/** Login happens before platform and owner-org resolution. Send once a key is valid. */ -export function trackBuilderOnboardingLogin(input: TrackBuilderOnboardingLoginInput): Promise { - return trackEvent({ - apikey: input.apikey, - appId: input.appId, - channel: 'builder-onboarding', - event: 'Builder Onboarding Login', - tags: { - journey_id: input.journeyId, - method: input.method, - retry_count: input.retryCount, - duration_ms: input.durationMs, - }, - }) -} - -export function trackBuilderOnboardingAppSelection(input: AppSelectionEvent & { apikey: string, appId: string, journeyId: string }): Promise { - return trackEvent({ - apikey: input.apikey, - appId: input.appId, - channel: 'builder-onboarding', - event: 'Builder Onboarding App Selection', - tags: { - journey_id: input.journeyId, - phase: input.phase, - ...(input.result ? { result: input.result } : {}), - ...(input.source ? { source: input.source } : {}), - visible_app_count: input.visibleCount, - }, - }) -} - export interface TrackBuilderOnboardingStepInput { apikey: string appId: string @@ -76,18 +33,9 @@ export type BuilderOnboardingAction // fork — `continue` resumes saved progress, `restart` wipes it. Carries a // `choice` tag with that value. = | 'resume_prompt_decision' - | 'question_shown' - | 'question_answered' - | 'question_skipped' | 'android_sa_method_selected' | 'android_sa_validation_recovery_selected' | 'android_sa_validation_result' - | 'credential_verified' - | 'credential_verification_failed' - | 'certificate_prepared' - | 'certificate_preparation_failed' - | 'keystore_prepared' - | 'keystore_preparation_failed' export interface TrackBuilderOnboardingActionInput { apikey: string diff --git a/cli/src/build/onboarding/ui/app-selection-gate.tsx b/cli/src/build/onboarding/ui/app-selection-gate.tsx deleted file mode 100644 index 5e647444b1..0000000000 --- a/cli/src/build/onboarding/ui/app-selection-gate.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import type { FC, ReactNode } from 'react' -import type { BuilderAppSelectionServices, BuilderVisibleApp } from '../app-selection.js' -import { Box, Text, useInput } from 'ink' -import Spinner from 'ink-spinner' -import React, { useCallback, useEffect, useRef, useState } from 'react' -import { AppSelectionError, rankVisibleApps } from '../app-selection.js' -import { PICKER_MIN_COLS, PICKER_MIN_ROWS, terminalFitsPicker } from '../min-terminal-size.js' -import { Header } from './components.js' -import { TerminalTooSmallPrompt } from './min-size-gate.js' - -type View = 'loading' | 'main' | 'all' | 'dashboard' | 'verifying' | 'error' -type Choice = { kind: 'app', app: BuilderVisibleApp, source: 'closest_list' | 'full_list' } | { kind: 'all' | 'login' | 'dashboard' | 'retry' | 'back' } - -export interface AppSelectionEvent { - phase: 'shown' | 'resolved' | 'error' - result?: 'exact_match' | 'selected' | 'list' | 'read' | 'missing' | 'build' | 'permission' | 'config' | 'api' - source?: 'closest_list' | 'full_list' - visibleCount: number -} - -export interface BuilderAppSelectionGateProps { - apikey: string - suggestedId: string - suggestedSource: 'builder' | 'capacitor' - services: BuilderAppSelectionServices - cols: number - rows: number - footer?: ReactNode - onSelected: (appId: string) => void - onSwitchKey: () => void - onCancel: () => void - onEvent?: (event: AppSelectionEvent) => void -} - -function visibleAppLabel(app: BuilderVisibleApp): string { - return app.name && app.name !== app.app_id ? `${app.name} · ${app.app_id}` : app.app_id -} - -function errorMessage(error: unknown): string { - if (error instanceof AppSelectionError) - return error.message - return error instanceof Error ? error.message : 'Could not check Capgo apps. Please retry.' -} - -const BuilderAppSelectionGate: FC = ({ apikey, suggestedId, suggestedSource, services, cols, rows, footer, onSelected, onSwitchKey, onCancel, onEvent }) => { - const [view, setView] = useState('loading') - const [apps, setApps] = useState([]) - const [index, setIndex] = useState(0) - const [query, setQuery] = useState('') - const [error, setError] = useState('') - const [retrySelection, setRetrySelection] = useState<{ app: BuilderVisibleApp, source?: 'closest_list' | 'full_list' } | undefined>() - const [dashboardOpened, setDashboardOpened] = useState(true) - const active = useRef(true) - const request = useRef(0) - const eventCallback = useRef(onEvent) - eventCallback.current = onEvent - const selectedCallback = useRef(onSelected) - selectedCallback.current = onSelected - - const emit = (event: AppSelectionEvent) => eventCallback.current?.(event) - - const verifyAndSelect = useCallback(async (app: BuilderVisibleApp, source?: 'closest_list' | 'full_list', count = 0) => { - const requestId = ++request.current - setRetrySelection({ app, source }) - setView('verifying') - try { - await services.verify(apikey, app.app_id) - await services.persist(app.app_id) - if (!active.current || requestId !== request.current) - return - selectedCallback.current(app.app_id) - emit({ phase: 'resolved', result: source ? 'selected' : 'exact_match', source, visibleCount: count }) - } - catch (cause) { - if (!active.current || requestId !== request.current) - return - const category = cause instanceof AppSelectionError ? cause.code : 'permission' - emit({ phase: 'error', result: category, visibleCount: count }) - setError(errorMessage(cause)) - setView('error') - } - }, [apikey, services]) - - const load = useCallback(async () => { - const requestId = ++request.current - setView('loading') - setError('') - setApps([]) - try { - const visible = await services.list(apikey) - if (!active.current || requestId !== request.current) - return - const ranked = rankVisibleApps(visible, suggestedId) - setApps(ranked) - setIndex(0) - const match = ranked.find(app => app.app_id === suggestedId) - if (match) { - await verifyAndSelect(match, undefined, ranked.length) - return - } - emit({ phase: 'shown', visibleCount: ranked.length }) - setRetrySelection(undefined) - setView('main') - } - catch (cause) { - if (!active.current || requestId !== request.current) - return - emit({ phase: 'error', result: 'list', visibleCount: 0 }) - setRetrySelection(undefined) - setError(errorMessage(cause)) - setView('error') - } - }, [apikey, services, suggestedId, verifyAndSelect]) - - useEffect(() => { - active.current = true - void load() - return () => { - active.current = false - request.current++ - } - }, [load]) - - const firstChoices: Choice[] = [ - ...apps.slice(0, 3).map(app => ({ kind: 'app' as const, app, source: 'closest_list' as const })), - ...(apps.length > 1 ? [{ kind: 'all' as const }] : []), - { kind: 'login' }, - ...(services.dashboardUrl ? [{ kind: 'dashboard' as const }] : []), - ] - const filteredApps = apps.filter(app => `${app.name ?? ''} ${app.app_id}`.toLowerCase().includes(query.toLowerCase())) - const allChoices: Choice[] = [ - ...filteredApps.map(app => ({ kind: 'app' as const, app, source: 'full_list' as const })), - { kind: 'back' }, - ] - const errorChoices: Choice[] = [ - { kind: 'retry' }, - ...(apps.length ? [{ kind: 'back' as const }] : []), - { kind: 'login' }, - ] - const choices = view === 'main' ? firstChoices : view === 'all' ? allChoices : view === 'error' ? errorChoices : [] - const boundedIndex = Math.min(index, Math.max(choices.length - 1, 0)) - - const choose = (choice: Choice) => { - if (choice.kind === 'app') { - void verifyAndSelect(choice.app, choice.source, apps.length) - } - else if (choice.kind === 'all') { - setQuery('') - setIndex(0) - setView('all') - } - else if (choice.kind === 'login') { - onSwitchKey() - } - else if (choice.kind === 'dashboard') { - const requestId = ++request.current - setDashboardOpened(true) - setView('dashboard') - void services.openDashboard().then((opened) => { - if (active.current && requestId === request.current) - setDashboardOpened(opened) - }).catch(() => { - if (active.current && requestId === request.current) - setDashboardOpened(false) - }) - } - else if (choice.kind === 'retry') { - if (retrySelection) - void verifyAndSelect(retrySelection.app, retrySelection.source, apps.length) - else - void load() - } - else { - setIndex(0) - setView('main') - } - } - - useInput((input, key) => { - if (key.escape) { - if (view === 'main') - onCancel() - else if (view === 'error' && apps.length === 0) - onCancel() - else if (view === 'all' || view === 'dashboard' || view === 'error') { - if (view === 'dashboard') - request.current++ - setIndex(0) - setView('main') - } - return - } - if (view === 'dashboard') { - if (key.return) - void load() - return - } - if (view !== 'main' && view !== 'all' && view !== 'error') - return - if (key.upArrow || (view !== 'all' && input === 'k')) { - setIndex(value => Math.max(0, value - 1)) - return - } - if (key.downArrow || (view !== 'all' && input === 'j')) { - setIndex(value => Math.min(choices.length - 1, value + 1)) - return - } - if (key.return) { - const choice = choices[boundedIndex] - if (choice) - choose(choice) - return - } - if (view === 'all') { - if (key.backspace || key.delete) { - setQuery(value => value.slice(0, -1)) - setIndex(0) - } - else if (!key.ctrl && !key.meta && !key.tab && input && !key.leftArrow && !key.rightArrow) { - setQuery(value => value + input) - setIndex(0) - } - } - }) - - if (!terminalFitsPicker(cols, rows)) - return - - const compact = cols < 64 || rows < 18 - const showDivider = !compact && rows >= 20 - const optionLimit = compact ? Math.max(1, rows - 9) : Math.max(3, rows - (showDivider ? 18 : 16)) - const start = Math.max(0, boundedIndex - optionLimit + 1) - const visibleChoices = choices.slice(start, start + optionLimit) - const suggestionLabel = suggestedSource === 'builder' ? 'Your Builder app ID:' : 'Your Capacitor app ID:' - - return ( - - {compact ? Capgo Cloud Build · Onboarding :
} - {(view === 'loading' || view === 'verifying') && ( - - - {view === 'loading' ? 'Checking Capgo apps…' : 'Checking app access…'} - - )} - {(view === 'main' || view === 'all') && ( - - Which Capgo app should Builder use? - {`${suggestionLabel} ${suggestedId}`} - No app with this ID is available to your API key. It may exist in Capgo, but you or your API key might lack access to it. - {view === 'all' - ? {`Search visible apps: ${query}█`} - : {apps.length === 0 ? 'No apps are visible to this API key.' : apps.length === 1 ? 'App visible to your API key:' : 'Apps visible to your API key (closest IDs first):'}} - - )} - {view === 'error' && {retrySelection ? 'Could not continue with this app' : 'Could not load Capgo apps'}{error}} - {view === 'dashboard' && ( - - Open Dashboard to create {suggestedId} - {dashboardOpened ? 'Create the app in your browser, then return here.' : 'Open this URL in your browser:'} - {!dashboardOpened && {services.dashboardUrl}} - ❯ I've created it — check again - Enter checks again · Esc goes back - - )} - {(view === 'main' || view === 'all' || view === 'error') && ( - - {showDivider && (view === 'main' || view === 'all') && {'─'.repeat(Math.min(76, cols - 2))}} - {view === 'all' && filteredApps.length === 0 && No matching apps} - {visibleChoices.map((choice, offset) => { - const selected = start + offset === boundedIndex - const label = choice.kind === 'app' ? visibleAppLabel(choice.app) - : choice.kind === 'all' ? 'Select a different app…' - : choice.kind === 'login' ? 'Log in with another API key' - : choice.kind === 'dashboard' ? `Open Dashboard to create ${suggestedId}` - : choice.kind === 'retry' ? 'Retry' - : 'Back to suggested apps' - return {`${selected ? '❯' : ' '} ${label}`} - })} - {compact && choices.length > optionLimit && ↑ ↓ more choices} - {!compact && ↑ ↓ choose · Enter select · Esc back} - - )} - {!compact && footer && } - {!compact && footer} - - ) -} - -export default BuilderAppSelectionGate diff --git a/cli/src/build/onboarding/ui/app.tsx b/cli/src/build/onboarding/ui/app.tsx index f78cb04890..0576979032 100644 --- a/cli/src/build/onboarding/ui/app.tsx +++ b/cli/src/build/onboarding/ui/app.tsx @@ -47,8 +47,6 @@ import { isAiAnalysisTooTall, resolveAiResultRoute } from '../ai-fit.js' import { getWorkflowDiffTelemetry, trackBuildOnboardingWorkflowEvent } from '../analytics.js' import { evaluateGate } from '../app-verification.js' import { exitAfterOnboardingBeforeExit } from './exit.js' -import { trackGuidedKeyValidationFailure, trackVerifiedIosKey, verifyIosKeyWithTelemetry } from './ios-credential-action.js' -import { trackCreatedIosCertificateResult, trackImportedIosCertificateSaveResult, trackIosCertificateCreationThrow, trackIosKeychainExportResult } from './ios-certificate-action.js' import { classifyCertAvailability, computeCertSha1, createCertificate, createProfile, deleteProfile, ensureBundleId, findCertIdBySha1, generateJwt, listApps, listBundleIds, listDistributionCerts, listProfilesForCert, revokeCertificate, verifyApiKey } from '../apple-api.js' import { runAscKeyHelper } from '../asc-key/helper.js' import { sanitizeBuildLogLines } from '../build-log.js' @@ -65,7 +63,6 @@ import { IOS_MIN_ROWS, terminalFitsOnboarding } from '../min-terminal-size.js' import { deleteProgress, extractKeyIdFromP8Path, getImportEntryStep, loadProgress, saveProgress } from '../progress.js' import { getBuildOnboardingRecoveryAdvice } from '../recovery.js' import { trackBuilderOnboardingAction, trackBuilderOnboardingStep } from '../telemetry.js' -import { saveImportDistributionAnswer, trackImportDistributionShown } from './import-distribution-analytics.js' import { getPhaseLabel, @@ -78,7 +75,6 @@ import { CompletedStepsLog } from './completed-steps-log.js' import { BOX_HEADER_ROWS, COMPACT_HEADER_ROWS, DiffSummary, Divider, FilteredTextInput, FullscreenAiViewer, FullscreenBuildOutput, FullscreenDiffViewer, Header, isBuildCompleteDismissKey, SecretsTable, SpinnerLine, SuccessLine, Table, WIZARD_PADDING_ROWS } from './components.js' import { logBudgetRows } from './frame-fit.js' import { TerminalTooSmallPrompt } from './min-size-gate.js' -import { routeFreshIosSetupMethod } from './setup-method-route.js' import { AskBuildStep, AskCiSecretsStep, @@ -249,9 +245,10 @@ interface LogEntry { text: string, color?: string } interface AppProps { /** * Capgo lookup key (progress files, saved credentials, Capgo SaaS build - * API). Resolved by the Builder app-id helper, which uses - * `plugins.CapgoBuilder.capgoBuilderAppId` when configured and otherwise - * keeps the prior updater/native fallback. Do NOT use for Apple-side operations — see + * API). Resolved by `getAppId()`, which prefers + * `config.plugins.CapacitorUpdater.appId` over `config.appId` so dev-tunnel + * sandboxes can override the Capgo-side identifier without renaming the + * iOS bundle. Do NOT use for Apple-side operations — see * `iosBundleIdInitial`. */ appId: string @@ -362,7 +359,9 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres // ─── iOS bundle id ───────────────────────────────────────────────────── // - // `appId` (prop) is the Capgo Builder lookup key. It owns the progress-file key, credentials + // `appId` (prop) is the Capgo lookup key — what `getAppId()` resolves to, + // which prefers `config.plugins.CapacitorUpdater.appId` over `config.appId` + // for dev-tunnel sandboxes. It owns the progress-file key, credentials // store key, and `capgo build request` command path. // // `iosBundleId` is what we send to Apple — sourced from `config.appId` @@ -810,29 +809,6 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres }, [appId, resolvedOrgId, step, journeyId], ) - const reportedCertificateSuccessesRef = useRef(new Set()) - const setupMethodShownRef = useRef(false) - useEffect(() => { - if (step !== 'setup-method-select') { - setupMethodShownRef.current = false - return - } - if (setupMethodShownRef.current || !terminalFitsOnboarding(terminalCols, terminalRows, 'ios')) - return - setupMethodShownRef.current = true - trackAction('question_shown', { attempt_id: journeyId, question_id: 'ios_setup_method' }) - }, [step, terminalCols, terminalRows, trackAction, journeyId]) - const importDistributionShownRef = useRef(false) - useEffect(() => { - if (step !== 'import-distribution-mode') { - importDistributionShownRef.current = false - return - } - if (importDistributionShownRef.current || !terminalFitsOnboarding(terminalCols, terminalRows, 'ios')) - return - importDistributionShownRef.current = true - trackImportDistributionShown(journeyId, trackAction) - }, [step, terminalCols, terminalRows, trackAction, journeyId]) const [teamId, setTeamId] = useState(initialProgress?.completedSteps.certificateCreated?.teamId || '') const [certData, setCertData] = useState(initialProgress?.completedSteps.certificateCreated || null) const [profileData, setProfileData] = useState(initialProgress?.completedSteps.profileCreated || null) @@ -1762,8 +1738,13 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres return if (existing?.ios) setStep('credentials-exist') + else if (isMacOS()) + // Fresh iOS, no creds: offer the import-vs-create fork (create-new → + // the guided .p8 helper). Only macOS can drive the helper; other + // hosts go straight to the manual .p8 instructions. + setStep('setup-method-select') else - setStep(routeFreshIosSetupMethod(isMacOS(), journeyId, trackAction)) + setStep('api-key-instructions') })() }, 800) } @@ -1803,8 +1784,12 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres return if (existing?.ios) setStep('credentials-exist') + else if (isMacOS()) + // Fresh iOS, no creds: route through the import-vs-create fork + // (create-new → the guided .p8 helper) on macOS. + setStep('setup-method-select') else - setStep(routeFreshIosSetupMethod(isMacOS(), journeyId, trackAction)) + setStep('api-key-instructions') })() return } @@ -1834,11 +1819,7 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres // helper window if the user quits the TUI, so the CLI doesn't hang. const abort = new AbortController() ascHelperAbortRef.current = abort - const outcome = await runAscKeyHelper({ - apikey, - signal: abort.signal, - onEvent: event => trackGuidedKeyValidationFailure(event.name, journeyId, trackAction, cancelled), - }) + const outcome = await runAscKeyHelper({ apikey, signal: abort.signal }) if (cancelled) return if (!outcome.ok) { @@ -2226,10 +2207,7 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres // ── apple-api (token-adapted) ── verifyApiKey: async () => { - const token = await getFreshToken() - const r = await verifyIosKeyWithTelemetry( - () => verifyApiKey(token), journeyId, trackAction, () => cancelled, - ) + const r = await verifyApiKey(await getFreshToken()) return { teamId: r.teamId } }, createCertificate: async ({ csr }) => createCertificate(await getFreshToken(), csr), @@ -2310,7 +2288,6 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres }, } - let certificateEffectRunning = false try { // Run against the freshest persisted progress — the prior input steps // persisted p8Path / keyId / issuerId before these auto steps run, so the @@ -2327,18 +2304,13 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres // verify-app: surface the step loader while the initial ASC fetch runs. if (step === 'verify-app') setVerifyAppLoading(true) - certificateEffectRunning = step === 'creating-certificate' const result = await runIosEffect(step, current, deps) - certificateEffectRunning = false if (cancelled) return const t: Partial | undefined = result.transient const np = result.progress - if (step === 'creating-certificate') - trackCreatedIosCertificateResult(result, journeyId, trackAction, reportedCertificateSuccessesRef.current) - // ── error route: surface through the TUI's handleError so the support // bundle + retryCount + telemetry UX is identical to the bespoke catch ── if (result.next === 'error') { @@ -2346,9 +2318,6 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres return } - if (step === 'verifying-key') - trackVerifiedIosKey(result, journeyId, trackAction) - // ── merge engine transient into the carried ref (threaded into the next // effect) AND mirror it into the React render state downstream code reads ── if (t) { @@ -2467,8 +2436,6 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres if (!next) return let advanceTo = next - if (step === 'backing-up' && (next === 'setup-method-select' || next === 'api-key-instructions')) - advanceTo = routeFreshIosSetupMethod(next === 'setup-method-select', journeyId, trackAction) if (step === 'verifying-key') { // The key is confirmed and the flow is moving on — NOW dismiss the // guided helper window (if it's still open on its success screen). @@ -2484,11 +2451,8 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres setStep(advanceTo) } catch (err) { - if (!cancelled) { - if (certificateEffectRunning) - trackIosCertificateCreationThrow(journeyId, trackAction) + if (!cancelled) handleErrorRef.current(err, step) - } } })() @@ -2642,9 +2606,6 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres const t: Partial | undefined = result.transient const np = result.progress - if (step === 'import-exporting') - trackIosKeychainExportResult(result, journeyId, trackAction) - // ── error route: surface through handleError so the support bundle + // retryCount + telemetry UX is identical to the bespoke catch ── if (result.next === 'error') { @@ -2948,9 +2909,6 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres const t = result.transient const np = result.progress - if (step === 'saving-credentials') - trackImportedIosCertificateSaveResult(result, deps.carried ?? {}, journeyId, trackAction, reportedCertificateSuccessesRef.current) - // ── Mirror engine transient → render state ───────────────────────────── if (t?.savedCredentials !== undefined) setSavedCredentials(t.savedCredentials) @@ -3451,8 +3409,13 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres if (existing?.ios) { setStep('credentials-exist') } + else if (isMacOS()) { + // macOS users see the fork: import existing or create new + setStep('setup-method-select') + } else { - setStep(routeFreshIosSetupMethod(isMacOS(), journeyId, trackAction)) + // Non-macOS hosts can only create new (importing requires Keychain) + setStep('api-key-instructions') } }} /> @@ -3477,7 +3440,7 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres if (existing?.ios) setStep('credentials-exist') else - setStep(routeFreshIosSetupMethod(isMacOS(), journeyId, trackAction)) + setStep('api-key-instructions') })() } else { @@ -3541,11 +3504,6 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres p8CreateMethod: value === 'import' ? existing.p8CreateMethod : undefined, } await saveProgress(appId, reduced) - trackAction('question_answered', { - attempt_id: journeyId, - question_id: 'ios_setup_method', - choice: value === 'import' ? 'import-existing' : 'create-new', - }) // Keep the React `importMode` mirror in sync (read by the // create-new effect driver's verifying-key guard + saving-credentials). @@ -3862,7 +3820,7 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres completedSteps: {}, } const reduced = applyIosInput('import-distribution-mode', base, { step: 'import-distribution-mode', value: value as 'app_store' | 'ad_hoc' | '__cancel__' }) - await saveImportDistributionAnswer(() => saveProgress(appId, reduced), value as 'app_store' | 'ad_hoc' | '__cancel__', journeyId, trackAction) + await saveProgress(appId, reduced) if (value === '__cancel__') { // The user bailed to the create-new path. Keep the React importMode diff --git a/cli/src/build/onboarding/ui/appflow-app.tsx b/cli/src/build/onboarding/ui/appflow-app.tsx index e2603b93a0..d26d1d0eac 100644 --- a/cli/src/build/onboarding/ui/appflow-app.tsx +++ b/cli/src/build/onboarding/ui/appflow-app.tsx @@ -44,7 +44,6 @@ const TOTAL_STAGES = 8 export interface AppflowAppProps { appId: string - packageName?: string /** Migration scope ('ios' | 'android') from the single-platform "migrating from Appflow?" gate. */ scope: MigrationScope apikey?: string @@ -55,7 +54,7 @@ export interface AppflowAppProps { onBeforeExit?: OnboardingBeforeExit } -const AppflowApp: FC = ({ appId, packageName, scope, apikey, supaHost, journeyId, onStep, onResult, onBeforeExit }) => { +const AppflowApp: FC = ({ appId, scope, apikey, supaHost, journeyId, onStep, onResult, onBeforeExit }) => { const { exit } = useApp() const { rows: terminalRows } = useTerminalSize() const [progress, setProgress] = useState(() => ({ scope, capgoAppId: appId, migratable: { ios: false, android: false }, completedSteps: [] })) @@ -68,7 +67,7 @@ const AppflowApp: FC = ({ appId, packageName, scope, apikey, su const [buildOutput, setBuildOutput] = useState([]) // Guards a single deps build (the appflow-side validators/token) + a single // in-flight auto effect per step. - const depsRef = useRef(buildAppflowEffectDeps({ appId, packageName: packageName ?? appId })) + const depsRef = useRef(buildAppflowEffectDeps({ appId, packageName: appId })) // Mirror buildOutput into a ref so finishMigration can lift the queued build // URL into the durable summary without taking buildOutput as a dependency // (which would rebuild the callback on every streamed line). diff --git a/cli/src/build/onboarding/ui/components.tsx b/cli/src/build/onboarding/ui/components.tsx index fbfec35da7..3b212afa53 100644 --- a/cli/src/build/onboarding/ui/components.tsx +++ b/cli/src/build/onboarding/ui/components.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import { Box, Text, useInput, useStdout } from 'ink' import Spinner from 'ink-spinner' // src/build/onboarding/ui/components.tsx -import React, { useEffect, useRef, useState } from 'react' +import React, { useEffect, useState } from 'react' import stringWidth from 'string-width' import { computeMaxScrollOffset, pickVisibleLines } from '../ai-fit.js' import type { DiffLine } from '../diff-utils.js' @@ -254,8 +254,6 @@ export const FilteredTextInput: FC<{ */ transform?: (value: string) => string mask?: boolean - /** Limit only the rendered mask; the full value is still submitted. */ - maxMaskWidth?: number /** * Pre-fills the input. Used when the user is editing an already-entered * value (e.g. fixing a typo in their ASC Key ID / Issuer ID after a @@ -264,18 +262,16 @@ export const FilteredTextInput: FC<{ */ initialValue?: string onSubmit: (value: string) => void -}> = ({ placeholder = '', filter = '=', allowedPattern, maxLength, transform, mask = false, maxMaskWidth, initialValue = '', onSubmit }) => { +}> = ({ placeholder = '', filter = '=', allowedPattern, maxLength, transform, mask = false, initialValue = '', onSubmit }) => { const [value, setValue] = useState(() => applyConstraints(initialValue, { filter, allowedPattern, maxLength, transform })) - const valueRef = useRef(value) useInput((input, key) => { if (key.return) { - onSubmit(valueRef.current) + onSubmit(value) return } if (key.backspace || key.delete) { - valueRef.current = valueRef.current.slice(0, -1) - setValue(valueRef.current) + setValue(prev => prev.slice(0, -1)) return } // Ignore control characters, arrows, etc. @@ -284,12 +280,11 @@ export const FilteredTextInput: FC<{ } // Append input then apply the full constraint pipeline (paste-safe). if (input) { - valueRef.current = applyConstraints(valueRef.current + input, { filter, allowedPattern, maxLength, transform }) - setValue(valueRef.current) + setValue(prev => applyConstraints(prev + input, { filter, allowedPattern, maxLength, transform })) } }) - const display = mask ? '•'.repeat(Math.min(value.length, maxMaskWidth ?? value.length)) : value + const display = mask ? '•'.repeat(value.length) : value const showCounter = maxLength !== undefined && !mask return ( diff --git a/cli/src/build/onboarding/ui/import-distribution-analytics.ts b/cli/src/build/onboarding/ui/import-distribution-analytics.ts deleted file mode 100644 index f77aa8310e..0000000000 --- a/cli/src/build/onboarding/ui/import-distribution-analytics.ts +++ /dev/null @@ -1,30 +0,0 @@ -type DistributionChoice = 'app_store' | 'ad_hoc' | '__cancel__' -type TrackAction = (action: 'question_shown' | 'question_answered', tags: Record) => void - -export function trackImportDistributionShown(journeyId: string, trackAction: TrackAction): void { - try { - trackAction('question_shown', { attempt_id: journeyId, question_id: 'ios_import_distribution' }) - } - catch { - // Telemetry must not interrupt onboarding. - } -} - -export async function saveImportDistributionAnswer( - save: () => Promise, - value: DistributionChoice, - journeyId: string, - trackAction: TrackAction, -): Promise { - await save() - try { - trackAction('question_answered', { - attempt_id: journeyId, - question_id: 'ios_import_distribution', - choice: value === '__cancel__' ? 'switch_to_create_new' : value, - }) - } - catch { - // Telemetry must not interrupt onboarding. - } -} diff --git a/cli/src/build/onboarding/ui/ios-certificate-action.ts b/cli/src/build/onboarding/ui/ios-certificate-action.ts deleted file mode 100644 index 359900f76a..0000000000 --- a/cli/src/build/onboarding/ui/ios-certificate-action.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { IosEffectResult, IosStepCtx } from '../ios/flow.js' -import type { OnboardingStep } from '../types.js' -import type { PreparationTrackAction } from './preparation-action.js' -import { emitPreparationAction, emitPreparationSuccessOnce } from './preparation-action.js' - -type TrackAction = PreparationTrackAction - -function emitCertificateAction( - trackAction: TrackAction, - action: 'certificate_prepared' | 'certificate_preparation_failed', - journeyId: string, - source: 'created' | 'keychain_import', - step: OnboardingStep, - reason?: 'create_failed' | 'certificate_limit' | 'export_failed', -): void { - emitPreparationAction(trackAction, { - action, - attemptId: journeyId, - source, - step, - tags: reason ? { reason } : undefined, - }) -} - -export function trackCreatedIosCertificateResult( - result: IosEffectResult, - journeyId: string, - trackAction: TrackAction, - reportedSuccesses: Set, -): void { - if (result.next === 'cert-limit-prompt' || result.transient?.certificateLimitReached) { - emitCertificateAction(trackAction, 'certificate_preparation_failed', journeyId, 'created', 'creating-certificate', 'certificate_limit') - return - } - if (result.next === 'error') { - emitCertificateAction(trackAction, 'certificate_preparation_failed', journeyId, 'created', 'creating-certificate', 'create_failed') - return - } - - const cert = result.progress.completedSteps.certificateCreated - if (result.next !== 'creating-profile' || !cert?.certificateId || !cert.p12Base64) - return - - const successKey = `created:${cert.certificateId}` - emitPreparationSuccessOnce(reportedSuccesses, successKey, trackAction, { - action: 'certificate_prepared', - attemptId: journeyId, - source: 'created', - step: 'creating-certificate', - }) -} - -export function trackIosCertificateCreationThrow(journeyId: string, trackAction: TrackAction): void { - emitCertificateAction(trackAction, 'certificate_preparation_failed', journeyId, 'created', 'creating-certificate', 'create_failed') -} - -export function trackIosKeychainExportResult(result: IosEffectResult, journeyId: string, trackAction: TrackAction): void { - if (result.next === 'error' && result.transient?.keychainP12ExportFailed) - emitCertificateAction(trackAction, 'certificate_preparation_failed', journeyId, 'keychain_import', 'import-exporting', 'export_failed') -} - -export function trackImportedIosCertificateSaveResult( - result: IosEffectResult, - carried: Partial, - journeyId: string, - trackAction: TrackAction, - reportedSuccesses: Set, -): void { - if (result.progress.setupMethod !== 'import-existing' || result.next !== 'ask-build' || !result.transient?.savedCredentials || !carried.keychainP12Exported || !carried.certData?.p12Base64) - return - - const successKey = `keychain_import:${carried.chosenIdentity?.sha1 ?? ''}` - emitPreparationSuccessOnce(reportedSuccesses, successKey, trackAction, { - action: 'certificate_prepared', - attemptId: journeyId, - source: 'keychain_import', - step: 'saving-credentials', - }) -} diff --git a/cli/src/build/onboarding/ui/ios-credential-action.ts b/cli/src/build/onboarding/ui/ios-credential-action.ts deleted file mode 100644 index 1b7699c8ca..0000000000 --- a/cli/src/build/onboarding/ui/ios-credential-action.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { IosEffectResult } from '../ios/flow.js' -import type { BuilderOnboardingAction } from '../telemetry.js' -import type { OnboardingStep } from '../types.js' -import { mapIosOnboardingError } from '../error-categories.js' - -type TrackAction = (action: BuilderOnboardingAction, tags: Record, step: OnboardingStep) => void - -function credentialTags(journeyId: string): Record { - return { credential: 'ios_app_store_connect_api_key', attempt_id: journeyId } -} - -export function trackGuidedKeyValidationFailure( - eventName: string, - journeyId: string, - trackAction: TrackAction, - cancelled = false, -): void { - if (cancelled || eventName !== 'validation_failed') - return - - trackAction('credential_verification_failed', { - ...credentialTags(journeyId), - source: 'guided_helper', - }, 'asc-key-generating') -} - -export async function verifyIosKeyWithTelemetry( - verify: () => Promise, - journeyId: string, - trackAction: TrackAction, - isCancelled: () => boolean, -): Promise { - try { - return await verify() - } - catch (error) { - if (!isCancelled()) { - trackAction('credential_verification_failed', { - ...credentialTags(journeyId), - source: 'cli_verifier', - error_category: mapIosOnboardingError(error, 'verifying-key'), - }, 'verifying-key') - } - throw error - } -} - -export function trackVerifiedIosKey( - result: IosEffectResult, - journeyId: string, - trackAction: TrackAction, -): void { - if (result.next === 'error' || !result.progress.completedSteps.apiKeyVerified) - return - - trackAction('credential_verified', credentialTags(journeyId), 'verifying-key') -} diff --git a/cli/src/build/onboarding/ui/login-gate.tsx b/cli/src/build/onboarding/ui/login-gate.tsx deleted file mode 100644 index 758811c165..0000000000 --- a/cli/src/build/onboarding/ui/login-gate.tsx +++ /dev/null @@ -1,308 +0,0 @@ -import type { FC, ReactNode } from 'react' -import type { BrowserLoginSession } from '../../../init/browser-login.js' -import type { BuilderLoginServices } from '../login.js' -import { Select } from '@inkjs/ui' -import { Box, Text, useInput } from 'ink' -import Spinner from 'ink-spinner' -import React, { useEffect, useRef, useState } from 'react' -import { Header, FilteredTextInput } from './components.js' -import { pickPlatformLayout } from './frame-fit.js' -import { CardChooser } from './platform-picker.js' - -type LoginMethod = 'browser' | 'paste' -type LoginView = 'checking' | 'candidate-error' | 'choice' | 'opening' | 'entry' | 'verifying' | 'identifying' | 'welcome' - -export interface BuilderLoginMetadata { - method?: LoginMethod - retryCount: number - durationMs: number -} - -export interface BuilderLoginGateProps { - candidateKey?: string - services: BuilderLoginServices - cols: number - rows: number - footer?: ReactNode - onAuthenticated: (key: string, metadata: BuilderLoginMetadata) => void - onCancel: () => void -} - -const BuilderLoginGate: FC = ({ candidateKey, services, cols, rows, footer, onAuthenticated, onCancel }) => { - const [view, setView] = useState(candidateKey ? 'checking' : services.browserAvailable ? 'choice' : 'entry') - const [method, setMethod] = useState(services.browserAvailable ? undefined : 'paste') - const [session, setSession] = useState() - const [browserUrl, setBrowserUrl] = useState() - const [error, setError] = useState() - const [accountEmail, setAccountEmail] = useState() - const [inputRevision, setInputRevision] = useState(0) - const attempts = useRef(0) - const shownAt = useRef(Date.now()) - const cancelled = useRef(false) - const welcomeTimer = useRef | undefined>(undefined) - const authenticatedCallback = useRef(onAuthenticated) - authenticatedCallback.current = onAuthenticated - const compact = cols < 64 || rows < 18 - - const showWelcome = async (key: string, metadata: BuilderLoginMetadata) => { - setView('identifying') - let lookupTimer: ReturnType | undefined - const email = await Promise.race([ - services.getAccountEmail(key).catch(() => undefined), - new Promise((resolve) => { - lookupTimer = setTimeout(() => resolve(undefined), 2000) - }), - ]) - if (lookupTimer) - clearTimeout(lookupTimer) - if (cancelled.current) - return - setAccountEmail(email) - setView('welcome') - welcomeTimer.current = setTimeout(() => { - if (!cancelled.current) - authenticatedCallback.current(key, metadata) - }, 1500) - } - - useEffect(() => () => { - cancelled.current = true - if (welcomeTimer.current) - clearTimeout(welcomeTimer.current) - }, []) - - useEffect(() => { - if (!candidateKey) - return - void services.validateExisting(candidateKey) - .then(() => { - if (!cancelled.current) - void showWelcome(candidateKey, { retryCount: 0, durationMs: 0 }) - }) - .catch(() => { - if (!cancelled.current) { - setError("We couldn't verify this API key. Retry or use another key.") - setView('candidate-error') - } - }) - }, [candidateKey, services]) - - const cancel = () => { - cancelled.current = true - onCancel() - } - - const retryCandidate = () => { - if (!candidateKey) - return - setError(undefined) - setView('checking') - void services.validateExisting(candidateKey) - .then(() => { - if (!cancelled.current) - void showWelcome(candidateKey, { retryCount: 0, durationMs: 0 }) - }) - .catch(() => { - if (!cancelled.current) { - setError("We couldn't verify this API key. Retry or use another key.") - setView('candidate-error') - } - }) - } - - const chooseMethod = (choice: string) => { - if (choice !== 'browser' && choice !== 'paste') - return - setMethod(choice) - setError(undefined) - if (choice === 'paste') { - setView('entry') - return - } - if (session) { - setBrowserUrl(session.url) - setView('entry') - return - } - setView('opening') - void services.beginBrowser(setBrowserUrl) - .then((openedSession) => { - if (cancelled.current) - return - setSession(openedSession) - setView('entry') - }) - .catch(() => { - if (!cancelled.current) { - setError('Could not open the dashboard. Select a login method to try again.') - setView('choice') - } - }) - } - - const submit = (value: string) => { - const key = value.trim() - if (!key) { - setError('API key is required.') - return - } - attempts.current += 1 - setError(undefined) - setView('verifying') - const verify = method === 'browser' && session - ? services.completeBrowser(session, key) - : services.savePasted(key) - void verify - .then(() => { - if (!cancelled.current) { - void showWelcome(key, { - method: method ?? 'paste', - retryCount: attempts.current - 1, - durationMs: Date.now() - shownAt.current, - }) - } - }) - .catch(() => { - if (!cancelled.current) { - setInputRevision(revision => revision + 1) - setError("We couldn't verify that key. Paste another key and try again.") - setView('entry') - } - }) - } - - useInput((_input, key) => { - if (key.escape && view !== 'welcome') - cancel() - else if (key.tab && view === 'entry' && services.browserAvailable) { - setError(undefined) - setView('choice') - } - }) - - const choiceOptions = [ - { value: 'browser', emoji: '🌎', name: 'Open browser', hint: 'Create key in Dashboard' }, - { value: 'paste', emoji: '📋', name: 'Paste API key', hint: 'Use an existing key' }, - ] - - const statusText = view === 'checking' - ? 'Checking Capgo login…' - : view === 'opening' - ? 'Opening the Capgo Dashboard…' - : view === 'verifying' - ? 'Checking API key…' - : view === 'identifying' - ? 'Getting account details…' - : undefined - - return ( - - {compact - ? Capgo Cloud Build · Login - :
} - {statusText && ( - - - - {statusText} - - - )} - {view === 'welcome' && ( - - ✔ - - {accountEmail ? `Welcome ${accountEmail} 👋` : 'Welcome to Capgo 👋'} - - - )} - {view === 'candidate-error' && (compact - ? ( - - We couldn't verify your Capgo login. - - - ) - : - )} - {view === 'entry' && ( - - Paste the API key from the Capgo Dashboard - {method === 'browser' && browserUrl && ( - - {session?.browserOpened ? 'Dashboard URL:' : 'Open this URL in your browser:'} - {browserUrl} - - )} - - - - {error && {error}} - {services.browserAvailable ? 'Enter verify · Tab change method · Esc cancel' : 'Enter verify · Esc cancel'} - {!compact && footer} - - )} - - ) -} - -export default BuilderLoginGate diff --git a/cli/src/build/onboarding/ui/preparation-action.ts b/cli/src/build/onboarding/ui/preparation-action.ts deleted file mode 100644 index 859e35ab99..0000000000 --- a/cli/src/build/onboarding/ui/preparation-action.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { BuilderOnboardingAction } from '../telemetry.js' - -export type PreparationTrackAction = ( - action: BuilderOnboardingAction, - tags: Record, - step: TStep, -) => void - -interface PreparationActionInput { - action: BuilderOnboardingAction - attemptId: string - source: string - step: TStep - tags?: Record -} - -export function emitPreparationAction( - trackAction: PreparationTrackAction, - input: PreparationActionInput, -): void { - try { - trackAction(input.action, { - attempt_id: input.attemptId, - source: input.source, - ...input.tags, - }, input.step) - } - catch { - // Even a synchronous telemetry failure must not interrupt onboarding. - } -} - -export function emitPreparationSuccessOnce( - reportedSuccesses: Set, - successKey: string, - trackAction: PreparationTrackAction, - input: PreparationActionInput, -): void { - if (reportedSuccesses.has(successKey)) - return - reportedSuccesses.add(successKey) - emitPreparationAction(trackAction, input) -} diff --git a/cli/src/build/onboarding/ui/setup-method-route.ts b/cli/src/build/onboarding/ui/setup-method-route.ts deleted file mode 100644 index 213ea74ecc..0000000000 --- a/cli/src/build/onboarding/ui/setup-method-route.ts +++ /dev/null @@ -1,22 +0,0 @@ -type TrackSkip = ( - action: 'question_skipped', - tags: { attempt_id: string, question_id: string, choice: string, reason: string }, - step: 'setup-method-select', -) => void - -export function routeFreshIosSetupMethod( - onMac: boolean, - journeyId: string, - trackAction: TrackSkip, -): 'setup-method-select' | 'api-key-instructions' { - if (onMac) - return 'setup-method-select' - - trackAction('question_skipped', { - attempt_id: journeyId, - question_id: 'ios_setup_method', - choice: 'create-new', - reason: 'non_macos_auto_create_new', - }, 'setup-method-select') - return 'api-key-instructions' -} diff --git a/cli/src/build/onboarding/ui/shell.tsx b/cli/src/build/onboarding/ui/shell.tsx index 6afe4917ea..af495cbebe 100644 --- a/cli/src/build/onboarding/ui/shell.tsx +++ b/cli/src/build/onboarding/ui/shell.tsx @@ -1,9 +1,5 @@ import type { FC } from 'react' import type { OnboardingResult, Platform } from '../types.js' -import type { BuilderLoginServices } from '../login.js' -import type { BuilderLoginMetadata } from './login-gate.js' -import type { BuilderAppSelectionServices } from '../app-selection.js' -import type { AppSelectionEvent } from './app-selection-gate.js' // src/build/onboarding/ui/shell.tsx // // Top-level wizard shell, rendered ONCE inside the alt-screen buffer @@ -36,8 +32,6 @@ import { TerminalTooSmallPrompt } from './min-size-gate.js' import { CardChooser, PlatformPicker } from './platform-picker.js' import { exitAfterOnboardingBeforeExit } from './exit.js' import { UpdatePrompt } from './update-prompt.js' -import BuilderLoginGate from './login-gate.js' -import BuilderAppSelectionGate from './app-selection-gate.js' import type { OnboardingBeforeExit } from './exit.js' // Progress shapes derived from the loaders so we don't re-import the type names. @@ -86,17 +80,15 @@ export function useTerminalSize(): { cols: number, rows: number } { export interface OnboardingShellProps { appId: string - suggestedSource: 'builder' | 'capacitor' - appSelectionServices: BuilderAppSelectionServices /** * iOS-side bundle id default — sourced from `config.appId` (top-level), which * is what `cap sync` writes into `PRODUCT_BUNDLE_IDENTIFIER`. Distinct from - * the Capgo app ID selected by this shell, which may use the Builder override - * or another visible app. Threaded to the iOS OnboardingApp; Android ignores it. + * `appId` above, which `getAppId()` may resolve to + * `config.plugins.CapacitorUpdater.appId` (a Capgo lookup key — wrong for + * Apple signing). Threaded down to the iOS OnboardingApp; the Android app + * ignores it. */ iosBundleIdInitial: string - /** Android package name for Appflow validation; defaults to the legacy Capgo key. */ - appflowPackageName?: string iosDir: string androidDir: string /** @@ -106,7 +98,6 @@ export interface OnboardingShellProps { */ guidedHelperUsable: boolean apikey?: string - loginServices: BuilderLoginServices supaHost?: string /** Custom Supabase anon key for self-hosting (--supa-anon). */ supaAnon?: string @@ -134,10 +125,6 @@ export interface OnboardingShellProps { onResult?: (result: OnboardingResult) => void /** Awaited immediately before Ink exits so replay can capture the alt-screen frame. */ onBeforeExit?: OnboardingBeforeExit - /** Called after the login gate has verified a key. */ - onAuthenticated?: (key: string, metadata: BuilderLoginMetadata) => void - onAppSelected?: (appId: string) => void - onAppSelectionEvent?: (event: AppSelectionEvent) => void } const AnalyticsNotice: FC = () => ( @@ -146,13 +133,10 @@ const AnalyticsNotice: FC = () => ( ) -const OnboardingShell: FC = ({ appId, suggestedSource, appSelectionServices, iosBundleIdInitial, appflowPackageName, iosDir, androidDir, guidedHelperUsable, apikey, loginServices, supaHost, supaAnon, journeyId, initialPlatform, updateInfo, analyticsNotice, onResolvePlatform, onStep, onResult, onBeforeExit, onAuthenticated, onAppSelected, onAppSelectionEvent }) => { +const OnboardingShell: FC = ({ appId, iosBundleIdInitial, iosDir, androidDir, guidedHelperUsable, apikey, supaHost, supaAnon, journeyId, initialPlatform, updateInfo, analyticsNotice, onResolvePlatform, onStep, onResult, onBeforeExit }) => { const { exit } = useApp() const { cols, rows } = useTerminalSize() const [ready, setReady] = useState(null) - const [authenticatedKey, setAuthenticatedKey] = useState() - const [selectedAppId, setSelectedAppId] = useState() - const [switchingKey, setSwitchingKey] = useState(false) // Set when progress loading fails (e.g. corrupt saved-progress JSON). loadProgress // throws for non-ENOENT errors, so without a rejection handler `choose` would // leave an unhandled promise rejection and the picker stuck with no feedback. @@ -173,10 +157,8 @@ const OnboardingShell: FC = ({ appId, suggestedSource, app // The picker stays on screen during the (few-ms) load, so there's no loading // frame on the picker path. const choose = useCallback((platform: Platform) => { - if (!selectedAppId) - return onResolvePlatform?.(platform) - void loadReady(platform, selectedAppId) + void loadReady(platform, appId) .then(setReady) .catch((err: unknown) => { // Surface the failure instead of hanging: show an error frame, report a @@ -187,7 +169,7 @@ const OnboardingShell: FC = ({ appId, suggestedSource, app onResult?.({ outcome: 'cancelled' }) setTimeout(exitAfterBeforeExit, 50) }) - }, [selectedAppId, onResolvePlatform, onResult, exitAfterBeforeExit]) + }, [appId, onResolvePlatform, onResult, exitAfterBeforeExit]) // Picker answer. The picker only yields iOS / Android now; both pass through // the "migrating from Appflow?" gate before committing to native onboarding. @@ -216,21 +198,16 @@ const OnboardingShell: FC = ({ appId, suggestedSource, app useEffect(() => { // Hold the auto-load until the update prompt (if any) is answered, so the // update offer is the first screen even when --platform pre-resolves. - if (authenticatedKey && selectedAppId && initialPlatform && (!updateInfo || updateAnswered)) + if (initialPlatform && (!updateInfo || updateAnswered)) choose(initialPlatform) - }, [authenticatedKey, selectedAppId, initialPlatform, choose, updateInfo, updateAnswered]) - - useEffect(() => { - if (authenticatedKey && !selectedAppId && !switchingKey) - onStep?.('app-selection') - }, [authenticatedKey, selectedAppId, switchingKey, onStep]) + }, [initialPlatform, choose, updateInfo, updateAnswered]) // Progress load failed (corrupt/unreadable saved state) — show why and exit, // rather than hanging on a frozen picker. The exit is scheduled in the .catch. if (loadError) { return ( - {`✖ Could not load onboarding progress for ${selectedAppId ?? appId}.`} + {`✖ Could not load onboarding progress for ${appId}.`} {loadError} Your saved progress file may be corrupt. Remove it and re-run `capgo build init`. @@ -245,12 +222,12 @@ const OnboardingShell: FC = ({ appId, suggestedSource, app // here would unmount it on a mid-flow shrink, tearing down step state and // exiting the wizard. The app owns the size decision so a shrink→regrow keeps // the user exactly where they were. - if (ready?.kind === 'ios' && selectedAppId) - return - if (ready?.kind === 'android' && selectedAppId) - return - if (ready?.kind === 'appflow' && selectedAppId) - return + if (ready?.kind === 'ios') + return + if (ready?.kind === 'android') + return + if (ready?.kind === 'appflow') + return // Not ready yet: the platform picker (or a brief framed load). The picker is // NOT gated to the full 80×49 onboarding floor — it's small and adapts @@ -293,56 +270,6 @@ const OnboardingShell: FC = ({ appId, suggestedSource, app ) } - if (!authenticatedKey || switchingKey) { - return ( - : undefined} - onAuthenticated={(key, metadata) => { - setAuthenticatedKey(key) - setSwitchingKey(false) - onAuthenticated?.(key, metadata) - }} - onCancel={() => { - if (switchingKey) { - setSwitchingKey(false) - return - } - onResult?.({ outcome: 'cancelled' }) - setTimeout(exitAfterBeforeExit, 50) - }} - /> - ) - } - - if (!selectedAppId) { - return ( - : undefined} - onSelected={(chosenId) => { - setSelectedAppId(chosenId) - onAppSelected?.(chosenId) - }} - onSwitchKey={() => setSwitchingKey(true)} - onCancel={() => { - onResult?.({ outcome: 'cancelled' }) - setTimeout(exitAfterBeforeExit, 50) - }} - onEvent={onAppSelectionEvent} - /> - ) - } - // Migration gate: the user picked iOS / Android — ask whether they are // migrating from Ionic Appflow before committing to native onboarding. YES // routes into the Appflow migration scoped to that platform; NO continues to diff --git a/cli/src/build/prescan/checks/ios-entitlements-checks.ts b/cli/src/build/prescan/checks/ios-entitlements-checks.ts index 9c491a5c71..203304be0b 100644 --- a/cli/src/build/prescan/checks/ios-entitlements-checks.ts +++ b/cli/src/build/prescan/checks/ios-entitlements-checks.ts @@ -18,11 +18,11 @@ import { plistArrayStrings } from './ios-plist-read' import { parseProvisioningMap } from './ios-profiles' function primaryProvisioningProfile(ctx: ScanContext): ReturnType[number] | undefined { - return parseProvisioningMap(ctx).find(profile => profile.bundleId === (ctx.nativeAppId ?? ctx.appId)) + return parseProvisioningMap(ctx).find(profile => profile.bundleId === ctx.appId) } const hasPrimaryProfile = (ctx: ScanContext): boolean => primaryProvisioningProfile(ctx) !== undefined -const hasAppEntitlements = (ctx: ScanContext): boolean => readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) !== null +const hasAppEntitlements = (ctx: ScanContext): boolean => readAppEntitlements(ctx.projectDir, ctx.appId) !== null /** * Independent evidence the app actually uses push: the Info.plist declares the @@ -143,7 +143,7 @@ export const entitlementsVsProfileCapability: PrescanCheck = { platforms: ['ios'], appliesTo: ctx => hasPrimaryProfile(ctx) && hasAppEntitlements(ctx), async run(ctx): Promise { - const app = readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) + const app = readAppEntitlements(ctx.projectDir, ctx.appId) const profile = primaryProvisioningProfile(ctx) if (!app || !profile) return [] @@ -196,11 +196,11 @@ export const apsEnvironmentVsMode: PrescanCheck = { id: 'ios/entitlements-aps-environment-vs-mode', platforms: ['ios'], appliesTo: (ctx) => { - const app = readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) + const app = readAppEntitlements(ctx.projectDir, ctx.appId) return app !== null && entString(app.raw, 'aps-environment') !== null && Boolean(ctx.distributionMode) }, async run(ctx): Promise { - const app = readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) + const app = readAppEntitlements(ctx.projectDir, ctx.appId) if (!app) return [] const value = entString(app.raw, 'aps-environment') @@ -265,11 +265,11 @@ export const associatedDomainsFormat: PrescanCheck = { id: 'ios/entitlements-associated-domains-format', platforms: ['ios'], appliesTo: (ctx) => { - const app = readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) + const app = readAppEntitlements(ctx.projectDir, ctx.appId) return app !== null && entArray(app.raw, ASSOCIATED_DOMAIN_KEY).length > 0 }, async run(ctx): Promise { - const app = readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) + const app = readAppEntitlements(ctx.projectDir, ctx.appId) if (!app) return [] const bad: string[] = [] @@ -300,11 +300,11 @@ export const appGroupsFormat: PrescanCheck = { id: 'ios/entitlements-app-groups-format', platforms: ['ios'], appliesTo: (ctx) => { - const app = readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) + const app = readAppEntitlements(ctx.projectDir, ctx.appId) return app !== null && entArray(app.raw, APP_GROUP_KEY).length > 0 }, async run(ctx): Promise { - const app = readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) + const app = readAppEntitlements(ctx.projectDir, ctx.appId) if (!app) return [] const bad = entArray(app.raw, APP_GROUP_KEY).filter(g => !APP_GROUP_RE.test(g)) @@ -343,7 +343,7 @@ export const entitlementsDeclaredAgeRange: PrescanCheck = { platforms: ['ios'], appliesTo: ctx => packageHasDependency(ctx.projectDir, AGE_RANGE_PLUGIN) && willUploadToAppStore(ctx), async run(ctx): Promise { - const app = readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) + const app = readAppEntitlements(ctx.projectDir, ctx.appId) if (app !== null && entBool(app.raw, DECLARED_AGE_RANGE_KEY) === true) return [] return [{ diff --git a/cli/src/build/prescan/command.ts b/cli/src/build/prescan/command.ts index 7ab794c9fc..6bb937a503 100644 --- a/cli/src/build/prescan/command.ts +++ b/cli/src/build/prescan/command.ts @@ -57,7 +57,6 @@ export function exitCodeFor(counts: Record, opts: OutcomeOptio export interface PrescanExecution { report: PrescanReport - appId: string /** apikey actually used for the scan (flag or saved key); undefined when remote checks were skipped */ apikey?: string } @@ -90,14 +89,14 @@ export async function executePrescan(appId: string | undefined, options: Prescan const overrides = parsePrescanOverrides({ skip: options.skip, warn: options.warn }) validateOverrideIds(overrides, ALL_CHECK_IDS) const report = await runPrescan(ctx, ALL_CHECKS, { overrides }) - return { report, appId: ctx.appId, apikey } + return { report, apikey } } export async function prescanCommand(appId: string | undefined, options: PrescanCommandOptions): Promise { validateFlags(options) if (!options.json) intro('Capgo build prescan') - const { report, appId: resolvedAppId, apikey: apikeyUsedForScan } = await executePrescan(appId, options) + const { report, apikey: apikeyUsedForScan } = await executePrescan(appId, options) if (options.json) { console.log(renderJsonReport(report)) } @@ -115,7 +114,7 @@ export async function prescanCommand(appId: string | undefined, options: Prescan tags: { 'source': 'standalone', 'result': enforced.error > 0 ? (options.ignoreFatal ? 'bypassed' : 'blocked') : enforced.warning > 0 ? (options.failOnWarnings ? 'blocked' : 'warned') : informationOnly > 0 ? 'information-only' : 'clean', - 'app-id': resolvedAppId, + 'app-id': appId ?? 'unknown', 'platform': options.platform ?? 'unknown', 'errors': String(report.counts.error), 'warnings': String(report.counts.warning), diff --git a/cli/src/build/prescan/context.ts b/cli/src/build/prescan/context.ts index 95cd2599a8..aade96c766 100644 --- a/cli/src/build/prescan/context.ts +++ b/cli/src/build/prescan/context.ts @@ -6,7 +6,6 @@ import { getConfig } from '../../utils' import { CliUserError } from '../../shared/cli-user-error' import { mergeCredentials } from '../credentials' import { withCwd } from '../cwd' -import { getBuilderAppId, hasBuilderAppIdField } from '../app-id' export interface BuildScanContextArgs { appId?: string @@ -29,14 +28,12 @@ export async function buildScanContext(args: BuildScanContextArgs): Promise getConfig(true))).config } catch { config = undefined } // no capacitor project — checks degrade individually - const appId = getBuilderAppId(args.appId, config, 'native', 'defined') + const appId = args.appId ?? config?.appId if (!appId) throw new CliUserError('Missing appId: pass it explicitly or run inside a Capacitor project') - const nativeAppId = hasBuilderAppIdField(config) ? config?.appId ?? appId : appId const credentials = args.credentials ?? (await mergeCredentials(appId, args.platform) as Record | undefined) return { appId, - nativeAppId, platform: args.platform, hostPlatform: process.platform, projectDir: args.projectDir, diff --git a/cli/src/build/prescan/types.ts b/cli/src/build/prescan/types.ts index 6abae8a820..3a040a7b42 100644 --- a/cli/src/build/prescan/types.ts +++ b/cli/src/build/prescan/types.ts @@ -24,8 +24,6 @@ export interface Finding { export interface ScanContext { appId: string - /** Native bundle/package id for local checks; defaults to appId for legacy scans. */ - nativeAppId?: string platform: Platform /** Operating system running the prescan, used for host-specific local checks. */ hostPlatform: NodeJS.Platform diff --git a/cli/src/build/request.ts b/cli/src/build/request.ts index c24d7362ff..9720b7bb92 100644 --- a/cli/src/build/request.ts +++ b/cli/src/build/request.ts @@ -67,7 +67,6 @@ import { uploadSupportLogs } from '../support/support-upload.js' import { offerSupportUploadBeforeAi } from '../support/support-upload-prompt.js' import { buildCliRequestHeaders } from '../analytics/cli-headers' import { assertCliPermission, canPromptInteractively, createSupabaseClient, findSavedKey, getConfig, getOrganizationId, getRemoteConfig, sendEvent, trimTrailingSlashes, TUS_UPLOAD_RETRY_DELAYS } from '../utils' -import { getBuilderAppId } from './app-id' import { syncAndroidVersion } from './android-version' import { createBuildCancellationSignalHandler, requestBuildCancellation } from './cancellation' import { mergeCredentials, MIN_OUTPUT_RETENTION_SECONDS, parseAndroidPlayStoreReleaseStatus, parseAndroidPlayStoreTrack, parseInAppUpdatePriority, parseOptionalBoolean, parseOutputRetentionSeconds } from './credentials' @@ -1467,7 +1466,7 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO // @capacitor/cli loadConfig() is cwd-based; honor --path for monorepos/workspaces. const config = await withCwd(projectDir, () => getConfig()) - appId = getBuilderAppId(appId, config?.config, 'native') || '' + appId = appId || config?.config?.appId if (!appId) { throw new Error('Missing argument, you need to provide a appId, or be in a capacitor project') diff --git a/cli/src/bundle/upload-config.ts b/cli/src/bundle/upload-config.ts deleted file mode 100644 index ef85f61c5f..0000000000 --- a/cli/src/bundle/upload-config.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { ExtConfigPairs } from '../config' -import type { OptionsUpload } from './upload_interface' -import { buildCordovaUploadConfig } from '../cordova/project' -import { isCordovaMode } from '../framework/mode' -import { CliUserError } from '../shared/cli-user-error' -import { getConfig, NO_CAPACITOR_CONFIG_MESSAGE } from '../utils' - -export function buildCordovaModeUploadExample(options: Pick & { appId?: string }): string { - const appId = options.appId?.trim() || '' - const path = options.path?.trim() || 'www' - const channel = options.channel?.trim() || '' - return `npx @capgo/cli@latest bundle upload ${appId} --mode cordova --path ${path} --channel ${channel}` -} - -export function buildMissingCapacitorConfigUploadMessage(options: Pick & { appId?: string }): string { - return [ - NO_CAPACITOR_CONFIG_MESSAGE, - 'If this is a Cordova project (config.xml / plugin.xml and a www folder), retry with:', - buildCordovaModeUploadExample(options), - ].join('\n') -} - -export function enhanceMissingCapacitorConfigUploadError( - error: unknown, - options: Pick & { appId?: string }, -): never { - if (error instanceof CliUserError && error.message === NO_CAPACITOR_CONFIG_MESSAGE) { - throw new CliUserError(buildMissingCapacitorConfigUploadMessage({ - appId: options.appId, - path: options.path, - channel: options.channel, - })) - } - throw error -} - -export async function loadUploadProjectConfig( - options: OptionsUpload, - context: { appId?: string } = {}, -): Promise { - if (isCordovaMode(options.mode)) - return buildCordovaUploadConfig({ path: options.path, appId: context.appId }) - - try { - return await getConfig() - } - catch (error) { - enhanceMissingCapacitorConfigUploadError(error, { - appId: context.appId, - path: options.path, - channel: options.channel, - }) - } -} diff --git a/cli/src/bundle/upload.ts b/cli/src/bundle/upload.ts index 85b5e2db4c..6187e10ea2 100644 --- a/cli/src/bundle/upload.ts +++ b/cli/src/bundle/upload.ts @@ -25,18 +25,15 @@ import { showReplicationProgress } from '../replicationProgress' import { CliUserError } from '../shared/cli-user-error' import { formatTable } from '../terminal-table' import { usesAlwaysDirectUpdate } from '../updaterConfig' -import { baseKeyV2, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, canPromptInteractively, channelUpdatePackageCliError, checkCompatibilityCloud, checkPlanValidUpload, checkRemoteCliMessages, createSupabaseClient, deletedFailedVersion, deltaManifestTooLargeMessage, findRoot, findSavedKey, formatError, getBundleVersion, getCompatibilityDetails, getInstalledVersion, getLocalConfig, getLocalDependencies, getOrganizationId, getPMAndCommand, getRemoteChecksums, getRemoteFileConfig, hasCliPermission, invokeCapgoCliApi, isCompatible, isDeprecatedPluginVersion, MAX_MANIFEST_ENTRIES, regexSemver, resolveUserIdFromApiKey, sendEvent, setVersionManifest, updateConfigUpdater, updateOrCreateChannel, updateOrCreateVersion, UPLOAD_TIMEOUT, UPLOAD_TIMEOUT_ERROR_NAME, uploadTimeoutMessage, uploadTUS, uploadUrl, zipFile } from '../utils' +import { baseKeyV2, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, canPromptInteractively, channelUpdatePackageCliError, checkCompatibilityCloud, checkPlanValidUpload, checkRemoteCliMessages, createSupabaseClient, deletedFailedVersion, deltaManifestTooLargeMessage, findRoot, findSavedKey, formatError, getBundleVersion, getCompatibilityDetails, getConfig, getInstalledVersion, getLocalConfig, getLocalDependencies, getOrganizationId, getPMAndCommand, getRemoteChecksums, getRemoteFileConfig, hasCliPermission, invokeCapgoCliApi, isCompatible, isDeprecatedPluginVersion, MAX_MANIFEST_ENTRIES, regexSemver, resolveUserIdFromApiKey, sendEvent, setVersionManifest, updateConfigUpdater, updateOrCreateChannel, updateOrCreateVersion, UPLOAD_TIMEOUT, UPLOAD_TIMEOUT_ERROR_NAME, uploadTimeoutMessage, uploadTUS, uploadUrl, zipFile } from '../utils' import type { AutoBumpLevel } from '../versionHelpers' import { autoBumpVersionBy, getVersionSuggestions, interactiveVersionBump, normalizeAutoBumpInput } from '../versionHelpers' import { resolveAutoBumpLevelFromAi } from './auto-bump-ai' import { maybePromptBuilderCta, shouldBlockIncompatibleUpload } from './builder-cta' import { checkIndexPosition, searchInDirectory } from './check' import { summarizeUploadCompatibility } from './compatibility' -import { CORDOVA_DEFAULT_WEB_DIR } from '../cordova/project' -import { isCordovaMode } from '../framework/mode' import { ensureNotifyAppReadyInBuildFolder } from '../recovery/notify-app-ready' import { parsePackageJsonOptionPaths, resolveAppIdWithRecovery } from '../recovery/app-id' -import { loadUploadProjectConfig } from './upload-config' import { prepareBundlePartialFiles, uploadPartial } from './partial' import { clackUploadReporter, getUploadReporter, runWithUploadReporter } from './reporter' import { formatUploadChannels, getChannelsToAssignByChecksum, parseUploadChannels } from './upload-channels' @@ -160,17 +157,13 @@ async function getAppIdAndPath(appId: string | undefined, options: OptionsUpload supaHost: options.supaHost, supaAnon: options.supaAnon, }) - const path = options.path || config?.webDir || (isCordovaMode(options.mode) ? CORDOVA_DEFAULT_WEB_DIR : undefined) + const path = options.path || config?.webDir if (!finalAppId) { - uploadFail(isCordovaMode(options.mode) - ? 'Missing appId. Pass it on the command line or set id in config.xml / plugin.xml' - : 'Missing argument, you need to provide a appid or be in a capacitor project') + uploadFail('Missing argument, you need to provide a appid or be in a capacitor project') } if (!path) { - uploadFail(isCordovaMode(options.mode) - ? `Missing upload path. Pass --path or use the default Cordova web dir (${CORDOVA_DEFAULT_WEB_DIR})` - : 'Missing argument, you need to provide a path (--path), or be in a capacitor project') + uploadFail('Missing argument, you need to provide a path (--path), or be in a capacitor project') } if (!existsSync(path)) { @@ -475,34 +468,6 @@ function shouldSendAppTooLargeEvent(options: OptionsUpload): boolean { return shouldUploadFullZip(options) || hasCompleteS3UploadConfig(options) } -const CORDOVA_UPDATER_PACKAGES = [ - '@capgo/cordova-updater', - 'cordova-plugin-capgo', -] as const - -type ResolvedUpdaterForUpload = { - packageName: string - version: string -} - -function isCordovaUpdaterPackage(packageName: string): boolean { - return (CORDOVA_UPDATER_PACKAGES as readonly string[]).includes(packageName) -} - -async function resolveUpdaterForUpload(options: OptionsUpload, root: string): Promise { - if (!isCordovaMode(options.mode)) { - const version = await getInstalledVersion('@capgo/capacitor-updater', root, options.packageJson) - return version ? { packageName: '@capgo/capacitor-updater', version } : null - } - - for (const packageName of CORDOVA_UPDATER_PACKAGES) { - const version = await getInstalledVersion(packageName, root, options.packageJson) - if (version) - return { packageName, version } - } - return null -} - async function prepareBundleFile(path: string, options: OptionsUpload, apikey: string, orgId: string, appid: string, maxUploadLength: number, alertUploadSize: number, publicKeyFromConfig?: string) { let ivSessionKey let sessionKey @@ -519,9 +484,7 @@ async function prepareBundleFile(path: string, options: OptionsUpload, apikey: s zipped = await zipFile(path) s.message(`Calculating checksum`) const root = findRoot(cwd()) - const resolvedUpdater = await resolveUpdaterForUpload(options, root) - const updaterVersion = resolvedUpdater?.version - const updaterPackageName = resolvedUpdater?.packageName + const updaterVersion = await getInstalledVersion('@capgo/capacitor-updater', root, options.packageJson) let useSha256 = false let coerced try { @@ -530,24 +493,15 @@ async function prepareBundleFile(path: string, options: OptionsUpload, apikey: s catch { coerced = undefined } - if (!resolvedUpdater) { - if (isCordovaMode(options.mode)) { - log.warn('Cannot find a Capgo updater plugin in node_modules. Using SHA256 checksum for this Cordova upload.') - useSha256 = true - } - else { - uploadFail('Cannot find @capgo/capacitor-updater in node_modules, please install it first with your package manager') - } - } - else if (updaterPackageName && isCordovaUpdaterPackage(updaterPackageName)) { - useSha256 = true + if (!updaterVersion) { + uploadFail('Cannot find @capgo/capacitor-updater in node_modules, please install it first with your package manager') } else if (coerced) { // Use SHA256 for v5.10.0+, v6.25.0+ and v7.0.30+ useSha256 = !isDeprecatedPluginVersion(coerced, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7) } else if (updaterVersion === 'link:@capgo/capacitor-updater' || updaterVersion === 'file:..' || updaterVersion === 'file:../') { - log.warn(`Using local ${updaterPackageName ?? '@capgo/capacitor-updater'}. Assuming latest version for checksum calculation.`) + log.warn('Using local @capgo/capacitor-updater. Assuming latest version for checksum calculation.') useSha256 = true } const forceCrc32 = options.forceCrc32Checksum === true @@ -1369,12 +1323,9 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio if (options.verbose) log.info(`[Verbose] API key retrieved successfully`) - const extConfig = await loadUploadProjectConfig(options, { appId: preAppid }) - if (options.verbose) { - log.info(isCordovaMode(options.mode) - ? `[Verbose] Cordova project config resolved (webDir: ${extConfig.config.webDir})` - : `[Verbose] Capacitor config loaded successfully`) - } + const extConfig = await getConfig() + if (options.verbose) + log.info(`[Verbose] Capacitor config loaded successfully`) // Record whether the user explicitly asked for a delta/partial upload BEFORE // any mutation of `options.delta`. The instant-update auto-enable below and @@ -1528,14 +1479,9 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio } if (options.autoSetBundle) { - if (isCordovaMode(options.mode)) { - log.warn('--auto-set-bundle is not supported in Cordova mode (no capacitor.config to update)') - } - else { - await updateConfigUpdater({ version: bundle }) - if (options.verbose) - log.info(`[Verbose] Auto-set bundle version in ${extConfig.path}`) - } + await updateConfigUpdater({ version: bundle }) + if (options.verbose) + log.info(`[Verbose] Auto-set bundle version in ${extConfig.path}`) } log.info(`Upload ${appid}@${bundle} started from path "${path}" to Capgo cloud`) diff --git a/cli/src/cordova/project.ts b/cli/src/cordova/project.ts deleted file mode 100644 index caefaf99b5..0000000000 --- a/cli/src/cordova/project.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { CapacitorConfig, ExtConfigPairs } from '../config' -import { existsSync, readFileSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import { cwd } from 'node:process' -import { DOMParser } from '@xmldom/xmldom' -import { isValidAppId } from '../recovery/app-id' - -export const CORDOVA_DEFAULT_WEB_DIR = 'www' - -const CORDOVA_CONFIG_FILES = ['config.xml', 'plugin.xml'] as const - -export function findCordovaProjectRoot(startDir = cwd()): string { - let current = resolve(startDir) - const filesystemRoot = resolve(current, '/') - - while (true) { - for (const fileName of CORDOVA_CONFIG_FILES) { - if (existsSync(join(current, fileName))) - return current - } - if (current === filesystemRoot) - break - current = dirname(current) - } - - return resolve(startDir) -} - -function parseXmlRootId(content: string, rootTag: 'widget' | 'plugin'): string | undefined { - try { - const doc = new DOMParser().parseFromString(content, 'text/xml') - const root = doc.documentElement - if (!root || root.nodeName.toLowerCase() !== rootTag) - return undefined - const candidate = root.getAttribute('id')?.trim() - return candidate && isValidAppId(candidate) ? candidate : undefined - } - catch { - return undefined - } -} - -function readCordovaAppIdFromFile(filePath: string): string | undefined { - if (!existsSync(filePath)) - return undefined - - try { - const content = readFileSync(filePath, 'utf8') - if (filePath.endsWith('config.xml')) - return parseXmlRootId(content, 'widget') - if (filePath.endsWith('plugin.xml')) - return parseXmlRootId(content, 'plugin') - } - catch { - return undefined - } - - return undefined -} - -export function collectCordovaAppIdCandidates(projectRoot = findCordovaProjectRoot(cwd())): string[] { - const candidates = new Set() - - for (const fileName of CORDOVA_CONFIG_FILES) { - const appId = readCordovaAppIdFromFile(join(projectRoot, fileName)) - if (appId) - candidates.add(appId) - } - - return [...candidates] -} - -export function resolveCordovaWebDir(path?: string): string { - return path?.trim() || CORDOVA_DEFAULT_WEB_DIR -} - -export function buildCordovaUploadConfig(options: { path?: string, appId?: string }): ExtConfigPairs { - const projectRoot = findCordovaProjectRoot() - const detectedAppId = collectCordovaAppIdCandidates(projectRoot)[0] - const appId = options.appId?.trim() || detectedAppId || '' - const webDirRelative = resolveCordovaWebDir(options.path) - const webDir = options.path?.trim() - ? webDirRelative - : resolve(projectRoot, webDirRelative) - - const config: CapacitorConfig = { - appId, - appName: 'Cordova App', - webDir, - plugins: {}, - } - - return { - config, - path: '', - } -} diff --git a/cli/src/framework/mode.ts b/cli/src/framework/mode.ts deleted file mode 100644 index 5656dd68bd..0000000000 --- a/cli/src/framework/mode.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const CLI_PROJECT_MODES = ['cordova'] as const - -export function isCordovaMode(mode?: string): boolean { - return mode === 'cordova' -} diff --git a/cli/src/index.ts b/cli/src/index.ts index 0ef4b1ca08..49730ccc5d 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -14,7 +14,6 @@ import { getInfo } from './app/info' import { listApp } from './app/list' import { setApp } from './app/set' import { setSetting } from './app/setting' -import { appTodo } from './app/todo' import { clearCredentialsCommand, listCredentialsCommand, migrateCredentialsCommand, saveCredentialsCommand, updateCredentialsCommand } from './build/credentials-command' import { exportCredentialsCommand, isCredentialsExportInvocation } from './build/credentials-export-command' import { sanitizeCredentialsExportTerminalText, writeCredentialsExportStderr } from './build/credentials-export-terminal' @@ -54,8 +53,6 @@ import { createKey, deleteOldKey, saveKeyCommand } from './key' import { login } from './login' import { startMcpServer } from './mcp/server' import { setupNotifications } from './notifications/setup' -import { startOnboardingChecks } from './onboarding/background' -import { waitForOnboardingChecks } from './onboarding/background-shutdown' import { type ObserveCliOptions, observeCommand } from './observe/command' import { addOrganization, deleteOrganization, listMembers, listOrganizations, setOrganization } from './organization' import { capturePosthogException, getCommandPath, shouldCapturePosthogException } from './posthog' @@ -64,9 +61,8 @@ import { probe } from './probe' import { testRunDeviceCommand } from './run/device' import { CliUserError } from './shared/cli-user-error' import { TwoFactorComplianceNetworkError } from './shared/two-factor-compliance' -import { whoami } from './user/whoami' +import { getUserId } from './user/account' import { formatError } from './utils' -import { CLI_PROJECT_MODES } from './framework/mode' import { normalizeAutoBumpInput } from './versionHelpers' // Common option descriptions used across multiple commands @@ -79,7 +75,6 @@ const optionDescriptions = { capacitorConfig: `Capacitor config source to update (useful with dynamic monorepo configs)`, verbose: `Enable verbose output with detailed logging`, ignoreNotifyAppReady: `Skip notifyAppReady() check (not recommended — updates may roll back)`, - mode: `Project framework mode. Use cordova for Cordova apps without capacitor.config (webDir defaults to www)`, acceptIncompatible: `Accept native-package incompatibility as handled (still checks and warns, continues, skips the crash-warning email). Use this when your app already guards missing plugins at runtime.`, acceptIncompatibleChannel: `Accept native-package incompatibility as handled (still checks and warns, sets the channel instead of failing). Use this when your app already guards missing plugins at runtime.`, } @@ -100,22 +95,17 @@ program enableSupabaseInstrumentation() let currentCommandPath = 'unknown' -let currentActionCommand: Command | undefined program.hook('preAction', (_thisCommand, actionCommand) => { setConfigWriteTarget(resolveCapacitorConfigTargetPath(actionCommand.optsWithGlobals().capacitorConfig, cwd(), { logError: true })) currentCommandPath = getCommandPath(actionCommand) - currentActionCommand = actionCommand setCurrentCliCommand(currentCommandPath) applyCommandAnalyticsOptOut(currentCommandPath, actionCommand.opts()) - startOnboardingChecks(actionCommand, currentCommandPath) const commandContext = extractCommandContext(actionCommand) - if (currentCommandPath === 'login' || currentCommandPath === 'init' || currentCommandPath === 'build init' || currentCommandPath === 'build onboarding') + if (currentCommandPath === 'login' || currentCommandPath === 'init') deferCommandInvocation(currentCommandPath, commandContext) - else { - const optionKey = actionCommand.optsWithGlobals().apikey - trackCommandInvoked(currentCommandPath, commandContext, typeof optionKey === 'string' ? optionKey : undefined) - } + else + trackCommandInvoked(currentCommandPath, commandContext) }) program.hook('postAction', (_thisCommand, actionCommand) => { @@ -247,12 +237,10 @@ Version must be > 0.0.0 and unique. Deleted versions cannot be reused for securi External option: Store only a URL link (useful for apps >200MB or privacy requirements). Capgo never inspects external content. Add encryption for trustless security. -Example: npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production,beta -Cordova example: npx @capgo/cli@latest bundle upload com.example.app --mode cordova --path www --channel production`) +Example: npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production,beta`) .action(handleBundleUploadCommand) .option('-a, --apikey ', optionDescriptions.apikey) - .addOption(new Option('--mode ', optionDescriptions.mode).choices([...CLI_PROJECT_MODES])) - .option('-p, --path ', `Path of the folder to upload, if not provided it will use the webDir set in capacitor.config (or www with --mode cordova)`) + .option('-p, --path ', `Path of the folder to upload, if not provided it will use the webDir set in capacitor.config`) .option('-c, --channel ', `Channel to link to. Use commas for multiple channels, for example production,beta`) .option('--rollout ', `Set the uploaded bundle as this channel's rollout target at a percentage from 0 to 100`, value => Number.parseFloat(value)) .option('--rollout-percentage-bps ', `Set the uploaded bundle rollout percentage in basis points from 0 to 10000`, value => Number.parseInt(value, 10)) @@ -487,19 +475,6 @@ Example: npx @capgo/cli@latest app list`) .option('--supa-host ', optionDescriptions.supaHost) .option('--supa-anon ', optionDescriptions.supaAnon) -app - .command('todo [appId]') - .alias('todoList') - .description(`📋 Show your app's onboarding todo list with done, skipped, and pending tasks. - -Uses the same live progress checks as the Capgo dashboard. The app ID can be inferred from your Capacitor project. - -Example: npx @capgo/cli@latest app todo com.example.app`) - .action(appTodo) - .option('-a, --apikey ', optionDescriptions.apikey) - .option('--supa-host ', optionDescriptions.supaHost) - .option('--supa-anon ', optionDescriptions.supaAnon) - app .command('debug [appId]') .action(debugApp) @@ -718,12 +693,11 @@ const account = program .command('account') .description(`👤 Manage your Capgo account details and retrieve information for support or collaboration.`) -account.command('whoami') - .alias('id') - .description(`🪪 Retrieve your account ID and email address. +account.command('id') + .description(`🪪 Retrieve your account ID, safe to share for collaboration or support purposes in Discord or other platforms. -Example: npx @capgo/cli@latest account whoami`) - .action(whoami) +Example: npx @capgo/cli@latest account id`) + .action(getUserId) .option('-a, --apikey ', optionDescriptions.apikey) const organization = program @@ -1541,8 +1515,6 @@ void (async () => { try { await program.parseAsync() await flushAnalytics() - if (currentActionCommand) - await waitForOnboardingChecks(currentActionCommand, currentCommandPath) } catch (error: unknown) { if (typeof error === 'object' && error !== null && 'code' in error) { diff --git a/cli/src/init/browser-login.ts b/cli/src/init/browser-login.ts index c952359875..9ddbed2679 100644 --- a/cli/src/init/browser-login.ts +++ b/cli/src/init/browser-login.ts @@ -10,12 +10,6 @@ interface BrowserLoginOptions extends SaveKeyOptions { local: boolean } -export interface BrowserLoginSession { - session: string - url: string - browserOpened: boolean -} - interface BrowserLoginEvent { channel: 'user-login' event: 'User CLI login' @@ -66,30 +60,24 @@ export function shouldStartInitBrowserLogin(resolvedKey: string | undefined, int return !resolvedKey && interactive } -export async function beginBrowserLogin( - onUrl: (url: string) => void, +export async function loginInitInBrowser( + options: BrowserLoginOptions, overrides: Partial = {}, -): Promise { +): Promise { const dependencies = { ...defaults, ...overrides } const session = dependencies.createSession() const url = consoleWebUrl(`/login-cli?session=${encodeURIComponent(session)}`) - onUrl(url) + dependencies.writeUrl(`Open this URL to create your CLI key: ${url}`) try { await dependencies.openUrl(url) - return { session, url, browserOpened: true } } catch { - return { session, url, browserOpened: false } + // The printed URL is the fallback when a browser cannot be opened. } -} -export async function completeBrowserLogin( - browserSession: BrowserLoginSession, - key: string, - options: BrowserLoginOptions, - overrides: Partial = {}, -): Promise { - const dependencies = { ...defaults, ...overrides } + const key = await dependencies.promptForKey() + if (!key) + throw new CliUserError('CLI login cancelled') await dependencies.validateKey(key, { local: options.local, supaHost: options.supaHost, @@ -103,27 +91,13 @@ export async function completeBrowserLogin( event: 'User CLI login', tracking_version: 2, org_id: orgId, - description: `cli-login:${browserSession.session}`, + description: `cli-login:${session}`, notifyConsole: true, }))) } catch { // Saving a valid key is the success condition; browser confirmation is best effort. } -} -export async function loginInitInBrowser( - options: BrowserLoginOptions, - overrides: Partial = {}, -): Promise { - const dependencies = { ...defaults, ...overrides } - const session = await beginBrowserLogin( - url => dependencies.writeUrl(`Open this URL to create your CLI key: ${url}`), - dependencies, - ) - const key = await dependencies.promptForKey() - if (!key) - throw new CliUserError('CLI login cancelled') - await completeBrowserLogin(session, key, options, dependencies) return key } diff --git a/cli/src/mcp/server.ts b/cli/src/mcp/server.ts index 317e753f45..582b0a185b 100644 --- a/cli/src/mcp/server.ts +++ b/cli/src/mcp/server.ts @@ -202,7 +202,6 @@ async function startMcpServerInternal(restoreConfigWriteTarget: () => void): Pro async ({ appId, path, - mode, bundle, channel, rollout, @@ -220,7 +219,6 @@ async function startMcpServerInternal(restoreConfigWriteTarget: () => void): Pro const result = await sdk.uploadBundle({ appId, path, - mode, bundle, channel, rollout, diff --git a/cli/src/mcp/tool-schemas.ts b/cli/src/mcp/tool-schemas.ts index 34dbd805a2..1b41375e12 100644 --- a/cli/src/mcp/tool-schemas.ts +++ b/cli/src/mcp/tool-schemas.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import { CLI_PROJECT_MODES } from '../framework/mode' import { buildCacheKeyOptionSchema, buildCacheOptionSchema } from '../schemas/build' import { capacitorConfigOptionSchema, observeOptionsObjectSchema, refineObserveDeviceId } from '../schemas/sdk' @@ -23,7 +22,6 @@ export const mcpDeleteAppInputSchema = z.object({ export const mcpUploadBundleInputSchema = z.object({ appId: z.string(), path: z.string(), - mode: z.enum(CLI_PROJECT_MODES).optional().describe('Project framework mode. Use cordova for Cordova apps without capacitor.config'), bundle: z.string().optional(), channel: z.string().optional(), rollout: z.number().min(0).max(100).optional(), diff --git a/cli/src/notify-app-ready-worker.ts b/cli/src/notify-app-ready-worker.ts deleted file mode 100644 index 1875b63843..0000000000 --- a/cli/src/notify-app-ready-worker.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { exit } from 'node:process' -import { workerData } from 'node:worker_threads' -import { runOnboardingCheck, type PreparedOnboardingCheck } from './onboarding/background-check' -import { scanNotifyAppReadySource } from './onboarding/notify-app-ready-source' - -void runOnboardingCheck(workerData as PreparedOnboardingCheck, { - channel: 'notify-app-ready', - step: 'add_code', - scan: scanNotifyAppReadySource, -}).catch(() => { - // Missing projects, parser/config failures, and rejected reports are optional. -}).finally(() => exit(0)) diff --git a/cli/src/onboarding-worker.ts b/cli/src/onboarding-worker.ts deleted file mode 100644 index 44c86fb51a..0000000000 --- a/cli/src/onboarding-worker.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { OnboardingCheckOptions } from './onboarding/background' -import { randomUUID } from 'node:crypto' -import { exit } from 'node:process' -import { Worker, workerData } from 'node:worker_threads' -import { prepareOnboardingCheck } from './onboarding/background-preparation' - -function runScanWorker(workerUrl: URL, data: object): Promise { - return new Promise((resolve) => { - try { - const worker = new Worker(workerUrl, { workerData: data, stdout: true, stderr: true }) - worker.stdout?.destroy() - worker.stderr?.destroy() - worker.on('error', () => {}) - worker.once('exit', () => resolve()) - } - catch { - resolve() - } - }) -} - -async function runOnboardingChecks(options: OnboardingCheckOptions): Promise { - const prepared = await prepareOnboardingCheck(options) - if (!prepared) - return - - const attemptIds = options.attemptIds ?? [options.attemptId ?? randomUUID(), randomUUID()] - await Promise.allSettled([ - runScanWorker(new URL('./notify-app-ready-worker.js', import.meta.url), { ...prepared, attemptId: attemptIds[0] }), - runScanWorker(new URL('./updater-installed-worker.js', import.meta.url), { ...prepared, attemptId: attemptIds[1] }), - ]) -} - -void runOnboardingChecks(workerData as OnboardingCheckOptions).catch(() => { - // Optional onboarding checks must never affect the requested command. -}).finally(() => exit(0)) diff --git a/cli/src/onboarding/background-api.ts b/cli/src/onboarding/background-api.ts deleted file mode 100644 index 07584989ab..0000000000 --- a/cli/src/onboarding/background-api.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { OnboardingCheckOptions } from './background' -import { defaultApiHost } from '../utils' - -function parseApiUrl(value: string): URL | undefined { - try { - const url = new URL(value) - if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) - return undefined - return url - } - catch { - return undefined - } -} - -export function isTrustedOnboardingApiHost( - apiHost: string, - options: Pick, - trustedOrigins: readonly string[], -): boolean { - const destination = parseApiUrl(apiHost) - if (!destination) - return false - const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(destination.hostname) - // Trust does not permit sending credentials over remote cleartext transport. - if (destination.protocol !== 'https:' && !loopback) - return false - - if (destination.origin === new URL(defaultApiHost).origin) - return true - // An explicit CLI self-host selection authorizes that origin, not project config alone. - const explicitHost = options.supaHost && options.supaAnon ? parseApiUrl(options.supaHost) : undefined - if (explicitHost?.origin === destination.origin) - return true - return trustedOrigins.some((origin) => { - const trusted = parseApiUrl(origin.trim()) - return trusted?.pathname === '/' && trusted.origin === destination.origin - }) -} diff --git a/cli/src/onboarding/background-check.ts b/cli/src/onboarding/background-check.ts deleted file mode 100644 index 2b6bc09d0c..0000000000 --- a/cli/src/onboarding/background-check.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { OnboardingScanProject } from './notify-app-ready-project' -import { buildCliRequestHeaders, setCurrentCliCommand } from '../analytics/cli-headers' -import { sendEvent, trimTrailingSlashes } from '../utils' - -interface BackgroundOnboardingCheck { - channel: 'notify-app-ready' | 'updater-installed' - step: 'add_code' | 'add_updater' - scan: (project: OnboardingScanProject) => 'found' | 'not_found' | 'unknown' -} - -export interface PreparedOnboardingCheck { - project: OnboardingScanProject - apiHost: string - anonKey?: string - apikey: string - command: string - attemptId: string -} - -export async function runOnboardingCheck(prepared: PreparedOnboardingCheck, check: BackgroundOnboardingCheck): Promise { - const { project, apiHost, anonKey, apikey, command, attemptId } = prepared - setCurrentCliCommand(command) - const trackScan = async (event: 'scan_started' | 'scan_ended', timestamp: number, tags: Record = {}) => { - try { - await sendEvent(apikey, { - channel: check.channel, - event, - tracking_version: 2, - timestamp: new Date(timestamp), - tags: { app_id: project.appId }, - nonPersonTags: { attempt_id: attemptId, command_path: command, ...tags }, - }, false, AbortSignal.timeout(500), apiHost, 'error') - } - catch { - // Scan telemetry must never prevent onboarding detection or todo reporting. - } - } - - await trackScan('scan_started', Date.now()) - const scanStartedAt = Date.now() - let scanEndedAt = scanStartedAt - let result: 'found' | 'not_found' | 'unknown' | 'error' = 'error' - let todoReportStatus = 'not_attempted' - let todoReportHttpStatus: number | undefined - try { - result = check.scan(project) - scanEndedAt = Date.now() - if (result !== 'found') - return - - todoReportStatus = 'failed' - const response = await fetch(`${trimTrailingSlashes(apiHost)}/app/${encodeURIComponent(project.appId)}`, { - method: 'PUT', - headers: buildCliRequestHeaders({ - 'Content-Type': 'application/json', - 'Authorization': apiHost.includes('/functions/v1') && anonKey ? `Bearer ${anonKey}` : apikey, - 'capgkey': apikey, - }), - // Preserve source, outcome, and all unrelated onboarding steps. - body: JSON.stringify({ onboarding: { steps: { [check.step]: { status: 'done' } } } }), - redirect: 'error', - }) - todoReportHttpStatus = response.status - todoReportStatus = response.ok ? 'success' : 'rejected' - await response.body?.cancel() - } - finally { - if (result === 'error') - scanEndedAt = Date.now() - await trackScan('scan_ended', scanEndedAt, { - result, - duration_ms: scanEndedAt - scanStartedAt, - todo_report_status: todoReportStatus, - ...(todoReportHttpStatus === undefined ? {} : { todo_report_http_status: todoReportHttpStatus }), - }) - } -} diff --git a/cli/src/onboarding/background-preparation.ts b/cli/src/onboarding/background-preparation.ts deleted file mode 100644 index 2d35a9b250..0000000000 --- a/cli/src/onboarding/background-preparation.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { OnboardingCheckOptions } from './background' -import type { PreparedOnboardingCheck } from './background-check' -import { env } from 'node:process' -import { defaultApiHost, findSavedKeySilent, isCapgoManagedSupabaseHost, normalizeSupabaseHost, resolveConfiguredCapgoPublicApiHost } from '../utils' -import { isTrustedOnboardingApiHost } from './background-api' -import { resolveNotifyAppReadyProject } from './notify-app-ready-project' - -function hasCustomUpdaterEndpoint(updater: Record | undefined): boolean { - return ['updateUrl', 'statsUrl'].some((field) => { - const value = updater?.[field] - if (value === undefined || value === null || value === '') - return false - if (typeof value !== 'string') - return true - try { - const hostname = new URL(value).hostname - return !['usecapgo.com', 'capgo.app'].some(domain => hostname === domain || hostname.endsWith(`.${domain}`)) - } - catch { - return true - } - }) -} - -export async function prepareOnboardingCheck(options: OnboardingCheckOptions): Promise | undefined> { - // Capture user-provided trust before evaluating executable project config. - const trustedOrigins = env.CAPGO_TRUSTED_API_ORIGINS?.split(',') ?? [] - const apikey = options.apikey ?? findSavedKeySilent() - if (!apikey) - return - const project = await resolveNotifyAppReadyProject(options) - if (!project) - return - - const updater = project.config.plugins?.CapacitorUpdater - if (hasCustomUpdaterEndpoint(updater)) - return - const config = { - hostApi: updater?.localApi || defaultApiHost, - supaHost: updater?.localSupa, - supaKey: updater?.localSupaAnon, - } - const explicitSelfHost = options.supaHost && options.supaAnon && !isCapgoManagedSupabaseHost(options.supaHost) - const apiHost = explicitSelfHost - ? `${normalizeSupabaseHost(options.supaHost!)}/functions/v1` - : resolveConfiguredCapgoPublicApiHost(config) - if (!isTrustedOnboardingApiHost(apiHost, options, trustedOrigins)) - return - const anonKey = options.supaAnon ?? config.supaKey - return { - project: { dir: project.dir, workspaceRoot: project.workspaceRoot, appId: project.appId, webDir: project.webDir }, - apiHost, - anonKey, - apikey, - command: options.command, - } -} diff --git a/cli/src/onboarding/background-shutdown.ts b/cli/src/onboarding/background-shutdown.ts deleted file mode 100644 index f6cd4701d0..0000000000 --- a/cli/src/onboarding/background-shutdown.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Command } from 'commander' -import process from 'node:process' -import { log } from '@clack/prompts' -import { isCI } from 'ci-info' -import { flushAnalytics, trackEvent } from '../analytics/track' -import { getPendingOnboardingChecks } from './background-workers' - -const gracePeriodMs = 5_000 - -// Only commands with human-facing output opt into the shutdown message and wait. -const interactiveCommands = new Set([ - 'doctor', 'login', 'get-qr', - 'app add', 'app delete', 'app list', 'app debug', 'app setting', 'app set', - 'bundle upload', 'bundle compatibility', 'bundle delete', 'bundle list', 'bundle cleanup', - 'bundle encrypt', 'bundle decrypt', 'bundle zip', - 'channel add', 'channel delete', 'channel list', 'channel currentBundle', 'channel set', - 'key save', 'key create', 'key delete_old', - 'organization list', 'organization add', 'organization members', 'organization set', 'organization delete', - 'organisation list', 'organisation add', 'organisation set', 'organisation delete', - 'build request', -]) - -function shouldWaitForOnboardingChecks(commandPath: string, options: Record): boolean { - return !!process.stdin.isTTY && !!process.stdout.isTTY && !isCI - && interactiveCommands.has(commandPath) - && !options.json && !options.outputText && !options.quiet -} - -export async function waitForOnboardingChecks(command: Pick, commandPath: string): Promise { - const pendingChecks = getPendingOnboardingChecks() - if (!shouldWaitForOnboardingChecks(commandPath, command.optsWithGlobals()) || pendingChecks.size === 0) - return - - let timer: ReturnType | undefined - const onInterrupt = () => process.exit(130) - // Take precedence over any command-specific cancellation handler still attached. - process.prependOnceListener('SIGINT', onInterrupt) - try { - log.info('Waiting for background checks to finish (up to 5 seconds). Press Ctrl-C to exit immediately.') - const checks = [...pendingChecks.values()] - const options = command.optsWithGlobals() - void trackEvent({ - channel: 'cli-usage', - event: 'background_checks_wait_started', - apikey: typeof options.apikey === 'string' ? options.apikey : undefined, - appId: typeof options.appId === 'string' ? options.appId : undefined, - timestamp: new Date(), - nonPersonTags: { - command_path: commandPath, - pending_checks: checks.reduce((total, check) => total + check.attemptIds.length, 0), - grace_period_ms: gracePeriodMs, - scan_attempt_ids: checks.flatMap(check => check.attemptIds), - }, - }) - await Promise.race([ - Promise.allSettled([...checks.map(check => check.completion), flushAnalytics(gracePeriodMs)]), - new Promise((resolve) => { - // This timer keeps the process alive while the workers remain unreferenced. - timer = setTimeout(resolve, gracePeriodMs) - }), - ]) - } - finally { - if (timer) - clearTimeout(timer) - process.removeListener('SIGINT', onInterrupt) - // A hanging request must not outlive the shared shutdown budget. - for (const worker of pendingChecks.keys()) - void worker.terminate().catch(() => {}) - // Delivery shares the worker budget; offline telemetry cannot extend shutdown. - await flushAnalytics(0) - } -} diff --git a/cli/src/onboarding/background-workers.ts b/cli/src/onboarding/background-workers.ts deleted file mode 100644 index 0397ecab7a..0000000000 --- a/cli/src/onboarding/background-workers.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Worker } from 'node:worker_threads' - -interface PendingOnboardingCheck { - completion: Promise - attemptId: string - attemptIds: string[] -} - -const pendingChecks = new Map() - -export function registerOnboardingCheck(worker: Worker, attemptIds: string[]): void { - const completion = new Promise((resolve) => { - worker.once('exit', () => { - pendingChecks.delete(worker) - resolve() - }) - }) - pendingChecks.set(worker, { completion, attemptId: attemptIds[0], attemptIds }) -} - -export function getPendingOnboardingChecks(): ReadonlyMap { - return pendingChecks -} diff --git a/cli/src/onboarding/background.ts b/cli/src/onboarding/background.ts deleted file mode 100644 index a3ca10aa05..0000000000 --- a/cli/src/onboarding/background.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Command } from 'commander' -import { randomUUID } from 'node:crypto' -import { cwd } from 'node:process' -import { Worker } from 'node:worker_threads' -import { registerOnboardingCheck } from './background-workers' - -export interface OnboardingCheckOptions { - cwd: string - command: string - attemptId?: string - attemptIds?: string[] - appId?: string - apikey?: string - capacitorConfig?: string - packageJson?: string - mainFile?: string - supaHost?: string - supaAnon?: string -} - -export function startOnboardingCheck(command: Command, commandPath: string, workerUrl: URL, attemptIds?: string[]): void { - try { - const options = command.optsWithGlobals() - const argument = (name: string) => { - const index = command.registeredArguments.findIndex(arg => arg.name() === name) - return index < 0 ? undefined : command.args[index] - } - const text = (value: unknown) => typeof value === 'string' ? value : undefined - const attemptId = attemptIds?.[0] ?? randomUUID() - const workerData: OnboardingCheckOptions = { - cwd: cwd(), - command: commandPath, - attemptId, - attemptIds, - appId: text(options.appId) ?? argument('appId'), - apikey: text(options.apikey) ?? argument('apikey'), - capacitorConfig: text(options.capacitorConfig), - packageJson: text(options.packageJson), - mainFile: text(options.mainFile), - supaHost: text(options.supaHost), - supaAnon: text(options.supaAnon), - } - const worker = new Worker(workerUrl, { workerData, stdout: true, stderr: true }) - // Discovery/config loading must not write into terminal UIs or MCP stdout. - // Discard captured streams so incoming output cannot reference the worker's IPC port. - worker.stdout?.destroy() - worker.stderr?.destroy() - worker.on('error', () => {}) - registerOnboardingCheck(worker, attemptIds ?? [attemptId]) - // Both detection and reporting can be abandoned when the command exits. - worker.unref() - } - catch { - // Optional onboarding detection must never affect the requested command. - } -} - -export function startOnboardingChecks(command: Command, commandPath: string): void { - startOnboardingCheck(command, commandPath, new URL('./onboarding-worker.js', import.meta.url), [randomUUID(), randomUUID()]) -} diff --git a/cli/src/onboarding/notify-app-ready-project.ts b/cli/src/onboarding/notify-app-ready-project.ts deleted file mode 100644 index 969c49f672..0000000000 --- a/cli/src/onboarding/notify-app-ready-project.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { CapacitorConfig } from '../config' -import type { OnboardingCheckOptions } from './background' -import { existsSync, readFileSync, realpathSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import { discoverCapacitorProjects, hasCapacitorConfig } from '../build/onboarding/project-discovery' -import { loadConfigTarget } from '../config' -import { getAppId } from '../utils' - -export interface NotifyAppReadyProject { - dir: string - workspaceRoot: string - config: CapacitorConfig - appId: string - webDir?: string -} - -export type OnboardingScanProject = Pick - -function ancestors(dir: string): string[] { - const result: string[] = [] - for (let current = resolve(dir); ; current = dirname(current)) { - result.push(current) - if (current === dirname(current)) - return result - } -} - -function workspaceRoot(dir: string): string { - return ancestors(dir).find((candidate) => { - if (['pnpm-workspace.yaml', 'nx.json', 'lerna.json', 'rush.json'].some(name => existsSync(join(candidate, name)))) - return true - try { - return !!JSON.parse(readFileSync(join(candidate, 'package.json'), 'utf8')).workspaces - } - catch { - return false - } - }) ?? dir -} - -async function readConfig(dir: string): Promise { - const file = ['capacitor.config.ts', 'capacitor.config.js', 'capacitor.config.json'] - .map(name => join(dir, name)) - .find(existsSync) - if (!file) - throw new Error('No Capacitor config') - return loadConfigTarget(file) -} - -function projectDirectory(options: OnboardingCheckOptions, configDir: string, config: CapacitorConfig): string | undefined { - if (options.packageJson) { - const paths = options.packageJson.split(',').map(path => path.trim()).filter(Boolean) - // Multiple metadata files do not identify a unique source app. - if (paths.length !== 1) - return undefined - const path = realpathSync(resolve(options.cwd, paths[0])) - JSON.parse(readFileSync(path, 'utf8')) - return dirname(path) - } - if (options.mainFile) { - const mainFile = realpathSync(resolve(options.cwd, options.mainFile)) - return ancestors(dirname(mainFile)).find(dir => existsSync(join(dir, 'package.json'))) - } - // A dynamic root config can point at web assets in an app workspace. - if (typeof config.webDir === 'string') { - const webDir = resolve(configDir, config.webDir) - const owner = ancestors(webDir === configDir ? webDir : dirname(webDir)) - .find(dir => existsSync(join(dir, 'package.json'))) - if (owner) - return owner - } - return existsSync(join(configDir, 'package.json')) ? configDir : undefined -} - -export async function resolveNotifyAppReadyProject(options: OnboardingCheckOptions): Promise { - const initialDir = realpathSync(options.cwd) - const root = workspaceRoot(initialDir) - const activeDir = ancestors(initialDir).find(hasCapacitorConfig) - let configDir = activeDir - let config: CapacitorConfig | undefined - - if (configDir) { - config = await readConfig(configDir) - } - else if (options.capacitorConfig) { - const path = realpathSync(resolve(initialDir, options.capacitorConfig)) - configDir = dirname(path) - config = await loadConfigTarget(path) - } - else { - const discovery = await discoverCapacitorProjects(root) - const selectedSource = options.packageJson || options.mainFile - ? projectDirectory(options, root, {} as CapacitorConfig) - : undefined - if ((options.packageJson || options.mainFile) && !selectedSource) - return undefined - // Select statically before evaluating any executable workspace config. - const matches = discovery.candidates.filter(candidate => selectedSource - ? realpathSync(candidate.dir) === selectedSource - : !options.appId || candidate.appId === options.appId) - if (matches.length !== 1) - return undefined - configDir = matches[0].dir - try { - config = await readConfig(configDir) - } - catch { - return undefined - } - } - - const appId = getAppId(undefined, config) - if (typeof appId !== 'string' || !appId.trim() || (options.appId && options.appId !== appId) - || (config.webDir !== undefined && (typeof config.webDir !== 'string' || !config.webDir.trim()))) { - return undefined - } - - if (activeDir && options.capacitorConfig) { - const target = await loadConfigTarget(realpathSync(resolve(initialDir, options.capacitorConfig))) - // A write target must not silently select a different app than the root loader. - if (getAppId(undefined, target) !== appId) - return undefined - } - - const dir = projectDirectory(options, configDir, config) - if (!dir) - return undefined - // If source selection points at another Capacitor app, do not report for this one. - if (dir !== configDir && hasCapacitorConfig(dir) && getAppId(undefined, await readConfig(dir)) !== appId) - return undefined - return { - dir: realpathSync(dir), - workspaceRoot: workspaceRoot(realpathSync(dir)), - config, - appId, - webDir: resolve(configDir, config.webDir ?? 'www'), - } -} diff --git a/cli/src/onboarding/notify-app-ready-source.ts b/cli/src/onboarding/notify-app-ready-source.ts deleted file mode 100644 index 748ebfc511..0000000000 --- a/cli/src/onboarding/notify-app-ready-source.ts +++ /dev/null @@ -1,189 +0,0 @@ -import type { OnboardingScanProject } from './notify-app-ready-project' -import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs' -import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path' -import { NodeTypes, parse as parseVue } from '@vue/compiler-dom' -import ts from 'typescript' - -const UPDATER_PACKAGE = '@capgo/capacitor-updater' -const SOURCE_EXTENSION = /\.(?:[cm]?[jt]sx?|vue)$/ -const EXCLUDED_DIRECTORY = /^(?:\..*|node_modules|dist|build|www|coverage|android|ios|test|tests|__tests__|__mocks__|fixtures|__fixtures__|e2e|cypress|playwright|scripts)$/ -const EXCLUDED_FILE = /\.(?:test|spec|d)\.[cm]?[jt]sx?$|^capacitor\.config\./ - -function contained(root: string, path: string): boolean { - const fromRoot = relative(root, path) - return !isAbsolute(fromRoot) && fromRoot !== '..' && !fromRoot.startsWith(`..${sep}`) -} - -function compilerOptions(dir: string): ts.CompilerOptions { - const path = ts.findConfigFile(dir, ts.sys.fileExists) - if (!path) - return {} - const config = ts.readConfigFile(path, ts.sys.readFile) - if (config.error) - return {} - return ts.parseJsonConfigFileContent({ ...config.config, files: [], include: [] }, ts.sys, dirname(path)).options -} - -function vueScripts(content: string): string { - // Only script blocks are JavaScript; markup/comments must not complete a todo. - const root = parseVue(content, { parseMode: 'sfc', onError: error => { throw error } }) - return root.children.flatMap((node) => { - if (node.type !== NodeTypes.ELEMENT || node.tag !== 'script') - return [] - return node.children.flatMap(child => child.type === NodeTypes.TEXT ? [child.content] : []) - }).join('\n') -} - -function importDeclaration(node: ts.Node): ts.ImportDeclaration | undefined { - for (let parent: ts.Node | undefined = node.parent; parent; parent = parent.parent) { - if (ts.isImportDeclaration(parent)) - return parent - } - return undefined -} - -function updaterImport(node: ts.Node): boolean { - const declaration = importDeclaration(node) - return !!declaration && ts.isStringLiteral(declaration.moduleSpecifier) - && declaration.moduleSpecifier.text === UPDATER_PACKAGE - && !declaration.importClause?.isTypeOnly -} - -function isUpdaterReference(node: ts.Expression, checker: ts.TypeChecker): boolean { - if (ts.isIdentifier(node)) { - return !!checker.getSymbolAtLocation(node)?.declarations?.some((declaration) => { - if (ts.isImportSpecifier(declaration)) { - return !declaration.isTypeOnly && (declaration.propertyName ?? declaration.name).text === 'CapacitorUpdater' - && updaterImport(declaration) - } - if (!ts.isBindingElement(declaration) || !ts.isObjectBindingPattern(declaration.parent)) - return false - const variable = declaration.parent.parent - const name = declaration.propertyName ?? declaration.name - if (!ts.isIdentifier(name) || name.text !== 'CapacitorUpdater' || !ts.isVariableDeclaration(variable)) - return false - const initializer = variable.initializer - return !!initializer && ts.isCallExpression(initializer) - && ts.isIdentifier(initializer.expression) && initializer.expression.text === 'require' - && !checker.getSymbolAtLocation(initializer.expression) - && initializer.arguments.length === 1 && ts.isStringLiteral(initializer.arguments[0]) - && initializer.arguments[0].text === UPDATER_PACKAGE - && ts.isVariableDeclarationList(variable.parent) && !!(variable.parent.flags & ts.NodeFlags.Const) - }) - } - if (ts.isPropertyAccessExpression(node) && node.name.text === 'CapacitorUpdater' && ts.isIdentifier(node.expression)) { - return !!checker.getSymbolAtLocation(node.expression)?.declarations?.some(declaration => - ts.isNamespaceImport(declaration) && updaterImport(declaration), - ) - } - return false -} - -function hasCall(source: ts.SourceFile, checker: ts.TypeChecker): boolean { - function visit(node: ts.Node): boolean { - if (ts.isCallExpression(node)) { - const callee = node.expression - if (ts.isPropertyAccessExpression(callee) && callee.name.text === 'notifyAppReady' - && isUpdaterReference(callee.expression, checker)) { - return true - } - if (ts.isElementAccessExpression(callee) && ts.isStringLiteral(callee.argumentExpression) - && callee.argumentExpression.text === 'notifyAppReady' && isUpdaterReference(callee.expression, checker)) { - return true - } - } - return ts.forEachChild(node, visit) ?? false - } - return visit(source) -} - -export function scanNotifyAppReadySource(project: OnboardingScanProject): 'found' | 'not_found' | 'unknown' { - try { - const deadline = Date.now() + 5_000 - const contents = new Map() - const originalPaths = new Map() - const seen = new Set() - const options = compilerOptions(project.dir) - let bytes = 0 - const checkBudget = () => { - if (Date.now() > deadline || seen.size > 10_000 || bytes > 20 * 1024 * 1024) - throw new Error('Source scan budget exceeded') - } - function addFile(path: string): void { - checkBudget() - if (!SOURCE_EXTENSION.test(path) || EXCLUDED_FILE.test(basename(path))) - return - const canonical = realpathSync(path) - if (seen.has(canonical) || !contained(project.workspaceRoot, canonical) - || relative(project.workspaceRoot, canonical).split(sep).some(part => EXCLUDED_DIRECTORY.test(part)) - || (project.webDir && project.webDir !== project.dir && contained(project.webDir, canonical))) { - return - } - seen.add(canonical) - const size = statSync(canonical).size - if (size > 1024 * 1024) - throw new Error('Source file too large') - bytes += size - checkBudget() - const content = readFileSync(canonical, 'utf8') - const normalizedPath = canonical.split(sep).join('/') - const virtualPath = extname(canonical) === '.vue' ? `${normalizedPath}.tsx` : normalizedPath - const script = extname(canonical) === '.vue' ? vueScripts(content) : content - contents.set(virtualPath, script) - originalPaths.set(virtualPath, canonical) - } - function walk(dir: string): void { - checkBudget() - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const path = join(dir, entry.name) - if (entry.isDirectory()) { - // Other workspace packages/apps are only followed through imports. - if (!EXCLUDED_DIRECTORY.test(entry.name) && !existsSync(join(path, 'package.json'))) - walk(path) - } - else if (entry.isFile()) { - addFile(path) - } - } - } - walk(project.dir) - - // Follow local shared modules, including tsconfig paths and workspace symlinks. - for (const [virtualPath, content] of contents) { - checkBudget() - const original = originalPaths.get(virtualPath)! - for (const imported of ts.preProcessFile(content, true, true).importedFiles) { - const name = imported.fileName - if (name === UPDATER_PACKAGE) - continue - const resolved = ts.resolveModuleName(name, original, options, ts.sys).resolvedModule?.resolvedFileName - ?? (name.startsWith('.') && SOURCE_EXTENSION.test(name) ? resolve(dirname(original), name) : undefined) - if (resolved && existsSync(resolved)) - addFile(resolved) - } - } - - const parseOptions: ts.CompilerOptions = { allowJs: true, noLib: true, noResolve: true, types: [], jsx: ts.JsxEmit.Preserve } - const host = ts.createCompilerHost(parseOptions) - host.getSourceFile = (path, languageVersion) => { - const content = contents.get(path) - return content === undefined ? undefined : ts.createSourceFile(path, content, languageVersion, true) - } - const program = ts.createProgram([...contents.keys()], parseOptions, host) - const checker = program.getTypeChecker() - let unknown = false - for (const source of program.getSourceFiles()) { - checkBudget() - if (program.getSyntacticDiagnostics(source).length) { - unknown = true - continue - } - if (hasCall(source, checker)) - return 'found' - } - return unknown ? 'unknown' : 'not_found' - } - catch { - return 'unknown' - } -} diff --git a/cli/src/onboarding/updater-installed.ts b/cli/src/onboarding/updater-installed.ts deleted file mode 100644 index 44148e4e1f..0000000000 --- a/cli/src/onboarding/updater-installed.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { OnboardingScanProject } from './notify-app-ready-project' -import { join } from 'node:path' -import { getUpdaterInstallState } from '../init/updater' - -export function scanUpdaterInstalled(project: OnboardingScanProject): 'found' | 'not_found' { - // A declaration alone, or another app's hoisted dependency, is insufficient. - return getUpdaterInstallState(join(project.dir, 'package.json')).ready ? 'found' : 'not_found' -} diff --git a/cli/src/recovery/app-id.ts b/cli/src/recovery/app-id.ts index adb41f3a85..8079547df3 100644 --- a/cli/src/recovery/app-id.ts +++ b/cli/src/recovery/app-id.ts @@ -5,10 +5,9 @@ import { join } from 'node:path' import { cwd } from 'node:process' import { confirm as pConfirm, isCancel as pIsCancel, log, select as pSelect, text as pText } from '@clack/prompts' import { trackEvent } from '../analytics/track' -import { addAppInternal, resolveAppGettingStartedMessage } from '../app/add' +import { addAppInternal } from '../app/add' import { getAppListPath } from '../app/list' import { extractApplicationIds } from '../build/onboarding/android/gradle-parser' -import { collectCordovaAppIdCandidates } from '../cordova/project' import { createSupabaseClient, findRoot, findSavedKeySilent, formatError, getAppId, getConfigForWrite, getOrganizationWithPermission, invokeCapgoCliApi, PACKNAME } from '../utils' import { writeConfigUpdater } from '../config' @@ -70,9 +69,6 @@ export function collectAppIdCandidates( } } - for (const cordovaAppId of collectCordovaAppIdCandidates(projectRoot)) - push(cordovaAppId) - return [...candidates] } @@ -279,12 +275,6 @@ export async function resolveAppIdWithRecovery(options: ResolveAppIdOptions): Pr await addAppInternal(appId, { apikey: resolvedApikey, supaHost: options.supaHost, supaAnon: options.supaAnon }, organization, true) await persistAppIdToConfig(appId) log.success(`Created app ${appId} in Capgo`) - const gettingStartedMessage = await resolveAppGettingStartedMessage(appId, { - supaHost: options.supaHost, - supaAnon: options.supaAnon, - }) - if (gettingStartedMessage) - log.info(gettingStartedMessage) trackAppIdRecovery(appId, 'create-app', resolvedApikey) return appId } diff --git a/cli/src/schemas/bundle.ts b/cli/src/schemas/bundle.ts index b65d701c7f..237f6bc17a 100644 --- a/cli/src/schemas/bundle.ts +++ b/cli/src/schemas/bundle.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import { CLI_PROJECT_MODES } from '../framework/mode' import { optionsBaseSchema } from './base' // ============================================================================ @@ -7,7 +6,6 @@ import { optionsBaseSchema } from './base' // ============================================================================ export const optionsUploadSchema = optionsBaseSchema.extend({ - mode: z.enum(CLI_PROJECT_MODES).optional(), bundle: z.string().optional(), path: z.string().optional(), channel: z.string().optional(), diff --git a/cli/src/schemas/sdk.ts b/cli/src/schemas/sdk.ts index 0feb50f923..642fec4ba4 100644 --- a/cli/src/schemas/sdk.ts +++ b/cli/src/schemas/sdk.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import { CLI_PROJECT_MODES } from '../framework/mode' import { buildCacheKeyOptionSchema, buildCacheOptionSchema, buildCredentialsSchema } from './build' import { localizedReleaseNotesSchema, rejectConflictingBooleanGroup } from './common' @@ -78,7 +77,6 @@ export type StarAllRepositoriesOptions = z.infer { - // Missing projects, config failures, and rejected reports are optional. -}).finally(() => exit(0)) diff --git a/cli/src/user/account.ts b/cli/src/user/account.ts index 9066f6d705..58362732cc 100644 --- a/cli/src/user/account.ts +++ b/cli/src/user/account.ts @@ -39,3 +39,7 @@ export async function getUserIdInternal(options: Options, silent = false) { throw error instanceof Error ? error : new Error(String(error)) } } + +export async function getUserId(options: Options) { + await getUserIdInternal(options, false) +} diff --git a/cli/src/user/whoami.ts b/cli/src/user/whoami.ts deleted file mode 100644 index e4bd2d6078..0000000000 --- a/cli/src/user/whoami.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { SupabaseClient } from '@supabase/supabase-js' -import type { Options } from '../api/app' -import type { Database } from '../types/supabase.types' -import { intro, log, outro } from '@clack/prompts' -import { trackEvent } from '../analytics/track' -import { formatTable } from '../terminal-table' -import { createSupabaseClient, findSavedKey, formatError, resolveUserIdFromApiKey } from '../utils' - -export async function resolveAccountEmail(supabase: SupabaseClient): Promise { - const emailResult = await supabase.rpc('request_actor_email_adress') - if (emailResult.error) - throw emailResult.error - if (!emailResult.data) - throw new Error('Account email not found for this API key') - - return emailResult.data -} - -export async function resolveAccountIdentity(supabase: SupabaseClient, apikey: string) { - const [userId, email] = await Promise.all([ - resolveUserIdFromApiKey(supabase, apikey, true), - resolveAccountEmail(supabase), - ]) - - return { userId, email } -} - -export async function whoami(options: Options) { - intro('Account details') - const apikey = options.apikey || findSavedKey() - if (!apikey) { - log.error('Missing API key, you need to provide an API key to fetch account details') - throw new Error('Missing API key') - } - - try { - const supabase = await createSupabaseClient(apikey, options.supaHost, options.supaAnon) - const { userId, email } = await resolveAccountIdentity(supabase, apikey) - - log.info(formatTable({ - headers: ['Account ID', 'Account email'], - rows: [[userId, email]], - })) - void trackEvent({ channel: 'account', event: 'Account Identity Viewed', apikey, tags: {} }) - outro('Done ✅') - } - catch (error) { - log.error(`Error getting account details ${formatError(error)}`) - throw error instanceof Error ? error : new Error(String(error)) - } -} diff --git a/cli/src/utils.ts b/cli/src/utils.ts index f943a66fb5..4d8124c426 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -653,10 +653,8 @@ function isPresentCapacitorConfig(extConfig: ExtConfigPairs | undefined): extCon return !!extConfig.path && existsSync(extConfig.path) } -export const NO_CAPACITOR_CONFIG_MESSAGE = 'No capacitor config file found, run `cap init` first' - async function getConfigFrom(loader: () => Promise, silent = false): Promise { - const message = NO_CAPACITOR_CONFIG_MESSAGE + const message = 'No capacitor config file found, run `cap init` first' try { const extConfig = await loader() if (!isPresentCapacitorConfig(extConfig)) { @@ -1091,8 +1089,7 @@ export async function createSupabaseClient(apikey: string, supaHost?: string, su config.supaKey = supaKey } if (!config.supaHost || !config.supaKey) { - if (!silent) - log.error(CAPGO_SERVER_CONFIG_MISSING_MESSAGE) + log.error(CAPGO_SERVER_CONFIG_MISSING_MESSAGE) throw new CliUserError(CAPGO_SERVER_CONFIG_MISSING_MESSAGE, { missingSupaHost: !config.supaHost, missingSupaKey: !config.supaKey, @@ -1995,7 +1992,7 @@ type SendEventPayload = TrackOptions & { nonPersonTags?: Record | { notifyConsole?: false, icon?: never } ) -export async function sendEvent(capgkey: string, payload: SendEventPayload, verbose?: boolean, signal?: AbortSignal, apiHost?: string, redirect?: RequestInit['redirect']): Promise { +export async function sendEvent(capgkey: string, payload: SendEventPayload, verbose?: boolean, signal?: AbortSignal): Promise { const telemetryDisabled = isTruthyEnvValue(env.CAPGO_DISABLE_TELEMETRY) || isTruthyEnvValue(env.CAPGO_DISABLE_POSTHOG) if (telemetryDisabled && !payload.notifyConsole) return @@ -2022,9 +2019,9 @@ export async function sendEvent(capgkey: string, payload: SendEventPayload, verb if (verbose) { log.info(`Get remove config: for ${payload.event}`) } - // A resolved destination avoids rediscovering config in background workers. - // Fetch config silently when needed so telemetry cannot interrupt terminal UIs. - const hostApi = apiHost ?? (await getRemoteConfig(true, signal)).hostApi + // Always fetch remote config silently — sendEvent is telemetry and must + // not bypass an Ink-controlled stdout (e.g. during `capgo init`). + const config = await getRemoteConfig(true, signal) if (verbose) { log.info(`Sending analytics event: ${JSON.stringify(enrichedPayload)}`) } @@ -2037,7 +2034,7 @@ export async function sendEvent(capgkey: string, payload: SendEventPayload, verb : controller.signal try { - const fetchResponse = await fetch(`${trimTrailingSlashes(hostApi)}/private/events`, { + const fetchResponse = await fetch(`${config.hostApi}/private/events`, { method: 'POST', body: JSON.stringify(enrichedPayload), headers: buildCliRequestHeaders({ @@ -2045,7 +2042,6 @@ export async function sendEvent(capgkey: string, payload: SendEventPayload, verb 'capgkey': capgkey, }), signal: eventSignal, - redirect, }) clearTimeout(timeoutId) diff --git a/cli/test/init/browser-login.test.ts b/cli/test/init/browser-login.test.ts index 5321d99351..2dd9ed8374 100644 --- a/cli/test/init/browser-login.test.ts +++ b/cli/test/init/browser-login.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs' import { describe, expect, it, mock } from 'bun:test' -import { beginBrowserLogin, completeBrowserLogin, loginInitInBrowser, shouldStartInitBrowserLogin } from '../../src/init/browser-login' +import { loginInitInBrowser, shouldStartInitBrowserLogin } from '../../src/init/browser-login' import * as loginModule from '../../src/login' const helperSource = readFileSync(new URL('../../src/init/browser-login.ts', import.meta.url), 'utf8') @@ -11,35 +11,6 @@ const initRuntimeSource = readFileSync(new URL('../../src/init/runtime.tsx', imp const initComponentsSource = readFileSync(new URL('../../src/init/ui/components.tsx', import.meta.url), 'utf8') describe('init browser login', () => { - it('keeps a browser session when opening the browser fails', async () => { - const urls: string[] = [] - const session = await beginBrowserLogin(url => urls.push(url), { - createSession: () => 'browser-session', - openUrl: async () => { throw new Error('browser unavailable') }, - }) - - expect(session).toEqual({ - session: 'browser-session', - url: 'https://console.capgo.app/login-cli?session=browser-session', - browserOpened: false, - }) - expect(urls).toEqual([session.url]) - }) - - it('completes the same session after validating the key', async () => { - const order: string[] = [] - await completeBrowserLogin({ - session: 'browser-session', - url: 'https://console.capgo.app/login-cli?session=browser-session', - browserOpened: true, - }, 'fake-key', { local: false }, { - validateKey: async () => { order.push('validate'); return { userId: 'user-1' } }, - listOrganizationIds: async () => { order.push('organizations'); return ['org-1'] }, - sendEvent: async (_key, payload) => { order.push(payload.description) }, - }) - expect(order).toEqual(['validate', 'organizations', 'cli-login:browser-session']) - }) - it('starts only for an interactive init with no resolved key', () => { expect(shouldStartInitBrowserLogin('', true)).toBe(true) expect(shouldStartInitBrowserLogin('argument-or-saved-key', true)).toBe(false) diff --git a/cli/test/prescan/checks-ios-entitlements-config.test.ts b/cli/test/prescan/checks-ios-entitlements-config.test.ts index ee1d67d2de..60b94de2ee 100644 --- a/cli/test/prescan/checks-ios-entitlements-config.test.ts +++ b/cli/test/prescan/checks-ios-entitlements-config.test.ts @@ -98,19 +98,6 @@ describe('ios/entitlements-vs-profile-capability', () => { expect(entitlementsVsProfileCapability.appliesTo?.(ctx)).toBe(false) }) - it('matches native iOS entitlements and profiles when the Builder app ID differs', async () => { - const ctx = ctxWithEntitlements( - 'com.apple.developer.healthkit', - { - appId: 'com.example.builder', - nativeAppId: 'com.demo.app', - credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('')) }, - }, - ) - expect(entitlementsVsProfileCapability.appliesTo?.(ctx)).toBe(true) - expect((await entitlementsVsProfileCapability.run(ctx))[0]?.severity).toBe('error') - }) - it('errors when the app declares a capability the profile does not grant', async () => { const ctx = ctxWithEntitlements( 'com.apple.developer.healthkit', diff --git a/cli/test/test-analytics.mjs b/cli/test/test-analytics.mjs index b951957f42..98830d0d11 100644 --- a/cli/test/test-analytics.mjs +++ b/cli/test/test-analytics.mjs @@ -46,8 +46,7 @@ try { delete process.env.CAPGO_DISABLE_TELEMETRY delete process.env.CAPGO_DISABLE_POSTHOG let requests = stubFetch() - const timestamp = new Date('2026-01-01T12:00:00Z') - await trackEvent({ apikey: 'capgo-key', channel: 'cli-usage', event: 'Test Event', orgId: 'org-1', appId: 'com.example.app', timestamp, tags: { foo: 'bar', count: 3, flag: true }, nonPersonTags: { scan_attempt_ids: ['example-attempt'] } }) + await trackEvent({ apikey: 'capgo-key', channel: 'cli-usage', event: 'Test Event', orgId: 'org-1', appId: 'com.example.app', tags: { foo: 'bar', count: 3, flag: true } }) await flushAnalytics() const req = findEvent(requests) assert.ok(req, 'expected a /private/events request') @@ -68,9 +67,6 @@ try { assert.equal(body.tags.flag, true) assert.equal(body.nonPersonTags.invocation_source, 'cli') assert.equal(typeof body.nonPersonTags.cli_version, 'string') - assert.deepEqual(body.nonPersonTags.scan_attempt_ids, ['example-attempt']) - assert.equal(body.tags.scan_attempt_ids, undefined, 'scan IDs are event properties only') - assert.equal(body.timestamp, timestamp.toISOString()) // 3. opt-out suppresses the send process.env.CAPGO_DISABLE_TELEMETRY = '1' @@ -136,12 +132,6 @@ try { assert.equal(body.tags.flags_count, 2) assert.equal(body.tags.positional_arg_count, 1) - requests = stubFetch() - trackCommandInvoked('app todo', ctx, 'explicit-todo-key') - await flushAnalytics() - assert.equal(findEvent(requests).init.headers.capgkey, 'explicit-todo-key', 'explicit --apikey takes precedence over a saved key') - assert.equal(JSON.stringify(JSON.parse(findEvent(requests).init.body).tags).includes('explicit-todo-key'), false, 'API keys must not appear in analytics tags') - // 6b. login/init defer invocation until an explicitly validated key is available process.env.CAPGO_TOKEN = 'stale-key' requests = stubFetch() diff --git a/cli/test/test-android-keystore-action.mjs b/cli/test/test-android-keystore-action.mjs deleted file mode 100644 index 3ed7595570..0000000000 --- a/cli/test/test-android-keystore-action.mjs +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict' -import { Buffer } from 'node:buffer' -import { runAndroidEffect } from '../src/build/onboarding/android/flow.ts' -import { trackAndroidKeystorePreparationFailure, trackPreparedAndroidKeystore } from '../src/build/onboarding/android/ui/keystore-action.ts' - -const journeyId = 'bj_keystore_test' -const actions = [] -const trackAction = (action, tags, step) => actions.push({ action, tags, step }) -const reportedSuccesses = new Set() - -const generatedProgress = () => ({ - appId: 'com.example.keystore', - platform: 'android', - startedAt: '2026-01-01T00:00:00.000Z', - keystoreMethod: 'generate', - keystoreAlias: 'release', - keystoreStorePassword: 'PRIVATE_STORE_PASSWORD', - keystoreKeyPassword: 'PRIVATE_KEY_PASSWORD', - keystoreCommonName: 'Private Customer Name', - completedSteps: {}, -}) - -const generatedOrder = [] -const generated = await runAndroidEffect('keystore-generating', generatedProgress(), { - generateKeystore: () => ({ - p12Base64: 'PRIVATE_GENERATED_KEYSTORE', - p12Bytes: Buffer.from('PRIVATE_GENERATED_KEYSTORE'), - alias: 'release', - notAfter: new Date('2050-01-01T00:00:00.000Z'), - }), - saveAndroidProgress: async (_appId, saved) => { - assert.equal(saved._keystoreBase64, 'PRIVATE_GENERATED_KEYSTORE') - assert.equal(saved.completedSteps.keystoreReady.isGenerated, true) - generatedOrder.push('saved-progress') - }, -}) -trackPreparedAndroidKeystore(generated.progress, 'generated', 'generated_with_keystore', journeyId, (...args) => { - generatedOrder.push('tracked-action') - trackAction(...args) -}, reportedSuccesses) -trackPreparedAndroidKeystore(generated.progress, 'generated', 'generated_with_keystore', journeyId, trackAction, reportedSuccesses) -assert.deepEqual(generatedOrder, ['saved-progress', 'tracked-action']) -assert.deepEqual(actions.splice(0), [{ - action: 'keystore_prepared', - tags: { - attempt_id: journeyId, - source: 'generated', - key_password: 'generated_with_keystore', - }, - step: 'keystore-generating', -}]) - -const importedProgress = keyPassword => ({ - appId: 'com.example.keystore', - platform: 'android', - startedAt: '2026-01-01T00:00:00.000Z', - keystoreMethod: 'existing', - keystoreExistingPath: '/private/customer-upload-key.p12', - keystoreAlias: 'private-customer-alias', - keystoreStorePassword: 'PRIVATE_STORE_PASSWORD', - ...(keyPassword ? { keystoreKeyPassword: keyPassword } : {}), - completedSteps: {}, -}) - -async function prepareImported(progress, probeResult) { - let persisted = null - const order = [] - const result = await runAndroidEffect('keystore-existing-key-password', progress, { - readFile: async () => Buffer.from('PRIVATE_IMPORTED_KEYSTORE'), - tryUnlockPrivateKey: () => probeResult, - saveAndroidProgress: async (_appId, saved) => { - persisted = saved - order.push('saved-progress') - }, - loadAndroidProgress: async () => persisted, - }) - return { result, order } -} - -const verifiedImport = await prepareImported(importedProgress(), { ok: true }) -trackPreparedAndroidKeystore(verifiedImport.result.progress, 'imported', 'verified', journeyId, (...args) => { - verifiedImport.order.push('tracked-action') - trackAction(...args) -}, reportedSuccesses) -assert.deepEqual(verifiedImport.order, ['saved-progress', 'tracked-action']) -assert.deepEqual(actions.splice(0), [{ - action: 'keystore_prepared', - tags: { - attempt_id: journeyId, - source: 'imported', - key_password: 'verified', - }, - step: 'keystore-existing-key-password', -}]) - -// A separately entered key password is valid preparation input even though a -// build has not verified it yet. -const separateSuccesses = new Set() -const separateImport = await prepareImported(importedProgress('PRIVATE_SEPARATE_KEY_PASSWORD'), { - ok: false, - reason: 'wrong-password', - message: 'PRIVATE_RAW_PROBE_ERROR', -}) -trackPreparedAndroidKeystore(separateImport.result.progress, 'imported', 'not_checked', journeyId, trackAction, separateSuccesses) -assert.deepEqual(actions.splice(0), [{ - action: 'keystore_prepared', - tags: { - attempt_id: journeyId, - source: 'imported', - key_password: 'not_checked', - }, - step: 'keystore-existing-key-password', -}]) - -// A failed same-password probe only opens the separate key-password question. -// It is not a preparation failure. -const promptResult = await runAndroidEffect('keystore-existing-key-password', importedProgress(), { - readFile: async () => Buffer.from('PRIVATE_IMPORTED_KEYSTORE'), - tryUnlockPrivateKey: () => ({ - ok: false, - reason: 'wrong-password', - message: 'PRIVATE_RAW_PROBE_ERROR', - }), -}) -assert.equal(promptResult.next, 'keystore-existing-key-password') -assert.equal(promptResult.transient.needsKeyPasswordPrompt, true) -assert.equal(actions.length, 0) - -// Genuine generation and import work failures emit safe categorical outcomes. -await assert.rejects(() => runAndroidEffect('keystore-generating', generatedProgress(), { - generateKeystore: () => { throw new Error('PRIVATE_GENERATION_ERROR') }, -}), /PRIVATE_GENERATION_ERROR/) -trackAndroidKeystorePreparationFailure('generated', 'generated_with_keystore', 'generate_failed', journeyId, trackAction) - -await assert.rejects(() => runAndroidEffect('keystore-existing-key-password', importedProgress('PRIVATE_SEPARATE_KEY_PASSWORD'), { - readFile: async () => { throw new Error('PRIVATE_IMPORT_ERROR') }, -}), /PRIVATE_IMPORT_ERROR/) -trackAndroidKeystorePreparationFailure('imported', 'not_checked', 'import_failed', journeyId, trackAction) -assert.deepEqual(actions.splice(0), [ - { - action: 'keystore_preparation_failed', - tags: { - attempt_id: journeyId, - source: 'generated', - reason: 'generate_failed', - key_password: 'generated_with_keystore', - }, - step: 'keystore-generating', - }, - { - action: 'keystore_preparation_failed', - tags: { - attempt_id: journeyId, - source: 'imported', - reason: 'import_failed', - key_password: 'not_checked', - }, - step: 'keystore-existing-key-password', - }, -]) - -// A failed attempt does not reserve the success key, so a retry can still -// report preparation. -trackAndroidKeystorePreparationFailure('generated', 'generated_with_keystore', 'generate_failed', journeyId, trackAction) -const retrySuccesses = new Set() -trackPreparedAndroidKeystore(generated.progress, 'generated', 'generated_with_keystore', journeyId, trackAction, retrySuccesses) -assert.deepEqual(actions.map(event => event.action), ['keystore_preparation_failed', 'keystore_prepared']) - -const safeEvents = JSON.stringify(actions) -for (const secret of [ - 'PRIVATE_STORE_PASSWORD', - 'PRIVATE_KEY_PASSWORD', - 'PRIVATE_SEPARATE_KEY_PASSWORD', - 'PRIVATE_GENERATED_KEYSTORE', - 'PRIVATE_RAW_PROBE_ERROR', - 'PRIVATE_GENERATION_ERROR', - 'PRIVATE_IMPORT_ERROR', - 'private-customer-alias', - '/private/customer-upload-key.p12', -]) { - assert.equal(safeEvents.includes(secret), false, `telemetry leaked ${secret}`) -} -actions.length = 0 - -// Incomplete data or an unsaved marker never counts as prepared. -trackPreparedAndroidKeystore({ - ...generated.progress, - completedSteps: {}, -}, 'generated', 'generated_with_keystore', journeyId, trackAction, new Set()) -assert.equal(actions.length, 0) -assert.doesNotThrow(() => trackAndroidKeystorePreparationFailure( - 'imported', - 'not_checked', - 'import_failed', - journeyId, - () => { throw new Error('telemetry unavailable') }, -)) - -console.log('✅ Android keystore actions follow persisted outcomes and use safe tags') diff --git a/cli/test/test-app-add-getting-started.mjs b/cli/test/test-app-add-getting-started.mjs deleted file mode 100644 index ce111b9481..0000000000 --- a/cli/test/test-app-add-getting-started.mjs +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict' -import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import process from 'node:process' -import { - appGettingStartedUrl, - formatAppGettingStartedMessage, - resolveAppGettingStartedMessage, - shouldPrintAppGettingStartedUrl, -} from '../src/app/add.ts' -import { defaultHostWeb } from '../src/utils.ts' - -console.log('🧪 Testing app add getting-started URL...\n') - -const appId = 'com.example.app' -const expectedUrl = `${defaultHostWeb}/app/${appId}/getting-started` - -assert.equal(appGettingStartedUrl(appId), expectedUrl) -assert.equal(appGettingStartedUrl(appId, 'https://dashboard.example.com/'), `https://dashboard.example.com/app/${appId}/getting-started`) -assert.equal( - formatAppGettingStartedMessage(appId), - `Continue setup at ${expectedUrl}`, -) - -assert.equal(shouldPrintAppGettingStartedUrl(defaultHostWeb, false), true) -assert.equal(shouldPrintAppGettingStartedUrl(defaultHostWeb, true), false) -assert.equal(shouldPrintAppGettingStartedUrl(`${defaultHostWeb}/`, true), false) -assert.equal(shouldPrintAppGettingStartedUrl('https://dashboard.example.com', true), true) - -const tempDirs = [] -function makeTempDir(name) { - const dir = realpathSync(mkdtempSync(join(tmpdir(), `capgo-cli-getting-started-${name}-`))) - tempDirs.push(dir) - return dir -} - -function writeCapacitorConfig(root, updater = {}) { - writeFileSync(join(root, 'capacitor.config.json'), JSON.stringify({ - appId, - appName: 'demo', - webDir: 'www', - plugins: { CapacitorUpdater: updater }, - }, null, 2)) -} - -async function withTempProject(name, updater, fn) { - const root = makeTempDir(name) - const previousCwd = process.cwd() - writeCapacitorConfig(root, updater) - process.chdir(root) - try { - return await fn() - } - finally { - process.chdir(previousCwd) - } -} - -assert.equal( - await withTempProject('default-host', {}, () => resolveAppGettingStartedMessage(appId)), - `Continue setup at ${expectedUrl}`, -) - -assert.equal( - await withTempProject('custom-dashboard', { - localWebHost: 'https://dashboard.example.com', - localSupa: 'https://supabase.example.com', - localSupaAnon: 'anon-key', - }, () => resolveAppGettingStartedMessage(appId)), - `Continue setup at https://dashboard.example.com/app/${appId}/getting-started`, -) - -assert.equal( - await withTempProject('custom-supabase-only', { - localSupa: 'https://supabase.example.com', - localSupaAnon: 'anon-key', - }, () => resolveAppGettingStartedMessage(appId)), - null, -) - -assert.equal( - await withTempProject('cli-supa-host', {}, () => resolveAppGettingStartedMessage(appId, { - supaHost: 'https://supabase.example.com', - supaAnon: 'anon-key', - })), - null, -) - -assert.equal( - await withTempProject('trailing-slash-default', { - localWebHost: `${defaultHostWeb}/`, - localSupa: 'https://supabase.example.com', - localSupaAnon: 'anon-key', - }, () => resolveAppGettingStartedMessage(appId)), - null, -) - -for (const dir of tempDirs) - rmSync(dir, { recursive: true, force: true }) - -console.log('✅ app add getting-started URL tests passed') diff --git a/cli/test/test-app-todo.mjs b/cli/test/test-app-todo.mjs deleted file mode 100644 index 4d0bb2b2b2..0000000000 --- a/cli/test/test-app-todo.mjs +++ /dev/null @@ -1,274 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict' -import { spawnSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { formatAppTodoList, getAppTodoSteps, readAppTodoProgress, TODO_BACKGROUND_WAIT_MS, waitForTodoBackgroundChecks } from '../src/app/todo.ts' -import { getAppOnboardingStepIds, parseAppOnboarding } from '../../supabase/functions/_backend/utils/appOnboarding.ts' -import messages from '../../messages/en.json' - -const appId = 'com.example.todo' -const options = { apikey: 'test-todo-key', supaHost: 'http://localhost:54321', supaAnon: 'test-anon-key' } -const progress = { - onboarding: { - setup: { - todo_list_version: 3, - steps: { - login_cli_mcp: { status: 'done' }, - add_channel: { status: 'done' }, - add_updater: { status: 'skipped' }, - add_code: { status: 'invalid' }, - completion: { status: 'done' }, - }, - }, - }, - hasChannel: false, - checkErrors: [], -} - -assert.equal(TODO_BACKGROUND_WAIT_MS, 10_000) -const countdown = [] -const timedOutAt = Date.now() -assert.equal(await waitForTodoBackgroundChecks([new Promise(() => {})], seconds => countdown.push(seconds), 2_200), false) -assert.equal(countdown[0], 3) -assert.ok(countdown.length >= 2, 'interactive countdown updates while waiting') -assert.ok(countdown.slice(1).every((seconds, index) => seconds < countdown[index]), 'remaining seconds only decrease') -assert.ok(Date.now() - timedOutAt >= 2_100, 'wait honors its shared deadline') -let finishCheck -const completedCheck = new Promise(resolve => { finishCheck = resolve }) -const finishEarly = waitForTodoBackgroundChecks([completedCheck], undefined, 10_000) -finishCheck() -assert.equal(await finishEarly, true, 'completed checks end the wait before the deadline') - -for (const version of [1, 2, 3, 4, 0, -1, 1.5, '3', undefined]) { - const value = { setup: { todo_list_version: version, ...(version === 4 ? { ota_todo_list_version: '1' } : {}), steps: version === 4 ? { ota: progress.onboarding.setup.steps } : progress.onboarding.setup.steps } } - const parsed = parseAppOnboarding(value) - const actual = getAppTodoSteps({ onboarding: value }) - assert.equal(actual.version, parsed.todo_list_version) - assert.deepEqual(actual.steps.map(step => step.id), getAppOnboardingStepIds(parsed.todo_list_version, parsed.ota_todo_list_version), 'step order matches the frontend') - for (const step of actual.steps) { - assert.equal(step.status, parsed.steps[step.id]?.status ?? 'pending') - const prefix = actual.version === 3 || actual.version === 4 ? 'setup-checklist-step-' : 'app-onboarding-cli-step-' - assert.equal(step.title, messages[prefix + step.id], 'task titles match the frontend') - } -} - -for (const value of [null, [], {}, { setup: null }, { setup: [] }]) - assert.equal(getAppTodoSteps({ onboarding: value }).steps.length, 12) -assert.equal(getAppTodoSteps({ onboarding: { todo_list_version: 1, steps: { add_app: { status: 'done' } } } }).steps[0].status, 'done', 'supports legacy unwrapped setup') - -const output = formatAppTodoList(appId, progress) -assert.match(output, /Todo list v3/) -assert.match(output, /2\/7 completed \(1 done, 1 skipped, 5 pending\)/) -assert.match(output, /\[x\] Done: Start guided setup/) -assert.match(output, /\[-\] Skipped: Install Capgo Updater/) -assert.match(output, /\[ \] Pending: Create a channel/) -assert.match(output, /\[ \] Pending: Add the app-ready code/) -assert.match(output, /Next step: Create a channel/) -assert.match(output, /Done when: Capgo finds a channel for this app/) -assert.match(output, /Run this command again to recheck progress/) -assert.doesNotMatch(output, /Done when: The CLI finds that call/, 'only the next pending step is explained') -assert.doesNotMatch(output, /\u001B\[/, 'plain output has no color codes') -const coloredOutput = formatAppTodoList(appId, progress, { color: true }) -assert.match(coloredOutput, /\u001B\[32m\[x\] Done\u001B\[0m/) -assert.match(coloredOutput, /\u001B\[33m\[ \] Pending\u001B\[0m/) -assert.match(coloredOutput, /\u001B\[2m\[-\] Skipped\u001B\[0m/) -assert.match(coloredOutput, /\u001B\[1;36mNext step: Create a channel\u001B\[0m/) -assert.doesNotMatch(output, /Completion|encryption|undefined/) -assert.match(formatAppTodoList(appId, { ...progress, hasChannel: true }), /3\/7 completed/) -assert.match(formatAppTodoList(appId, { ...progress, hasChannel: true }), /Next step: Add the app-ready code/) -assert.match(formatAppTodoList(appId, { onboarding: progress.onboarding }), /3\/7 completed/, 'retains saved channel progress when the live check is unavailable') -assert.equal(progress.onboarding.setup.steps.add_channel.status, 'done', 'does not mutate saved progress') -const v4Progress = { onboarding: { setup: { todo_list_version: 4, ota_todo_list_version: '1', paths: ['ota'], selected_path: 'ota', steps: { ota: { ...progress.onboarding.setup.steps } } } }, hasChannel: false } -const v4Output = formatAppTodoList(appId, v4Progress) -assert.match(v4Output, /Todo list v4/) -assert.match(v4Output, /2\/7 completed/) -assert.match(v4Output, /Next step: Create a channel/) -assert.equal(getAppTodoSteps(v4Progress).steps.find(step => step.id === 'add_updater').status, 'skipped') -for (const otaVersion of ['2', 1, undefined]) { - const unsupported = { onboarding: { setup: { todo_list_version: 4, ota_todo_list_version: otaVersion, steps: { ota: progress.onboarding.setup.steps } } } } - assert.deepEqual(getAppTodoSteps(unsupported).steps, [], 'unsupported OTA versions do not show v1 steps') - assert.match(formatAppTodoList(appId, unsupported), /does not support this OTA checklist version/) -} -const allDone = formatAppTodoList(appId, { onboarding: { setup: { todo_list_version: 3, steps: Object.fromEntries(getAppOnboardingStepIds(3).map(id => [id, { status: 'done' }])) } } }) -assert.match(allDone, /7\/7 completed \(7 done, 0 skipped, 0 pending\)/) -assert.doesNotMatch(allDone, /Next step:/) -const v2Output = formatAppTodoList(appId, { onboarding: { setup: { todo_list_version: 2, steps: {} } } }) -assert.doesNotMatch(v2Output, /Next step:|Done when:/, 'v2 keeps the checklist without v3 guidance') - -for (const [id, action, completion] of [ - ['login_cli_mcp', 'Run a Capgo CLI command', 'Capgo records that CLI or MCP activity'], - ['add_channel', 'Create a channel', 'Capgo finds a channel'], - ['add_updater', 'Install @capgo/capacitor-updater', 'The CLI finds the dependency'], - ['add_code', 'Call CapacitorUpdater.notifyAppReady()', 'The CLI finds that call'], - ['run_device', 'Build and open the app', 'Capgo sees a device'], - ['upload_bundle', 'Build and upload your first', 'Capgo finds a published bundle'], - ['test_update', 'Assign the update', 'Capgo records a device applying'], -]) { - const steps = Object.fromEntries(getAppOnboardingStepIds(3).map(stepId => [stepId, { status: stepId === id ? 'pending' : 'done' }])) - const text = formatAppTodoList(appId, { onboarding: { setup: { todo_list_version: 3, steps } } }) - assert.match(text, new RegExp(`Next step: ${messages[`setup-checklist-step-${id}`]}`)) - assert.ok(text.includes(action), `${id} explains the action`) - assert.ok(text.includes(`Done when: ${completion}`), `${id} explains the completion signal`) - assert.equal((text.match(/Next step:/g) ?? []).length, 1) -} - -const originalFetch = globalThis.fetch -try { - globalThis.fetch = async (input, init) => { - assert.equal(String(input), options.supaHost + '/functions/v1/private/onboarding_progress') - assert.equal(init.method, 'POST') - assert.deepEqual(JSON.parse(init.body), { appId, N: 0, initial: true }) - assert.equal(init.headers.capgkey, options.apikey) - assert.equal(init.headers.Authorization, 'Bearer ' + options.supaAnon) - return Response.json(progress) - } - assert.deepEqual(await readAppTodoProgress(appId, options), progress) - for (const [status, expected] of [[401, /app.read permission/], [403, /app.read permission/], [404, /App not found/], [500, /database_unavailable/]]) { - globalThis.fetch = async () => Response.json({ error: 'database_unavailable' }, { status }) - await assert.rejects(() => readAppTodoProgress(appId, options), expected) - } - globalThis.fetch = async () => Response.json({ status: 'ok' }) - await assert.rejects(() => readAppTodoProgress(appId, options), /invalid progress response/) -} -finally { - globalThis.fetch = originalFetch -} - -const fixture = mkdtempSync(join(tmpdir(), 'capgo-app-todo-')) -try { - writeFileSync(join(fixture, 'capacitor.config.json'), JSON.stringify({ appId, appName: 'Todo test', webDir: 'dist' })) - writeFileSync(join(fixture, 'package.json'), JSON.stringify({ name: 'todo-test', version: '1.0.0' })) - const preload = join(fixture, 'fetch.mjs') - writeFileSync(preload, ` - import { appendFileSync, existsSync, writeFileSync } from 'node:fs' - import { isMainThread } from 'node:worker_threads' - const nativeFetch = globalThis.fetch - const codeMarker = ${JSON.stringify(join(fixture, 'background-code-updated'))} - const updaterMarker = ${JSON.stringify(join(fixture, 'background-updater-updated'))} - const progressReads = ${JSON.stringify(join(fixture, 'progress-reads'))} - globalThis.fetch = async (input, init) => { - const url = input?.url ?? String(input) - const scenario = process.env.CAPGO_TODO_SCENARIO - if (!url.startsWith('http') || url.includes('.wasm')) return nativeFetch(input, init) - if (url.includes('/private/events') && process.env.CAPGO_TODO_TRACKING_FILE) { - const event = JSON.parse(init.body) - if (event.event === 'CLI Command Invoked') - writeFileSync(process.env.CAPGO_TODO_TRACKING_FILE, JSON.stringify({ key: init.headers.capgkey, command: event.tags.command_path })) - } - if (url.includes('/private/config')) return Response.json({ supaHost: ${JSON.stringify(options.supaHost)}, supaKey: ${JSON.stringify(options.supaAnon)} }) - if (url.includes('/rpc/reject_access_due_to_2fa_for_app')) return Response.json(scenario === 'two-factor') - if (scenario?.startsWith('background-updated') && init?.method === 'PUT' && url.endsWith('/app/${appId}')) { - await new Promise(resolve => setTimeout(resolve, 700)) - const steps = JSON.parse(init.body).onboarding.steps - if (steps.add_code) writeFileSync(codeMarker, 'done') - if (steps.add_updater) writeFileSync(updaterMarker, 'done') - return Response.json({ status: 'ok' }) - } - if (url.includes('/private/onboarding_progress')) { - if (scenario?.startsWith('background-updated') && isMainThread) appendFileSync(progressReads, 'read\\n') - if (scenario === 'denied') return Response.json({ error: 'app_access_denied' }, { status: 403 }) - if (scenario === 'missing') return Response.json({ error: 'app_not_found' }, { status: 404 }) - if (scenario === 'failed') return Response.json({ error: 'database_unavailable' }, { status: 500 }) - if (scenario === 'invalid') return Response.json({ status: 'ok' }) - const progress = ${JSON.stringify(progress)} - if (scenario === 'partial') progress.checkErrors = ['run_device'] - if (scenario === 'v2') progress.onboarding.setup.todo_list_version = 2 - if (scenario === 'empty') progress.onboarding = null - if (scenario?.startsWith('background-updated')) { - if (scenario === 'background-updated-v4') { - progress.onboarding.setup.todo_list_version = 4 - progress.onboarding.setup.ota_todo_list_version = '1' - progress.onboarding.setup.steps = { ota: progress.onboarding.setup.steps } - } - const steps = scenario === 'background-updated-v4' ? progress.onboarding.setup.steps.ota : progress.onboarding.setup.steps - steps.add_code.status = existsSync(codeMarker) ? 'done' : 'pending' - steps.add_updater.status = existsSync(updaterMarker) ? 'done' : 'pending' - } - return Response.json(progress) - } - return Response.json({ status: 'ok' }) - } - `) - const builtCli = new URL('../dist/index.js', import.meta.url).pathname - for (const alias of ['todo', 'todoList']) { - const help = spawnSync('node', [builtCli, 'app', alias, '--help'], { encoding: 'utf8' }) - assert.equal(help.status, 0, help.stderr) - assert.match(help.stdout, /todo\|todoList \[options\] \[appId\]/) - for (const scenario of ['v3', 'v2', 'empty', 'partial', 'inferred', 'denied', 'missing', 'failed', 'invalid', 'two-factor']) { - const child = spawnSync('node', [ - '--import', preload, builtCli, 'app', alias, - ...(scenario === 'inferred' ? [] : [appId]), - '-a', options.apikey, '--supa-host', options.supaHost, '--supa-anon', options.supaAnon, - ], { - cwd: fixture, encoding: 'utf8', timeout: 15000, - env: { ...process.env, CAPGO_TODO_SCENARIO: scenario, CAPGO_DISABLE_TELEMETRY: '1', CAPGO_DISABLE_POSTHOG: '1', CI: '1' }, - }) - const text = child.stdout + child.stderr - const failure = ['denied', 'missing', 'failed', 'invalid', 'two-factor'].includes(scenario) - assert.equal(child.status, failure ? 1 : 0, text) - assert.match(text, /Loading the todo list/, 'non-interactive commands report the pending work') - assert.doesNotMatch(text, /Todo list loaded|Could not load todo list/, 'non-interactive commands do not render spinner completion') - assert.doesNotMatch(text, /\u001B\[/, 'non-interactive commands do not use ANSI colors') - if (!failure) { - assert.match(text, new RegExp('App: ' + appId.replaceAll('.', '\\.'))) - assert.match(text, /\[ \] Pending:/) - if (scenario === 'v2') { - assert.match(text, /Todo list v2/) - assert.doesNotMatch(text, /Next step:/) - assert.doesNotMatch(text, /Waiting 10 seconds for background TODO list checks/, 'v2 does not wait for background checks') - } - else if (scenario === 'empty') { - assert.match(text, /0\/12 completed/) - assert.doesNotMatch(text, /Waiting 10 seconds for background TODO list checks/) - } - else assert.match(text, /2\/7 completed/) - if (scenario === 'partial') assert.match(text, /Some live progress checks failed/) - } - else { - assert.doesNotMatch(text, /\[x\] Done:|\[ \] Pending:/, 'failures never print a misleading checklist') - if (scenario === 'denied') assert.match(text, /app.read permission/) - if (scenario === 'two-factor') assert.match(text, /2FA|two.factor/i) - } - } - } - const trackingFile = join(fixture, 'tracking.json') - const tracked = spawnSync('node', ['--import', preload, builtCli, 'app', 'todo', appId, '-a', options.apikey, '--supa-host', options.supaHost, '--supa-anon', options.supaAnon], { - cwd: fixture, encoding: 'utf8', timeout: 15000, - env: { ...process.env, CAPGO_TOKEN: 'stale-saved-key', CAPGO_TODO_TRACKING_FILE: trackingFile, CI: '1', CAPGO_DISABLE_TELEMETRY: '', CAPGO_DISABLE_POSTHOG: '' }, - }) - assert.equal(tracked.status, 0, tracked.stdout + tracked.stderr) - assert.deepEqual(JSON.parse(readFileSync(trackingFile, 'utf8')), { key: options.apikey, command: 'app todo' }, 'the command event uses --apikey instead of a saved key') - - writeFileSync(join(fixture, 'package.json'), JSON.stringify({ - name: 'todo-test', version: '1.0.0', dependencies: { '@capgo/capacitor-updater': '8.0.0' }, - })) - mkdirSync(join(fixture, 'node_modules/@capgo/capacitor-updater'), { recursive: true }) - writeFileSync(join(fixture, 'node_modules/@capgo/capacitor-updater/package.json'), JSON.stringify({ name: '@capgo/capacitor-updater', version: '8.0.0' })) - mkdirSync(join(fixture, 'src')) - writeFileSync(join(fixture, 'src/main.ts'), "import { CapacitorUpdater } from '@capgo/capacitor-updater'; CapacitorUpdater.notifyAppReady()") - for (const scenario of ['background-updated', 'background-updated-v4']) { - for (const name of ['background-code-updated', 'background-updater-updated', 'progress-reads']) - rmSync(join(fixture, name), { force: true }) - const backgroundUpdate = spawnSync('node', [ - '--import', preload, builtCli, 'app', 'todo', appId, - '-a', options.apikey, '--supa-host', options.supaHost, '--supa-anon', options.supaAnon, - ], { - cwd: fixture, encoding: 'utf8', timeout: 15_000, - env: { ...process.env, CAPGO_TODO_SCENARIO: scenario, CAPGO_DISABLE_TELEMETRY: '1', CAPGO_DISABLE_POSTHOG: '1', CI: '1' }, - }) - const backgroundText = backgroundUpdate.stdout + backgroundUpdate.stderr - assert.equal(backgroundUpdate.status, 0, backgroundText) - assert.match(backgroundText, new RegExp(`Todo list v${scenario === 'background-updated-v4' ? 4 : 3}`)) - assert.equal((backgroundText.match(/Waiting 10 seconds for background TODO list checks to finish/g) ?? []).length, 1, backgroundText) - assert.doesNotMatch(backgroundText, /Waiting [1-9] seconds for background TODO list checks to finish/, 'non-interactive output does not count down') - assert.match(backgroundText, /\[x\] Done: Add the app-ready code/, 'the printed list includes the background report') - assert.match(backgroundText, /\[x\] Done: Install Capgo Updater/, 'the list waits for the updater check too') - assert.equal(readFileSync(join(fixture, 'progress-reads'), 'utf8').trim().split('\n').length, 2, `${scenario} rereads progress after the worker completes`) - } -} -finally { - rmSync(fixture, { recursive: true, force: true }) -} -console.log('App todo frontend parity, live progress, errors, app ID inference, and both built CLI aliases passed') diff --git a/cli/test/test-auth-session.mjs b/cli/test/test-auth-session.mjs index 2ed8aa82f3..e234358ce7 100644 --- a/cli/test/test-auth-session.mjs +++ b/cli/test/test-auth-session.mjs @@ -37,7 +37,6 @@ const { whoamiMessage, logoutMessage, } = await import('../src/auth/session.ts') -const { resolveAccountIdentity } = await import('../src/user/whoami.ts') await test('validateAndSaveKey rejects an empty key (no write, no network)', async () => { let threw = false @@ -112,33 +111,6 @@ await test('loginSuccessMessage names the user and the scope path', () => { ok(loginSuccessMessage('u9', true).includes('./.capgo'), 'local path mentions ./.capgo') }) -await test('account identity starts ID and email RPCs in parallel', async () => { - const started = [] - const finish = {} - const client = { - rpc(name) { - started.push(name) - return new Promise((resolve) => { finish[name] = resolve }) - }, - } - - const identity = resolveAccountIdentity(client, 'test-key') - eq(started.join(','), 'request_actor_user_id,request_actor_email_adress') - finish.request_actor_email_adress({ data: 'account@example.com', error: null }) - finish.request_actor_user_id({ data: 'user-1', error: null }) - const result = await identity - eq(result.userId, 'user-1') - eq(result.email, 'account@example.com') -}) - -await test('account identity rejects missing email', async () => { - const client = { rpc: async name => ({ data: name === 'request_actor_user_id' ? 'user-1' : null, error: null }) } - let threw = false - try { await resolveAccountIdentity(client, 'test-key') } - catch (error) { threw = true; ok(/email not found/.test(error.message)) } - ok(threw, 'a missing email must not produce a partial identity') -}) - console.log(`📊 Results: ${pass} passed, ${fail} failed`) if (fail > 0) process.exit(1) diff --git a/cli/test/test-authenticated-command-invocation.mjs b/cli/test/test-authenticated-command-invocation.mjs index 2f66f10fe1..6412309dc5 100644 --- a/cli/test/test-authenticated-command-invocation.mjs +++ b/cli/test/test-authenticated-command-invocation.mjs @@ -36,7 +36,6 @@ const testDir = dirname(fileURLToPath(import.meta.url)) const indexSource = readFileSync(join(testDir, '../src/index.ts'), 'utf8') const loginSource = readFileSync(join(testDir, '../src/login.ts'), 'utf8') const initSource = readFileSync(join(testDir, '../src/init/command.ts'), 'utf8') -const builderSource = readFileSync(join(testDir, '../src/build/onboarding/command.ts'), 'utf8') function sourceBetween(source, start, end) { const startIndex = source.indexOf(start) @@ -49,8 +48,6 @@ function sourceBetween(source, start, end) { const preActionSource = sourceBetween(indexSource, "program.hook('preAction'", "program.hook('postAction'") assert.match(preActionSource, /currentCommandPath === 'login'/) assert.match(preActionSource, /currentCommandPath === 'init'/) -assert.match(preActionSource, /currentCommandPath === 'build init'/) -assert.match(preActionSource, /currentCommandPath === 'build onboarding'/) assert.match(preActionSource, /deferCommandInvocation\(currentCommandPath, commandContext\)/) const initCommandSource = sourceBetween(indexSource, ".command('init [apikey] [appId]')", "program\n .command('doctor')") @@ -80,8 +77,4 @@ assert.ok(initSavedKeyFallbackIndex < initValidationIndex, 'init restores its sa assert.ok(initValidationIndex >= 0, 'init validates the selected key with Capgo') assert.ok(initInvocationIndex > initValidationIndex, 'init invocation is emitted only after validation') -const builderAuthCallback = sourceBetween(builderSource, 'onAuthenticated: (key, metadata) => {', 'onBeforeExit: finishBuildReplay') -assert.match(builderAuthCallback, /flushDeferredCommandInvocation\(key\)/) -assert.match(builderAuthCallback, /if \(metadata\.method\)/) - console.log('✅ authenticated command invocation tests passed') diff --git a/cli/test/test-background-check-shutdown.mjs b/cli/test/test-background-check-shutdown.mjs deleted file mode 100644 index 4e1db3f885..0000000000 --- a/cli/test/test-background-check-shutdown.mjs +++ /dev/null @@ -1,184 +0,0 @@ -import assert from 'node:assert/strict' -import { spawn } from 'node:child_process' -import { once } from 'node:events' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { afterAll, beforeAll, test } from 'bun:test' - -const dir = mkdtempSync(join(tmpdir(), 'capgo-background-shutdown-')) -let harness -let runnerCount = 0 - -beforeAll(async () => { - writeFileSync(join(dir, 'entry.ts'), ` - export { startOnboardingCheck } from ${JSON.stringify(fileURLToPath(new URL('../src/onboarding/background.ts', import.meta.url)))} - export { waitForOnboardingChecks } from ${JSON.stringify(fileURLToPath(new URL('../src/onboarding/background-shutdown.ts', import.meta.url)))} - export { getPendingOnboardingChecks } from ${JSON.stringify(fileURLToPath(new URL('../src/onboarding/background-workers.ts', import.meta.url)))} - `) - const build = await Bun.build({ entrypoints: [join(dir, 'entry.ts')], outdir: dir, target: 'node', format: 'esm' }) - assert.equal(build.success, true) - harness = pathToFileURL(join(dir, 'entry.js')).href - writeFileSync(join(dir, 'busy.mjs'), 'setInterval(() => {}, 10_000)') - writeFileSync(join(dir, 'quick.mjs'), 'setTimeout(() => {}, 300)') - writeFileSync(join(dir, 'slow.mjs'), 'setTimeout(() => {}, 900)') -}) - -afterAll(() => rmSync(dir, { recursive: true, force: true })) - -function run({ commandPath = 'app list', options = {}, tty = true, stdinTty = tty, stdoutTty = tty, ci = false, workers = ['busy.mjs'], foregroundMs = 0, previousInterrupt = false, telemetry = 'ok', disabled = false } = {}) { - const source = ` - import { performance } from 'node:perf_hooks' - import { startOnboardingCheck, waitForOnboardingChecks, getPendingOnboardingChecks } from ${JSON.stringify(harness)} - Object.defineProperty(process.stdin, 'isTTY', { value: ${stdinTty} }) - Object.defineProperty(process.stdout, 'isTTY', { value: ${stdoutTty} }) - globalThis.fetch = async (url, init) => { - if (!String(url).endsWith('/private/events')) - return new Response('', { status: 500 }) - console.log('telemetry:' + init.body) - if (${JSON.stringify(telemetry)} === 'reject') - throw new Error('offline') - if (${JSON.stringify(telemetry)} === 'hang') { - return new Promise((resolve, reject) => { - const abort = () => { console.log('telemetry-aborted'); reject(new Error('aborted')) } - if (init.signal.aborted) abort() - else init.signal.addEventListener('abort', abort, { once: true }) - }) - } - return new Response('{}', { headers: { 'Content-Type': 'application/json' } }) - } - const command = { optsWithGlobals: () => (${JSON.stringify({ apikey: 'fake-api-key', appId: 'com.example.ready', ...options })}), registeredArguments: [], args: [] } - for (const filename of ${JSON.stringify(workers)}) - startOnboardingCheck(command, ${JSON.stringify(commandPath)}, new URL(filename, ${JSON.stringify(pathToFileURL(join(dir, 'run.mjs')).href)})) - console.log('pending-attempts:' + JSON.stringify([...getPendingOnboardingChecks().values()].map(check => check.attemptId))) - let foregroundInterrupts = 0 - if (${previousInterrupt}) { - process.on('SIGINT', () => { foregroundInterrupts++; console.log('foreground-interrupted') }) - process.emit('SIGINT') - } - await new Promise(resolve => setTimeout(resolve, ${foregroundMs})) - console.log('interrupt-count-before-wait:' + foregroundInterrupts) - const started = performance.now() - await waitForOnboardingChecks(command, ${JSON.stringify(commandPath)}) - console.log('foreground-finished:' + Math.round(performance.now() - started)) - ` - const runner = join(dir, `run-${++runnerCount}.mjs`) - writeFileSync(runner, source) - const child = spawn('node', [runner], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, CI: ci ? 'true' : 'false', CAPGO_DISABLE_TELEMETRY: disabled ? 'true' : '', CAPGO_DISABLE_POSTHOG: '' } }) - let output = '' - let errors = '' - let waiting - const waitingMessage = new Promise(resolve => { waiting = resolve }) - for (const [stream, stderr] of [[child.stdout, false], [child.stderr, true]]) { - stream.on('data', chunk => { - if (stderr) errors += chunk - else output += chunk - if ((output + errors).includes('Waiting for background checks')) waiting() - }) - } - const timeout = setTimeout(() => child.kill('SIGKILL'), 10_000) - const completion = once(child, 'exit').then(([code, signal]) => { - clearTimeout(timeout) - return { code, signal, output, errors, text: output + errors } - }) - return { child, completion, waitingMessage } -} - -function waitedMs(result) { - assert.equal(result.code, 0, result.text) - assert.equal(result.signal, null) - return Number(result.output.match(/foreground-finished:(\d+)/)?.[1]) -} - -function waitEvents(result) { - return result.output.split('\n').filter(line => line.startsWith('telemetry:')).map(line => JSON.parse(line.slice('telemetry:'.length))) -} - -test.concurrent('both workers share one five-second shutdown budget', async () => { - const { completion } = run({ workers: ['busy.mjs', 'busy.mjs'] }) - const result = await completion - const duration = waitedMs(result) - assert.ok(duration >= 4_900 && duration < 6_000, `shared wait was ${duration}ms`) - assert.equal(result.text.match(/Waiting for background checks/g)?.length, 1) - const events = waitEvents(result) - assert.equal(events.length, 1) - const event = events[0] - assert.equal(event.event, 'background_checks_wait_started') - assert.equal(event.channel, 'cli-usage') - assert.equal(event.tracking_version, 2) - assert.ok(Number.isFinite(Date.parse(event.timestamp))) - assert.equal(event.nonPersonTags.command_path, 'app list') - assert.equal(event.nonPersonTags.pending_checks, 2) - assert.equal(event.nonPersonTags.grace_period_ms, 5_000) - const attempts = JSON.parse(result.output.match(/pending-attempts:(.+)/)[1]) - assert.deepEqual(event.nonPersonTags.scan_attempt_ids, attempts) - assert.equal(new Set(attempts).size, 2) - for (const id of attempts) assert.match(id, /^[0-9a-f-]{36}$/) - assert.equal(JSON.stringify(event).includes('fake-api-key'), false) - assert.equal(JSON.stringify(event).includes(dir), false) -}, 12_000) - -test.concurrent('exits early as soon as the last worker finishes', async () => { - const result = await run({ workers: ['quick.mjs', 'slow.mjs'] }).completion - const duration = waitedMs(result) - assert.ok(duration >= 800 && duration < 2_000, `early completion was ${duration}ms`) - assert.ok(result.text.includes('Waiting for background checks')) -}) - -test.concurrent('completed or absent checks produce no waiting message or delay', async () => { - for (const settings of [{ workers: [] }, { workers: ['quick.mjs'], foregroundMs: 1_000 }]) { - const result = await run(settings).completion - assert.ok(waitedMs(result) < 100) - assert.ok(!result.text.includes('Waiting for background checks')) - assert.deepEqual(waitEvents(result), []) - } -}) - -test.concurrent('SIGINT during the grace period exits immediately even after a previous interrupt', async () => { - const { child, completion, waitingMessage } = run({ previousInterrupt: true }) - await Promise.race([waitingMessage, completion.then(() => { throw new Error('exited before entering the grace period') })]) - child.kill('SIGINT') - const result = await completion - assert.equal(result.code, 130) - assert.equal(result.signal, null) - const previousInterrupts = Number(result.output.match(/interrupt-count-before-wait:(\d+)/)[1]) - assert.ok(previousInterrupts >= 1) - assert.equal(result.output.match(/foreground-interrupted/g)?.length, previousInterrupts, result.text) - assert.ok(!result.text.includes('foreground-finished')) -}) - -test.concurrent('machine output, init, MCP, CI and non-interactive commands do not wait or print', async () => { - const cases = [ - { options: { json: true } }, { options: { outputText: true } }, { options: { quiet: true } }, - { commandPath: 'channel currentBundle', options: { quiet: true } }, - { commandPath: 'bundle zip', options: { json: true } }, - { commandPath: 'init' }, { commandPath: 'build init' }, { commandPath: 'build onboarding' }, - { commandPath: 'mcp' }, { commandPath: 'account id' }, { commandPath: 'bundle releaseType' }, - { commandPath: 'build credentials export' }, { commandPath: 'generate-docs' }, - { commandPath: 'unknown-command' }, { ci: true }, { stdinTty: false }, { stdoutTty: false }, - ] - const results = await Promise.all(cases.map(settings => run(settings).completion)) - for (const result of results) { - assert.ok(waitedMs(result) < 100) - assert.ok(!result.text.includes('Waiting for background checks')) - assert.deepEqual(waitEvents(result), []) - } -}, 12_000) - -test.concurrent('telemetry opt-out and delivery errors preserve the wait and early exit', async () => { - for (const settings of [{ disabled: true }, { telemetry: 'reject' }]) { - const result = await run({ workers: ['quick.mjs'], ...settings }).completion - assert.ok(waitedMs(result) < 2_000) - assert.ok(result.text.includes('Waiting for background checks')) - assert.equal(waitEvents(result).length, settings.disabled ? 0 : 1) - } -}) - -test.concurrent('hanging telemetry is aborted within the same five-second budget', async () => { - const result = await run({ workers: ['quick.mjs'], telemetry: 'hang' }).completion - const duration = waitedMs(result) - assert.ok(duration >= 4_900 && duration < 6_000, `telemetry wait was ${duration}ms`) - assert.equal(waitEvents(result).length, 1) - assert.ok(result.output.includes('telemetry-aborted')) -}, 12_000) diff --git a/cli/test/test-builder-app-id-config.mjs b/cli/test/test-builder-app-id-config.mjs deleted file mode 100644 index e5dfd1f0b8..0000000000 --- a/cli/test/test-builder-app-id-config.mjs +++ /dev/null @@ -1,206 +0,0 @@ -import assert from 'node:assert/strict' -import { createRequire } from 'node:module' -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' -import ts from 'typescript' -import { getBuilderAppId } from '../src/build/app-id.ts' -import { persistBuilderAppSelection } from '../src/build/onboarding/app-selection.ts' -import { loadConfig, writeConfigUpdater } from '../src/config/index.ts' -import { getAppId } from '../src/utils.ts' -import { flushAnalytics, isBuilderInvocation, resolveTrackingContext, trackCommandInvoked } from '../src/analytics/track.ts' -import { buildDeps } from '../src/build/onboarding/mcp/onboarding-tools.ts' -import { buildScanContext } from '../src/build/prescan/context.ts' -import { generateWorkflow } from '../src/build/onboarding/workflow-generator.ts' - -const require = createRequire(import.meta.url) -const { loadConfig: loadCapacitorConfig } = require('@capacitor/cli/dist/config') -const { syncCommand } = require('@capacitor/cli/dist/tasks/sync') -const cliRoot = dirname(dirname(fileURLToPath(import.meta.url))) -const root = mkdtempSync(join(cliRoot, '.builder-app-id-config-')) -const nativeId = 'com.example.native' -const updaterId = 'com.example.ota' -const builderId = 'com.example.builder' - -function configFor(builderValue = builderId) { - return { - appId: nativeId, - appName: 'Builder config proof', - webDir: 'www', - plugins: { - CapacitorUpdater: { appId: updaterId }, - CapgoBuilder: { capgoBuilderAppId: builderValue }, - }, - } -} - -function errorsFor(path) { - const program = ts.createProgram([path], { - noEmit: true, - strict: true, - skipLibCheck: true, - module: ts.ModuleKind.NodeNext, - moduleResolution: ts.ModuleResolutionKind.NodeNext, - }) - return ts.getPreEmitDiagnostics(program).filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) -} - -try { - assert.equal(getBuilderAppId(undefined, configFor()), builderId) - assert.equal(getBuilderAppId('com.example.explicit', configFor()), 'com.example.explicit') - assert.equal(getBuilderAppId(undefined, { ...configFor(), plugins: { CapacitorUpdater: { appId: updaterId } } }), updaterId) - assert.equal(getBuilderAppId(undefined, { ...configFor(), plugins: { CapacitorUpdater: { appId: updaterId } } }, 'native'), nativeId) - assert.equal(getBuilderAppId(undefined, configFor(), 'native'), builderId) - assert.equal(getBuilderAppId('', { ...configFor(), plugins: {} }, 'native', 'defined'), '', 'prescan keeps its nullish explicit-ID fallback') - assert.equal(getAppId(undefined, configFor()), updaterId, 'OTA resolution must ignore the Builder field') - const workflow = generateWorkflow({ - appId: getBuilderAppId(undefined, configFor()), - defaultPlatform: 'ios', - packageManager: 'npm', - buildScript: { type: 'skip' }, - secretKeys: [], - }) - assert.match(workflow.content, /@capgo\/cli@latest build request com\.example\.builder/) - assert.doesNotMatch(workflow.content, /build request com\.example\.native/) - for (const value of ['', ' ', 42, null, undefined]) { - const invalid = configFor() - invalid.plugins.CapgoBuilder.capgoBuilderAppId = value - assert.throws(() => getBuilderAppId(undefined, invalid), /plugins\.CapgoBuilder\.capgoBuilderAppId must be a non-empty string/) - assert.equal(getBuilderAppId('com.example.explicit', invalid), 'com.example.explicit', 'explicit app IDs bypass invalid Builder config') - } - assert.equal(isBuilderInvocation('build request'), true) - assert.equal(isBuilderInvocation('build credentials save'), true) - assert.equal(isBuilderInvocation('mcp:start_capgo_build'), true) - assert.equal(isBuilderInvocation('mcp:capgo_builder_onboarding_next_step'), true) - assert.equal(isBuilderInvocation('app list'), false) - assert.equal(isBuilderInvocation('bundle upload'), false) - assert.equal(isBuilderInvocation('mcp:capgo_init_next_step'), false) - - // An ordinary CapacitorConfig object literal rejects the proposed root field. - // Its documented plugins map accepts the Builder namespace without augmentation. - const rootAttempt = join(root, 'root-attempt.ts') - writeFileSync(rootAttempt, `import type { CapacitorConfig } from '@capacitor/cli' -const config: CapacitorConfig = { appId: '${nativeId}', appName: 'Proof', webDir: 'www', capgoBuilderAppId: '${builderId}' } -export default config -`) - assert.ok(errorsFor(rootAttempt).some(error => error.code === 2353 && ts.flattenDiagnosticMessageText(error.messageText, ' ').includes('capgoBuilderAppId'))) - - for (const extension of ['ts', 'json', 'js']) { - const project = join(root, extension) - mkdirSync(join(project, 'www'), { recursive: true }) - writeFileSync(join(project, 'package.json'), JSON.stringify({ name: `builder-proof-${extension}`, version: '1.0.0', private: true })) - writeFileSync(join(project, 'www', 'index.html'), 'proof') - const path = join(project, `capacitor.config.${extension}`) - const config = configFor() - if (extension === 'ts') { - writeFileSync(path, `import type { CapacitorConfig } from '@capacitor/cli' -const config: CapacitorConfig = ${JSON.stringify(config, null, 2)} -export default config -`) - assert.deepEqual(errorsFor(path), [], 'the plugins field must typecheck as an ordinary CapacitorConfig') - } - else if (extension === 'js') { - writeFileSync(path, `/** @type {import('@capacitor/cli').CapacitorConfig} */ -const config = ${JSON.stringify(config, null, 2)} -module.exports = config -`) - } - else { - writeFileSync(path, JSON.stringify(config, null, 2)) - } - - const previousCwd = process.cwd() - try { - process.chdir(project) - assert.equal(process.cwd(), project) - const capacitor = await loadCapacitorConfig() - assert.equal(capacitor.app.appId, nativeId) - assert.equal(capacitor.app.extConfig.plugins.CapgoBuilder.capgoBuilderAppId, builderId) - await syncCommand(capacitor, 'web') - assert.equal((await loadCapacitorConfig()).app.extConfig.plugins.CapgoBuilder.capgoBuilderAppId, builderId) - - const capgo = await loadConfig() - assert.equal(getBuilderAppId(undefined, capgo.config), builderId) - if (extension === 'ts') { - const aborted = new AbortController() - aborted.abort() - assert.equal((await resolveTrackingContext('builder-config-proof', aborted.signal, true)).appId, builderId) - assert.equal((await resolveTrackingContext('builder-config-proof', aborted.signal, false)).appId, updaterId, 'non-Builder telemetry stays on OTA resolution') - const originalFetch = globalThis.fetch - const events = [] - globalThis.fetch = async (url, init) => { - if (String(url).endsWith('/private/events')) - events.push(JSON.parse(init.body)) - return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }) - } - try { - const commandContext = { flags: [], positional_arg_count: 0 } - trackCommandInvoked('build request', commandContext, 'builder-config-proof') - await flushAnalytics() - assert.equal(events.at(-1)?.tags.app_id, builderId) - trackCommandInvoked('app list', commandContext, 'builder-config-proof') - await flushAnalytics() - assert.equal(events.at(-1)?.tags.app_id, updaterId, 'app list telemetry must ignore the Builder field') - } - finally { - globalThis.fetch = originalFetch - } - } - const scan = await buildScanContext({ platform: 'android', projectDir: project, credentials: {} }) - assert.equal(scan.appId, builderId, 'prescan Capgo checks use the Builder key') - assert.equal(scan.nativeAppId, nativeId, 'prescan local iOS checks use the native ID') - assert.equal(scan.config.appId, nativeId, 'prescan native checks keep the Capacitor ID') - const explicitScan = await buildScanContext({ appId: 'com.example.explicit', platform: 'android', projectDir: project, credentials: {} }) - assert.equal(explicitScan.appId, 'com.example.explicit') - assert.equal(explicitScan.nativeAppId, nativeId, 'an explicit Capgo ID does not replace the native ID') - if (extension === 'ts') { - const deps = buildDeps(() => ({})) - assert.equal(await deps.getAppId(), builderId, 'MCP Capgo operations use the Builder key') - assert.equal(await deps.getNativeAppId(), nativeId, 'MCP native operations keep the Capacitor ID') - for (let attempt = 0; attempt < 20 && deps.iosEffectDeps.detectBundleIds().capacitor.value !== nativeId; attempt++) - await new Promise(resolve => setTimeout(resolve, 5)) - assert.equal(deps.iosEffectDeps.detectBundleIds().capacitor.value, nativeId, 'iOS bundle detection stays native') - } - await writeConfigUpdater({ - path, - config: { ...capgo.config, plugins: { ...capgo.config.plugins, CapacitorUpdater: { appId: 'com.example.new-ota' } } }, - }) - const updated = await loadConfig() - assert.equal(updated.config.appId, nativeId) - assert.equal(updated.config.plugins.CapgoBuilder.capgoBuilderAppId, builderId) - assert.equal(getAppId(undefined, updated.config), 'com.example.new-ota') - updated.config.plugins.CapacitorUpdater.appId = 'com.example.raw-ota' - await writeConfigUpdater(updated, true) - const rawUpdated = await loadConfig() - assert.equal(rawUpdated.config.plugins.CapgoBuilder.capgoBuilderAppId, builderId, 'raw updater writes must retain the Builder field') - assert.equal(getAppId(undefined, rawUpdated.config), 'com.example.raw-ota') - assert.equal((await loadCapacitorConfig()).app.extConfig.plugins.CapgoBuilder.capgoBuilderAppId, builderId) - if (extension === 'ts') - assert.deepEqual(errorsFor(path), [], 'Capgo updater writes must keep the TypeScript config valid') - if (extension === 'js') - assert.match(readFileSync(path, 'utf8'), /capgoBuilderAppId/) - assert.equal(await persistBuilderAppSelection(builderId), false, 'matching Builder ID needs no config write') - assert.equal(await persistBuilderAppSelection('com.example.selected'), true) - const selectedConfig = await loadConfig() - assert.equal(selectedConfig.config.appId, nativeId) - assert.equal(selectedConfig.config.plugins.CapacitorUpdater.appId, 'com.example.raw-ota') - assert.equal(selectedConfig.config.plugins.CapgoBuilder.capgoBuilderAppId, 'com.example.selected') - assert.equal((await loadCapacitorConfig()).app.extConfig.plugins.CapgoBuilder.capgoBuilderAppId, 'com.example.selected') - } - finally { - process.chdir(previousCwd) - } - } - - const invalidProject = join(root, 'invalid-explicit') - mkdirSync(join(invalidProject, 'www'), { recursive: true }) - writeFileSync(join(invalidProject, 'capacitor.config.json'), JSON.stringify(configFor(''))) - const overriddenScan = await buildScanContext({ appId: 'com.example.explicit', platform: 'ios', projectDir: invalidProject, credentials: {} }) - assert.equal(overriddenScan.appId, 'com.example.explicit', 'explicit IDs bypass invalid Builder config during prescan') - assert.equal(overriddenScan.nativeAppId, nativeId, 'prescan still uses the native ID for local checks') -} -finally { - if (existsSync(root)) - rmSync(root, { recursive: true, force: true }) -} diff --git a/cli/test/test-builder-app-selection.mjs b/cli/test/test-builder-app-selection.mjs deleted file mode 100644 index 327eae939e..0000000000 --- a/cli/test/test-builder-app-selection.mjs +++ /dev/null @@ -1,331 +0,0 @@ -#!/usr/bin/env bun -import assert from 'node:assert/strict' -import { EventEmitter } from 'node:events' -import { render, Text } from 'ink' -import React from 'react' -import stringWidth from 'string-width' -import { AppSelectionError, createBuilderAppSelectionServices, getAppSelectionSuggestion, listVisibleBuilderApps, rankVisibleApps, verifyBuilderApp } from '../src/build/onboarding/app-selection.ts' -import BuilderAppSelectionGate from '../src/build/onboarding/ui/app-selection-gate.tsx' - -assert.deepEqual(getAppSelectionSuggestion({ - appId: 'com.example.native', - plugins: { CapgoBuilder: { capgoBuilderAppId: 'com.example.cloud' } }, -}), { appId: 'com.example.cloud', source: 'builder' }) - -assert.deepEqual(getAppSelectionSuggestion({ - appId: 'com.example.native', - plugins: { CapacitorUpdater: { appId: 'com.example.ota' } }, -}), { appId: 'com.example.native', source: 'capacitor' }) - -const apps = [ - { app_id: 'com.example.forecast', name: 'Forecast' }, - { app_id: 'com.other.weather', name: 'Other' }, - { app_id: 'com.example.weather.beta', name: 'Weather Beta' }, - { app_id: 'com.example.weather.dev', name: 'Weather Preview' }, -] -assert.deepEqual(rankVisibleApps(apps, 'com.example.weather').slice(0, 3).map(app => app.app_id), [ - 'com.example.weather.dev', - 'com.example.weather.beta', - 'com.example.forecast', -]) -assert.equal(createBuilderAppSelectionServices({ supaHost: 'https://example.invalid' }).dashboardUrl, '', 'custom API hosts cannot use the hosted Dashboard') - -console.log('Builder app suggestion and similarity passed') - -{ - const paths = [] - const page = Array.from({ length: 50 }, (_, index) => ({ app_id: `com.example.app${index}`, name: `App ${index}` })) - const result = await listVisibleBuilderApps('test-key', { supaHost: 'https://example.invalid', supaAnon: 'anon-test' }, async (path, options) => { - paths.push({ path, options }) - return { data: path.endsWith('page=0') ? page : [{ app_id: 'com.example.last', name: 'Last' }], error: null } - }) - assert.equal(result.length, 51) - assert.deepEqual(paths.map(item => item.path), ['app?page=0', 'app?page=1']) - assert.ok(paths.every(item => item.options.apikey === 'test-key' && item.options.supaHost === 'https://example.invalid' && item.options.supaAnon === 'anon-test')) - await assert.rejects(listVisibleBuilderApps('test-key', {}, async path => path.endsWith('page=0') - ? { data: page, error: null } - : { data: null, error: new Error('page failed') }), error => error instanceof AppSelectionError && error.code === 'list') -} - -{ - const calls = [] - const request = async (path) => { - calls.push(path) - return { data: { app_id: 'com.example.weather', name: 'Weather' }, error: null } - } - const createClient = async () => ({ rpc: async (name, args) => { - calls.push({ name, args }) - return { data: true, error: null } - } }) - await verifyBuilderApp('test-key', 'com.example.weather', {}, { request, createClient }) - assert.equal(calls[0], 'app/com.example.weather') - assert.equal(calls[1].name, 'cli_check_permission') - assert.equal(calls[1].args.permission_key, 'app.build_native') - assert.equal(calls[1].args.app_id, 'com.example.weather') - await assert.rejects(verifyBuilderApp('test-key', 'com.example.weather', {}, { - request, - createClient: async () => ({ rpc: async () => ({ data: false, error: null }) }), - }), error => error instanceof AppSelectionError && error.code === 'build') - await assert.rejects(verifyBuilderApp('test-key', 'com.example.weather', {}, { - request: async () => ({ data: null, error: Object.assign(new Error('denied'), { context: { status: 401 } }) }), - createClient, - }), error => error instanceof AppSelectionError && error.code === 'read') -} - -console.log('Builder app pagination and permissions passed') - -function makeStream(cols = 100, rows = 50) { - const stream = new EventEmitter() - stream.columns = cols - stream.rows = rows - stream.isTTY = true - stream.lastFrame = '' - stream.frames = [] - stream.write = (frame) => { - stream.lastFrame = String(frame) - stream.frames.push(stream.lastFrame) - return true - } - return stream -} - -function makeStdin() { - const stream = new EventEmitter() - const chunks = [] - stream.isTTY = true - stream.setEncoding = () => {} - stream.setRawMode = () => {} - stream.resume = () => {} - stream.pause = () => {} - stream.ref = () => {} - stream.unref = () => {} - stream.read = () => chunks.shift() ?? null - stream.send = (chunk) => { - chunks.push(chunk) - stream.emit('readable') - } - return stream -} - -async function waitFor(predicate, label) { - const deadline = Date.now() + 5000 - while (!predicate() && Date.now() < deadline) - await new Promise(resolve => setTimeout(resolve, 10)) - assert.ok(predicate(), `Timed out waiting for ${label}`) -} - -function renderGate({ visible = [], cols = 100, rows = 50, footer, verify = async () => {}, persist = async () => false, openDashboard = async () => true } = {}) { - const stdout = makeStream(cols, rows) - const stdin = makeStdin() - const selected = [] - const events = [] - const verified = [] - const saved = [] - let switched = 0 - const services = { - list: async () => visible, - verify: async (_key, id) => { verified.push(id); await verify(id) }, - persist: async (id) => { saved.push(id); return persist(id) }, - openDashboard, - dashboardUrl: 'https://console.capgo.app/app/new', - } - const instance = render(React.createElement(BuilderAppSelectionGate, { - apikey: 'test-key', - suggestedId: 'com.example.weather', - suggestedSource: 'capacitor', - services, - cols, - rows, - footer, - onSelected: id => selected.push(id), - onSwitchKey: () => { switched++ }, - onCancel: () => {}, - onEvent: event => events.push(event), - }), { stdout, stderr: makeStream(cols, rows), stdin, debug: true, exitOnCtrlC: false, patchConsole: false }) - return { stdout, stdin, instance, selected, events, verified, saved, switched: () => switched } -} - -async function stop(ui) { - ui.instance.unmount() - await ui.instance.waitUntilExit() -} - -{ - const ui = renderGate({ visible: [{ app_id: 'com.example.weather', name: 'Weather' }] }) - await waitFor(() => ui.selected.length === 1, 'exact match resolution') - assert.deepEqual(ui.verified, ['com.example.weather']) - assert.deepEqual(ui.saved, ['com.example.weather']) - assert.ok(ui.stdout.frames.every(frame => !frame.includes('Which Capgo app should Builder use?'))) - assert.equal(ui.events.find(event => event.phase === 'resolved')?.result, 'exact_match') - assert.equal(ui.events.some(event => event.phase === 'shown'), false, 'exact match does not show a choice screen') - await stop(ui) -} - -{ - const ui = renderGate({ visible: [{ app_id: 'com.example.weather.dev', name: 'Weather Preview' }] }) - await waitFor(() => ui.stdout.lastFrame.includes('Which Capgo app should Builder use?'), 'single app screen') - assert.match(ui.stdout.lastFrame, /Your Capacitor app ID: com\.example\.weather/) - assert.match(ui.stdout.lastFrame.replace(/\s+/g, ' '), /No app with this ID is available to your API key\. It may exist in Capgo, but you or your API key might lack access to it\./) - assert.match(ui.stdout.lastFrame, /App visible to your API key:/) - assert.match(ui.stdout.lastFrame, /─{20,}/, 'a divider separates the app choices from the explanation') - assert.match(ui.stdout.lastFrame, /Weather Preview.*com\.example\.weather\.dev/) - assert.match(ui.stdout.lastFrame, /\n\s*\n\s*↑ ↓ choose · Enter select · Esc back/u, 'normal terminals leave a blank row above navigation hints') - assert.doesNotMatch(ui.stdout.lastFrame, /Select a different app/) - assert.deepEqual(ui.selected, []) - await new Promise(resolve => setTimeout(resolve, 100)) - ui.stdin.send('\r') - await waitFor(() => ui.selected.length === 1, 'explicit single app selection') - assert.deepEqual(ui.selected, ['com.example.weather.dev']) - await stop(ui) -} - -{ - const rows = 30 - const ui = renderGate({ - visible: [{ app_id: 'com.example.weather.dev', name: 'Weather Preview' }], - rows, - footer: React.createElement(Text, null, 'Analytics notice'), - }) - await waitFor(() => ui.stdout.lastFrame.includes('Analytics notice'), 'app selection footer') - const lines = ui.stdout.lastFrame.split('\n') - assert.ok(lines.findIndex(line => line.includes('Analytics notice')) >= rows - 3, 'analytics notice stays near the terminal bottom') - await stop(ui) -} - -{ - const ui = renderGate({ visible: apps }) - await waitFor(() => ui.stdout.lastFrame.includes('Select a different app'), 'multiple app screen') - assert.match(ui.stdout.lastFrame, /Weather Preview.*com\.example\.weather\.dev/) - assert.match(ui.stdout.lastFrame, /Weather Beta.*com\.example\.weather\.beta/) - assert.doesNotMatch(ui.stdout.lastFrame, /com\.other\.weather/) - await new Promise(resolve => setTimeout(resolve, 100)) - for (let index = 0; index < 3; index++) { - ui.stdin.send('j') - await new Promise(resolve => setTimeout(resolve, 30)) - } - ui.stdin.send('\r') - await waitFor(() => ui.stdout.lastFrame.includes('Search visible apps:'), 'full visible-app list') - ui.stdin.send('other') - await waitFor(() => ui.stdout.lastFrame.includes('com.other.weather'), 'full-list search') - ui.stdin.send('\r') - await waitFor(() => ui.selected.length === 1, 'full-list app selection') - assert.deepEqual(ui.selected, ['com.other.weather']) - await stop(ui) -} - -{ - const ui = renderGate() - await waitFor(() => ui.stdout.lastFrame.includes('No apps are visible to this API key'), 'zero apps screen') - assert.doesNotMatch(ui.stdout.lastFrame, /Select a different app/) - assert.match(ui.stdout.lastFrame, /Log in with another API key/) - await stop(ui) -} - -{ - const visible = [] - let opened = 0 - const ui = renderGate({ visible, openDashboard: async () => { opened++; return false } }) - await waitFor(() => ui.stdout.lastFrame.includes('No apps are visible to this API key'), 'empty list before Dashboard') - await new Promise(resolve => setTimeout(resolve, 100)) - ui.stdin.send('j') - await new Promise(resolve => setTimeout(resolve, 30)) - ui.stdin.send('\r') - await waitFor(() => ui.stdout.lastFrame.includes('Open this URL in your browser'), 'Dashboard URL fallback') - assert.equal(opened, 1) - visible.push({ app_id: 'com.example.weather', name: 'Weather' }) - ui.stdin.send('\r') - await waitFor(() => ui.selected.length === 1, 'Dashboard-created app recheck') - assert.deepEqual(ui.selected, ['com.example.weather']) - await stop(ui) -} - -{ - let resolveFirst - let firstRequest - let opened = 0 - const ui = renderGate({ - openDashboard: () => { - if (++opened === 1) { - firstRequest = new Promise((resolve) => { resolveFirst = resolve }) - return firstRequest - } - return Promise.resolve(true) - }, - }) - await waitFor(() => ui.stdout.lastFrame.includes('No apps are visible to this API key'), 'Dashboard race initial picker') - ui.stdin.send('j') - await waitFor(() => ui.stdout.lastFrame.includes('❯ Open Dashboard'), 'first Dashboard choice') - ui.stdin.send('\r') - await waitFor(() => opened === 1 && ui.stdout.lastFrame.includes('Create the app in your browser'), 'first Dashboard request') - ui.stdin.send('\x1B') - await waitFor(() => ui.stdout.lastFrame.includes('No apps are visible to this API key'), 'return from Dashboard') - ui.stdin.send('j') - await waitFor(() => ui.stdout.lastFrame.includes('❯ Open Dashboard'), 'second Dashboard choice') - ui.stdin.send('\r') - await waitFor(() => opened === 2 && ui.stdout.lastFrame.includes('Create the app in your browser'), 'second Dashboard request') - resolveFirst(false) - await firstRequest - await new Promise(resolve => setImmediate(resolve)) - assert.doesNotMatch(ui.stdout.lastFrame, /Open this URL in your browser/, 'an older Dashboard result cannot replace the current request') - await stop(ui) -} - -{ - const ui = renderGate({ - visible: [{ app_id: 'com.example.weather.dev', name: 'Weather Preview' }], - verify: async () => { throw new AppSelectionError('build', 'This API key needs app.build_native permission.') }, - }) - await waitFor(() => ui.stdout.lastFrame.includes('Which Capgo app should Builder use?'), 'permission test picker') - await new Promise(resolve => setTimeout(resolve, 100)) - ui.stdin.send('\r') - await waitFor(() => ui.stdout.lastFrame.includes('This API key needs app.build_native'), 'permission recovery') - assert.deepEqual(ui.selected, []) - assert.equal(ui.events.find(event => event.phase === 'error')?.result, 'build') - await stop(ui) -} - -{ - let attempts = 0 - const ui = renderGate({ - visible: [{ app_id: 'com.example.weather.dev', name: 'Weather Preview' }], - verify: async () => { - if (attempts++ === 0) - throw new AppSelectionError('build', 'Temporary permission check failure.') - }, - }) - await waitFor(() => ui.stdout.lastFrame.includes('Which Capgo app should Builder use?'), 'explicit retry picker') - ui.stdin.send('\r') - await waitFor(() => ui.stdout.lastFrame.includes('Temporary permission check failure.'), 'explicit retry error') - ui.stdin.send('\r') - await waitFor(() => ui.selected.length === 1, 'explicit retry success') - assert.deepEqual(ui.events.find(event => event.phase === 'resolved'), { phase: 'resolved', result: 'selected', source: 'closest_list', visibleCount: 1 }) - await stop(ui) -} - -{ - let attempts = 0 - const ui = renderGate({ - visible: [{ app_id: 'com.example.weather', name: 'Weather' }], - verify: async () => { - if (attempts++ === 0) - throw new AppSelectionError('build', 'Temporary permission check failure.') - }, - }) - await waitFor(() => ui.stdout.lastFrame.includes('Temporary permission check failure.'), 'exact match retry error') - ui.stdin.send('\r') - await waitFor(() => ui.selected.length === 1, 'exact match retry success') - assert.deepEqual(ui.events.find(event => event.phase === 'resolved'), { phase: 'resolved', result: 'exact_match', source: undefined, visibleCount: 1 }) - await stop(ui) -} - -{ - const ui = renderGate({ visible: [{ app_id: 'com.example.weather.dev', name: 'Weather Preview' }], cols: 44, rows: 11 }) - await waitFor(() => ui.stdout.lastFrame.includes('App visible to your API key'), 'compact app screen') - assert.doesNotMatch(ui.stdout.lastFrame, /─{20,}/, 'compact terminals omit the divider') - assert.doesNotMatch(ui.stdout.lastFrame, /↑ ↓ choose · Enter select · Esc back/u, 'compact terminals omit the full navigation hint') - assert.ok(ui.stdout.lastFrame.split('\n').length <= 11, 'compact app screen fits the terminal height') - assert.ok(ui.stdout.lastFrame.split('\n').every(line => stringWidth(line) <= 44), 'compact app screen fits terminal width') - await stop(ui) -} - -console.log('Builder app selection gate passed') diff --git a/cli/test/test-builder-login-gate.mjs b/cli/test/test-builder-login-gate.mjs deleted file mode 100644 index bda4f975fb..0000000000 --- a/cli/test/test-builder-login-gate.mjs +++ /dev/null @@ -1,304 +0,0 @@ -#!/usr/bin/env bun -import assert from 'node:assert/strict' -import { EventEmitter } from 'node:events' -import { render } from 'ink' -import React from 'react' -import stringWidth from 'string-width' -import { resolveBuilderCandidateKey } from '../src/build/onboarding/login.ts' -import BuilderLoginGate from '../src/build/onboarding/ui/login-gate.tsx' -import OnboardingShell from '../src/build/onboarding/ui/shell.tsx' - -const previousToken = process.env.CAPGO_TOKEN -try { - process.env.CAPGO_TOKEN = 'env-test-key' - assert.equal(resolveBuilderCandidateKey(' explicit-test-key '), 'explicit-test-key') - assert.equal(resolveBuilderCandidateKey(' '), 'env-test-key') - assert.equal(resolveBuilderCandidateKey(), 'env-test-key') -} -finally { - if (previousToken === undefined) - delete process.env.CAPGO_TOKEN - else - process.env.CAPGO_TOKEN = previousToken -} - -console.log('Builder candidate key precedence passed') - -function makeStream(cols = 100, rows = 50) { - const stream = new EventEmitter() - stream.columns = cols - stream.rows = rows - stream.isTTY = true - stream.lastFrame = '' - stream.frames = [] - stream.write = (frame) => { - stream.lastFrame = String(frame) - stream.frames.push(stream.lastFrame) - return true - } - return stream -} - -function makeStdin() { - const stream = new EventEmitter() - const chunks = [] - stream.isTTY = true - stream.setEncoding = () => {} - stream.setRawMode = () => {} - stream.resume = () => {} - stream.pause = () => {} - stream.ref = () => {} - stream.unref = () => {} - stream.read = () => chunks.shift() ?? null - stream.send = (chunk) => { - chunks.push(chunk) - stream.emit('readable') - } - return stream -} - -async function waitFor(predicate, description = 'login UI') { - const deadline = Date.now() + 5000 - while (!predicate() && Date.now() < deadline) - await new Promise(resolve => setTimeout(resolve, 10)) - assert.ok(predicate(), `Timed out waiting for ${description}`) -} - -async function sendUntil(ui, input, predicate, description) { - const deadline = Date.now() + 5000 - while (!predicate() && Date.now() < deadline) { - ui.stdin.send(input) - await new Promise(resolve => setTimeout(resolve, 50)) - } - assert.ok(predicate(), `Timed out waiting for ${description}`) -} - -async function stop(ui) { - ui.instance.unmount() - await ui.instance.waitUntilExit() -} - -function renderGate({ cols = 100, rows = 50, candidateKey, browserAvailable = true, savePasted = async () => {}, validateExisting = async () => {}, getAccountEmail = async () => 'account@example.com', beginBrowser = async () => ({ session: 'test-session', url: 'https://console.capgo.app/login-cli?session=test-session', browserOpened: true }), completeBrowser = async () => {} } = {}) { - const stdout = makeStream(cols, rows) - const stdin = makeStdin() - const authenticated = [] - let cancelled = false - const services = { browserAvailable, savePasted, validateExisting, getAccountEmail, beginBrowser, completeBrowser } - const instance = render(React.createElement(BuilderLoginGate, { - candidateKey, - services, - cols, - rows, - onAuthenticated: (key, metadata) => authenticated.push({ key, metadata }), - onCancel: () => { cancelled = true }, - }), { stdout, stderr: makeStream(cols, rows), stdin, debug: true, exitOnCtrlC: false, patchConsole: false }) - return { stdout, stdin, authenticated, wasCancelled: () => cancelled, instance } -} - -{ - const ui = renderGate() - await waitFor(() => ui.stdout.lastFrame.includes('How would you like to log in?'), 'initial login choice') - assert.match(ui.stdout.lastFrame, /Create key in Dashboard/) - assert.match(ui.stdout.lastFrame, /Use an existing key/) - await stop(ui) -} - -{ - const submitted = [] - const ui = renderGate({ - cols: 44, - rows: 11, - browserAvailable: false, - savePasted: async key => submitted.push(key), - }) - await waitFor(() => ui.stdout.lastFrame.includes('Paste the API key'), 'compact manual entry') - assert.doesNotMatch(ui.stdout.lastFrame, /[╭╮╰╯]/u, 'small input must be unboxed') - // Ink can paint the new field before its useInput subscription is active. - await new Promise(resolve => setTimeout(resolve, 100)) - const key = '12345678-1234-1234-1234-123456789abc' - ui.stdin.send(key) - await waitFor(() => ui.stdout.lastFrame.includes('••••'), 'compact masked paste') - ui.stdin.send('\r') - await waitFor(() => ui.stdout.lastFrame.includes('Welcome account@example.com 👋'), 'compact welcome') - assert.ok(ui.stdout.lastFrame.split('\n').length <= 11, 'compact welcome must fit the terminal') - assert.ok(ui.stdout.lastFrame.split('\n').every(line => stringWidth(line) <= 44), 'compact welcome must fit terminal width') - await waitFor(() => ui.authenticated.length === 1, 'compact key verification') - assert.deepEqual(submitted, [key]) - assert.equal(ui.authenticated[0].metadata.method, 'paste') - assert.ok(ui.stdout.lastFrame.length < 2000) - await stop(ui) -} - -console.log('Builder Ink login screen passed') - -{ - const ui = renderGate({ candidateKey: 'existing-key' }) - await waitFor(() => ui.stdout.lastFrame.includes('Welcome account@example.com 👋'), 'existing-key welcome') - assert.equal(ui.authenticated.length, 0, 'onboarding must wait for welcome screen') - const welcomeShownAt = Date.now() - await waitFor(() => ui.authenticated.length === 1, 'existing-key verification') - assert.ok(Date.now() - welcomeShownAt >= 1350, 'welcome should remain visible for about 1.5 seconds') - assert.equal(ui.authenticated[0].key, 'existing-key') - assert.equal(ui.authenticated[0].metadata.method, undefined) - assert.ok(ui.stdout.frames.every(frame => !frame.includes('How would you like to log in?'))) - await stop(ui) -} - -{ - const ui = renderGate({ candidateKey: 'existing-key', getAccountEmail: async () => { throw new Error('email unavailable') } }) - await waitFor(() => ui.stdout.lastFrame.includes('Welcome to Capgo 👋'), 'generic welcome when email lookup fails') - await waitFor(() => ui.authenticated.length === 1, 'login continues without account email') - await stop(ui) -} - -{ - let openings = 0 - const completed = [] - const ui = renderGate({ - beginBrowser: async (onUrl) => { - openings++ - const session = { session: 'test-session', url: 'https://console.capgo.app/login-cli?session=test-session', browserOpened: false } - onUrl(session.url) - return session - }, - completeBrowser: async (_session, key) => { - completed.push(key) - if (completed.length === 1) - throw new Error('invalid test key') - }, - }) - await waitFor(() => ui.stdout.lastFrame.includes('How would you like to log in?'), 'browser login choice after existing-key check') - await sendUntil(ui, '\r', () => ui.stdout.lastFrame.includes('Open this URL in your browser:'), 'browser login entry') - assert.match(ui.stdout.lastFrame, /╭|╮/u, 'large input should be boxed') - await new Promise(resolve => setTimeout(resolve, 100)) - ui.stdin.send('invalid-key') - await waitFor(() => ui.stdout.lastFrame.includes('••••'), 'first browser masked paste') - ui.stdin.send('\r') - await waitFor(() => ui.stdout.lastFrame.includes('Paste another key'), 'invalid-key retry message') - await new Promise(resolve => setTimeout(resolve, 100)) - ui.stdin.send('valid-test-key') - await waitFor(() => ui.stdout.lastFrame.includes('••••'), 'second browser masked paste') - ui.stdin.send('\r') - await waitFor(() => ui.authenticated.length === 1, 'browser-key verification') - assert.deepEqual(completed, ['invalid-key', 'valid-test-key']) - assert.equal(openings, 1) - assert.equal(ui.authenticated[0].metadata.method, 'browser') - assert.equal(ui.authenticated[0].metadata.retryCount, 1) - assert.ok(ui.stdout.frames.every(frame => !frame.includes('invalid-key') && !frame.includes('valid-test-key'))) - await stop(ui) -} - -{ - const ui = renderGate() - await waitFor(() => ui.stdout.lastFrame.includes('How would you like to log in?'), 'login choice before Escape') - await sendUntil(ui, '\x1b', ui.wasCancelled, 'Escape cancellation') - await stop(ui) -} - -console.log('Builder browser retry and cancellation passed') - -{ - const ui = renderGate({ - cols: 44, - rows: 11, - beginBrowser: async (onUrl) => { - const session = { session: 'small-session', url: 'https://console.capgo.app/login-cli?session=small-session', browserOpened: false } - onUrl(session.url) - return session - }, - }) - await waitFor(() => ui.stdout.lastFrame.includes('How would you like to log in?'), 'small-terminal login choice') - assert.ok(ui.stdout.lastFrame.split('\n').length <= 11, 'small login choice must fit the terminal') - assert.ok(ui.stdout.lastFrame.split('\n').every(line => stringWidth(line) <= 44), 'small login choice must fit terminal width') - await sendUntil(ui, '\r', () => ui.stdout.lastFrame.includes('small-session'), 'small-terminal browser entry') - assert.match(ui.stdout.lastFrame, /Paste the API key/) - assert.doesNotMatch(ui.stdout.lastFrame, /[╭╮╰╯]/u) - assert.ok(ui.stdout.lastFrame.split('\n').length <= 11, 'small browser fallback must fit the terminal') - assert.ok(ui.stdout.lastFrame.split('\n').every(line => stringWidth(line) <= 44), 'small browser fallback must fit terminal width') - await stop(ui) -} - -console.log('Builder small-terminal browser fallback passed') - -function renderShellForLogin({ initialPlatform, list = async () => [{ app_id: 'com.example.builderlogin', name: 'Builder Login' }] } = {}) { - const stdout = makeStream(100, 50) - const stdin = makeStdin() - const resolved = [] - let finishValidation - const validation = new Promise(resolve => (finishValidation = resolve)) - const services = { - browserAvailable: true, - validateExisting: async () => validation, - getAccountEmail: async () => 'account@example.com', - savePasted: async () => {}, - beginBrowser: async () => { throw new Error('unused') }, - completeBrowser: async () => {}, - } - let instance - instance = render(React.createElement(OnboardingShell, { - appId: 'com.example.builderlogin', - iosBundleIdInitial: 'com.example.builderlogin', - iosDir: 'ios', - androidDir: 'android', - guidedHelperUsable: false, - apikey: 'existing-test-key', - loginServices: services, - suggestedSource: 'capacitor', - appSelectionServices: { - list, - verify: async () => {}, - persist: async () => false, - openDashboard: async () => true, - dashboardUrl: 'https://console.capgo.app/app/new', - }, - journeyId: 'bj_login-test', - initialPlatform, - onResolvePlatform: (platform) => { - resolved.push(platform) - instance?.unmount() - }, - }), { stdout, stderr: makeStream(100, 50), stdin, debug: true, exitOnCtrlC: false, patchConsole: false }) - return { stdout, stdin, instance, resolved, finishValidation } -} - -{ - const shell = renderShellForLogin() - await waitFor(() => shell.stdout.lastFrame.includes('Checking Capgo login'), 'shell login check') - const checkingLines = shell.stdout.lastFrame.split('\n') - const checkingLine = checkingLines.findIndex(line => line.includes('Checking Capgo login')) - assert.ok(checkingLine > 15, 'login progress should be vertically centered') - assert.match(checkingLines[checkingLine], /^\s{30,}Checking Capgo login/u, 'login progress text should be horizontally centered') - assert.ok(checkingLines[checkingLine - 2].trim(), 'spinner should be above the progress text') - assert.doesNotMatch(shell.stdout.lastFrame, /Which platform do you want to set up/) - shell.finishValidation() - await waitFor(() => shell.stdout.lastFrame.includes('Welcome account@example.com 👋'), 'shell welcome before platform choice') - assert.doesNotMatch(shell.stdout.lastFrame, /Which platform do you want to set up/) - await waitFor(() => shell.stdout.lastFrame.includes('Which platform do you want to set up'), 'platform picker after authentication') - await stop(shell) -} - -{ - const shell = renderShellForLogin({ initialPlatform: 'ios' }) - await waitFor(() => shell.stdout.lastFrame.includes('Checking Capgo login'), 'shell preselected-platform login check') - assert.deepEqual(shell.resolved, []) - shell.finishValidation() - await waitFor(() => shell.resolved.length === 1, 'preselected platform after authentication') - assert.deepEqual(shell.resolved, ['ios']) - await stop(shell) -} - -console.log('Builder shell authenticates before platform choice and auto-load') - -{ - let finishAppList - const appList = new Promise(resolve => (finishAppList = resolve)) - const shell = renderShellForLogin({ initialPlatform: 'ios', list: async () => appList }) - shell.finishValidation() - await waitFor(() => shell.stdout.lastFrame.includes('Checking Capgo apps'), 'app check after login') - assert.deepEqual(shell.resolved, [], 'preselected platform must wait for app resolution') - finishAppList([{ app_id: 'com.example.builderlogin', name: 'Builder Login' }]) - await waitFor(() => shell.resolved.length === 1, 'preselected platform after app verification') - await stop(shell) -} - -console.log('Builder shell verifies the app before --platform auto-load') diff --git a/cli/test/test-builder-project-discovery.mjs b/cli/test/test-builder-project-discovery.mjs index 0f47f0e65b..d8103080e3 100644 --- a/cli/test/test-builder-project-discovery.mjs +++ b/cli/test/test-builder-project-discovery.mjs @@ -268,21 +268,6 @@ try { assert.equal(result.candidates[0].appId, 'com.example.javascript') }) - await test('shows the configured Builder ID in workspace candidates', async () => { - const root = fixture('builder-app-id') - writeJson(join(root, 'package.json'), { private: true, workspaces: ['apps/*'] }) - const jsDir = addPackage(root, 'apps/javascript', '@example/javascript') - const jsonDir = addPackage(root, 'apps/json', '@example/json') - writeText(join(jsDir, 'capacitor.config.js'), "module.exports = { appId: 'com.example.native', plugins: { CapgoBuilder: { capgoBuilderAppId: 'com.example.builder' } } }\n") - writeJson(join(jsonDir, 'capacitor.config.json'), { - appId: 'com.example.native-json', - plugins: { CapgoBuilder: { capgoBuilderAppId: 'com.example.builder-json' } }, - }) - - const result = await discoverCapacitorProjects(root) - assert.deepEqual(result.candidates.map(candidate => candidate.appId), ['com.example.builder', 'com.example.builder-json']) - }) - await test('reads literal appId metadata without executing candidate configuration code', async () => { const root = fixture('non-executing-config-discovery') writeJson(join(root, 'package.json'), { private: true, workspaces: ['apps/*'] }) diff --git a/cli/test/test-bundle-pages.test.ts b/cli/test/test-bundle-pages.test.ts deleted file mode 100644 index f24542b961..0000000000 --- a/cli/test/test-bundle-pages.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from 'bun:test' -import { getActiveAppVersions } from '../src/api/versions.ts' - -function makeBundleRow(index: number) { - return { - id: index, - name: `1.0.${index}`, - app_id: 'com.test.app', - created_at: '2024-01-01T00:00:00.000Z', - deleted: false, - } -} - -function makeCannotGetBundleError(message = 'Cannot get bundle') { - const response = new Response(JSON.stringify({ error: 'cannot_get_bundle', message }), { status: 400 }) - return Object.assign(new Error('Edge Function returned a non-2xx status code'), { context: response }) -} - -function createInvokeStub(handlers: Record Promise<{ data: unknown, error: Error | null }>>) { - return async (path: string) => { - const url = new URL(path, 'https://example.test/') - const page = Number(url.searchParams.get('page') || '0') - const handler = handlers[page] - if (!handler) - throw new Error(`unexpected bundle page ${page}`) - return handler() - } -} - -describe('fetchBundlePages empty-list EOF', () => { - it('returns [] when page 0 responds with cannot_get_bundle', async () => { - const versions = await getActiveAppVersions('test-key', 'com.test.app', { - invoke: createInvokeStub({ - 0: async () => ({ data: null, error: makeCannotGetBundleError() }), - }), - }) - expect(versions).toEqual([]) - }) - - it('returns accumulated bundles when a later page responds with cannot_get_bundle', async () => { - const firstPage = Array.from({ length: 50 }, (_, index) => makeBundleRow(index)) - const versions = await getActiveAppVersions('test-key', 'com.test.app', { - invoke: createInvokeStub({ - 0: async () => ({ data: firstPage, error: null }), - 1: async () => ({ data: null, error: makeCannotGetBundleError() }), - }), - }) - expect(versions).toHaveLength(50) - expect(versions[0]?.name).toBe('1.0.0') - expect(versions[49]?.name).toBe('1.0.49') - }) - - it('still throws when cannot_get_bundle has a different message', async () => { - const firstPage = Array.from({ length: 50 }, (_, index) => makeBundleRow(index)) - await expect(getActiveAppVersions('test-key', 'com.test.app', { - invoke: createInvokeStub({ - 0: async () => ({ data: firstPage, error: null }), - 1: async () => ({ data: null, error: makeCannotGetBundleError('Access denied') }), - }), - })).rejects.toThrow(/not found in database/) - }) - - it('still throws for unrelated errors on later pages', async () => { - const firstPage = Array.from({ length: 50 }, (_, index) => makeBundleRow(index)) - await expect(getActiveAppVersions('test-key', 'com.test.app', { - invoke: createInvokeStub({ - 0: async () => ({ data: firstPage, error: null }), - 1: async () => ({ data: null, error: new Error('upstream failure') }), - }), - })).rejects.toThrow(/not found in database/) - }) -}) diff --git a/cli/test/test-cli-help.mjs b/cli/test/test-cli-help.mjs index a9cc1c1bff..7fc4ee6cfc 100644 --- a/cli/test/test-cli-help.mjs +++ b/cli/test/test-cli-help.mjs @@ -57,8 +57,4 @@ for (const help of [buildHelp, credentialsHelp, requestHelp, saveHelp, updateHel assert.doesNotMatch(help, /npx @capgo\/cli(?!@latest)/) } -const bundleUploadHelp = getHelp('bundle', 'upload') -assert.match(bundleUploadHelp, /--mode /) -assert.match(bundleUploadHelp, /cordova/) - console.log('✅ CLI help readability checks passed') diff --git a/cli/test/test-cli-user-error-config.mjs b/cli/test/test-cli-user-error-config.mjs index cb147b9691..27c72b9295 100644 --- a/cli/test/test-cli-user-error-config.mjs +++ b/cli/test/test-cli-user-error-config.mjs @@ -11,9 +11,9 @@ import { encryptZipInternal } from '../src/bundle/encrypt.ts' import { zipBundleInternal } from '../src/bundle/zip.ts' import { shouldCapturePosthogException } from '../src/posthog.ts' import { CliUserError } from '../src/shared/cli-user-error.ts' -import { getAppId, getConfigForWrite, getOrganizationId, NO_CAPACITOR_CONFIG_MESSAGE } from '../src/utils.ts' +import { getAppId, getConfigForWrite, getOrganizationId } from '../src/utils.ts' -const NO_CONFIG_MESSAGE = NO_CAPACITOR_CONFIG_MESSAGE +const NO_CONFIG_MESSAGE = 'No capacitor config file found, run `cap init` first' const ORG_ID_MESSAGE = 'Cannot get organization id for app' function assertCliUserError(error, message, contextKeys = []) { diff --git a/cli/test/test-cordova-upload-mode.mjs b/cli/test/test-cordova-upload-mode.mjs deleted file mode 100644 index a8e7c797fa..0000000000 --- a/cli/test/test-cordova-upload-mode.mjs +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env node - -import assert from 'node:assert/strict' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { spawnSync } from 'node:child_process' -import { withCwd } from '../src/build/cwd.ts' -import { buildCordovaUploadConfig, collectCordovaAppIdCandidates, CORDOVA_DEFAULT_WEB_DIR } from '../src/cordova/project.ts' -import { buildMissingCapacitorConfigUploadMessage, enhanceMissingCapacitorConfigUploadError, loadUploadProjectConfig } from '../src/bundle/upload-config.ts' -import { CliUserError } from '../src/shared/cli-user-error.ts' -import { NO_CAPACITOR_CONFIG_MESSAGE } from '../src/utils.ts' - -const cliDir = new URL('..', import.meta.url) - -function t(name, fn) { - return (async () => { - try { - await fn() - process.stdout.write(`✓ ${name}\n`) - } - catch (error) { - process.stderr.write(`✗ ${name}\n`) - throw error - } - })() -} - -function writeCordovaProject(dir, { appId = 'com.example.cordova', webDir = 'www' } = {}) { - writeFileSync(join(dir, 'config.xml'), ` - - -`) - mkdirSync(join(dir, webDir), { recursive: true }) - writeFileSync(join(dir, webDir, 'index.html'), 'notifyAppReady()') -} - -await t('collectCordovaAppIdCandidates reads config.xml widget id', async () => { - const dir = mkdtempSync(join(tmpdir(), 'capgo-cordova-config-')) - try { - writeCordovaProject(dir, { appId: 'com.customer.cordova' }) - assert.deepEqual(collectCordovaAppIdCandidates(dir), ['com.customer.cordova']) - } - finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -await t('collectCordovaAppIdCandidates ignores widget id in XML comments', async () => { - const dir = mkdtempSync(join(tmpdir(), 'capgo-cordova-comment-')) - try { - writeFileSync(join(dir, 'config.xml'), ` - - - -`) - assert.deepEqual(collectCordovaAppIdCandidates(dir), ['com.customer.real']) - } - finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -await t('collectCordovaAppIdCandidates reads plugin.xml id', async () => { - const dir = mkdtempSync(join(tmpdir(), 'capgo-cordova-plugin-')) - try { - writeFileSync(join(dir, 'plugin.xml'), ` -`) - assert.deepEqual(collectCordovaAppIdCandidates(dir), ['com.customer.plugin']) - } - finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -await t('buildCordovaUploadConfig defaults webDir to www', async () => { - const dir = mkdtempSync(join(tmpdir(), 'capgo-cordova-default-www-')) - try { - writeCordovaProject(dir) - await withCwd(dir, async () => { - const config = buildCordovaUploadConfig({}) - assert.equal(config.config.webDir, join(dir, CORDOVA_DEFAULT_WEB_DIR)) - assert.equal(config.config.appId, 'com.example.cordova') - assert.equal(config.path, '') - }) - } - finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -await t('buildCordovaUploadConfig resolves webDir from nested working directory', async () => { - const dir = mkdtempSync(join(tmpdir(), 'capgo-cordova-nested-cwd-')) - const nestedDir = join(dir, 'scripts') - try { - writeCordovaProject(dir) - mkdirSync(nestedDir, { recursive: true }) - await withCwd(nestedDir, async () => { - const config = buildCordovaUploadConfig({}) - assert.equal(config.config.webDir, join(dir, CORDOVA_DEFAULT_WEB_DIR)) - assert.equal(config.config.appId, 'com.example.cordova') - }) - } - finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -await t('loadUploadProjectConfig resolves cordova mode without capacitor.config', async () => { - const dir = mkdtempSync(join(tmpdir(), 'capgo-cordova-upload-config-')) - try { - writeCordovaProject(dir, { appId: 'com.upload.cordova', webDir: 'www' }) - await withCwd(dir, async () => { - const config = await loadUploadProjectConfig({ mode: 'cordova', path: 'www' }, { appId: 'com.upload.cordova' }) - assert.equal(config.config.webDir, 'www') - assert.equal(config.config.appId, 'com.upload.cordova') - }) - } - finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -await t('missing capacitor config suggests --mode cordova on upload', async () => { - const hint = buildMissingCapacitorConfigUploadMessage({ - appId: 'com.example.app', - path: 'www', - channel: 'production', - }) - assert.match(hint, new RegExp(NO_CAPACITOR_CONFIG_MESSAGE)) - assert.match(hint, /--mode cordova/) - assert.match(hint, /--channel production/) - assert.match(hint, /npx @capgo\/cli@latest bundle upload com\.example\.app --mode cordova --path www --channel production/) - - assert.throws( - () => enhanceMissingCapacitorConfigUploadError(new CliUserError(NO_CAPACITOR_CONFIG_MESSAGE), { - appId: 'com.example.app', - path: 'www', - channel: 'production', - }), - (error) => { - assert.equal(error instanceof CliUserError, true) - assert.equal(error.message, hint) - return true - }, - ) -}) - -await t('bundle upload rejects unknown --mode values', async () => { - const result = spawnSync(process.execPath, ['dist/index.js', 'bundle', 'upload', 'com.example.app', '--mode', 'react-native'], { - cwd: cliDir, - encoding: 'utf8', - }) - assert.notEqual(result.status, 0) - assert.match(`${result.stderr}\n${result.stdout}`, /invalid|error/i) -}) - -console.log('Cordova upload mode tests passed') diff --git a/cli/test/test-create-supabase-client.mjs b/cli/test/test-create-supabase-client.mjs index 3f5d6d3882..719a501fb1 100644 --- a/cli/test/test-create-supabase-client.mjs +++ b/cli/test/test-create-supabase-client.mjs @@ -17,17 +17,6 @@ const isolatedDir = mkdtempSync(join(tmpdir(), 'capgo-create-supabase-client-')) async function assertMissingConfig(fetchImpl, expectedContext, label) { globalThis.fetch = fetchImpl const controller = new AbortController() - const stdoutWrite = process.stdout.write - const stderrWrite = process.stderr.write - let output = '' - process.stdout.write = function (chunk, ...args) { - output += String(chunk) - return stdoutWrite.call(this, chunk, ...args) - } - process.stderr.write = function (chunk, ...args) { - output += String(chunk) - return stderrWrite.call(this, chunk, ...args) - } let thrown try { await createSupabaseClient('test-api-key', undefined, undefined, true, false, controller.signal) @@ -36,13 +25,8 @@ async function assertMissingConfig(fetchImpl, expectedContext, label) { catch (error) { thrown = error } - finally { - process.stdout.write = stdoutWrite - process.stderr.write = stderrWrite - } assert.equal(thrown instanceof CliUserError, true, label) - assert.doesNotMatch(output, /Cannot connect to server/, `silent validation must not write outside Ink (${label})`) assert.equal(thrown.message, CAPGO_SERVER_CONFIG_MISSING_MESSAGE, label) assert.equal(thrown.context?.missingSupaHost, expectedContext.missingSupaHost, label) assert.equal(thrown.context?.missingSupaKey, expectedContext.missingSupaKey, label) diff --git a/cli/test/test-ios-certificate-action.mjs b/cli/test/test-ios-certificate-action.mjs deleted file mode 100644 index eac69c0019..0000000000 --- a/cli/test/test-ios-certificate-action.mjs +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict' -import { Buffer } from 'node:buffer' -import { CertificateLimitError } from '../src/build/onboarding/apple-api.ts' -import { runIosEffect } from '../src/build/onboarding/ios/flow.ts' -import { trackCreatedIosCertificateResult, trackImportedIosCertificateSaveResult, trackIosCertificateCreationThrow, trackIosKeychainExportResult } from '../src/build/onboarding/ui/ios-certificate-action.ts' - -const journeyId = 'bj_certificate_test' -const actions = [] -const trackAction = (action, tags, step) => actions.push({ action, tags, step }) -const reportedSuccesses = new Set() -const progress = (setupMethod = 'create-new') => ({ - appId: 'com.example.certificate', - platform: 'ios', - startedAt: '2026-01-01T00:00:00.000Z', - setupMethod, - ...(setupMethod === 'import-existing' ? { importDistribution: 'ad_hoc' } : {}), - completedSteps: {}, -}) - -// The create action follows both .p12 creation and persistence of the marker. -const creationOrder = [] -const createDeps = { - generateCsr: () => ({ csr: 'PRIVATE_CSR', privateKeyPem: 'PRIVATE_KEY' }), - createCertificate: async () => ({ certificateId: 'FAKE_CERT_ID', certificateContent: 'PRIVATE_CERT', expirationDate: '2027-01-01', teamId: 'FAKE_TEAM' }), - createP12: () => { creationOrder.push('created-p12'); return 'PRIVATE_P12' }, - saveProgress: async (_appId, saved) => { - assert.equal(saved.completedSteps.certificateCreated.p12Base64, 'PRIVATE_P12') - creationOrder.push('saved-progress') - }, -} -const created = await runIosEffect('creating-certificate', progress(), createDeps) -trackCreatedIosCertificateResult(created, journeyId, (...args) => { - creationOrder.push('tracked-action') - trackAction(...args) -}, reportedSuccesses) -trackCreatedIosCertificateResult(created, journeyId, trackAction, reportedSuccesses) -assert.deepEqual(creationOrder, ['created-p12', 'saved-progress', 'tracked-action']) -assert.deepEqual(actions.splice(0), [{ - action: 'certificate_prepared', - tags: { attempt_id: journeyId, source: 'created' }, - step: 'creating-certificate', -}]) - -const failedCreation = await runIosEffect('creating-certificate', progress(), { - ...createDeps, - createP12: () => { throw new Error('PRIVATE_CREATE_EXCEPTION') }, -}) -trackCreatedIosCertificateResult(failedCreation, journeyId, trackAction, reportedSuccesses) -assert.equal(failedCreation.next, 'error') -assert.deepEqual(actions.splice(0), [{ - action: 'certificate_preparation_failed', - tags: { attempt_id: journeyId, source: 'created', reason: 'create_failed' }, - step: 'creating-certificate', -}]) - -const limited = await runIosEffect('creating-certificate', progress(), { - ...createDeps, - createCertificate: async () => { throw new CertificateLimitError([]) }, - listCertificates: async () => [], -}) -trackCreatedIosCertificateResult(limited, journeyId, trackAction, reportedSuccesses) -assert.equal(limited.next, 'cert-limit-prompt') -assert.deepEqual(actions.splice(0), [{ - action: 'certificate_preparation_failed', - tags: { attempt_id: journeyId, source: 'created', reason: 'certificate_limit' }, - step: 'creating-certificate', -}]) - -const limitLookupFailed = await runIosEffect('creating-certificate', progress(), { - ...createDeps, - createCertificate: async () => { throw new CertificateLimitError([]) }, - listCertificates: async () => { throw new Error('PRIVATE_LOOKUP_EXCEPTION') }, -}) -trackCreatedIosCertificateResult(limitLookupFailed, journeyId, trackAction, reportedSuccesses) -assert.equal(limitLookupFailed.next, 'error', 'lookup failure keeps the existing onboarding error route') -assert.deepEqual(actions.splice(0), [{ - action: 'certificate_preparation_failed', - tags: { attempt_id: journeyId, source: 'created', reason: 'certificate_limit' }, - step: 'creating-certificate', -}]) - -// A failed attempt does not reserve the success key; the retry can report success. -const retrySuccesses = new Set() -trackCreatedIosCertificateResult(failedCreation, journeyId, trackAction, retrySuccesses) -trackCreatedIosCertificateResult(created, journeyId, trackAction, retrySuccesses) -assert.deepEqual(actions.map(event => event.action), ['certificate_preparation_failed', 'certificate_prepared']) -actions.length = 0 - -const identity = { sha1: 'a'.repeat(40), name: 'Fake Distribution', type: 'distribution', teamId: 'FAKE_TEAM' } -const profile = { path: '/fake.mobileprovision', uuid: 'FAKE_PROFILE', name: 'Fake Profile', expirationDate: '2027-01-01', profileType: 'ad_hoc' } -const importDeps = { - carried: { chosenIdentity: identity, chosenProfile: profile }, - exportP12FromKeychain: async () => ({ base64: 'PRIVATE_EXPORTED_P12', passphrase: 'PRIVATE_PASSWORD' }), - readFile: async () => Buffer.from('PRIVATE_PROFILE'), -} -const exported = await runIosEffect('import-exporting', progress('import-existing'), importDeps) -trackIosKeychainExportResult(exported, journeyId, trackAction) -trackImportedIosCertificateSaveResult(exported, exported.transient, journeyId, trackAction, reportedSuccesses) -assert.equal(exported.next, 'saving-credentials') -assert.equal(exported.transient.keychainP12Exported, true) -assert.equal(actions.length, 0, 'export alone is transient and emits no success') - -const carried = { ...importDeps.carried, ...exported.transient } -const saveDeps = { - carried, - updateSavedCredentials: async (_appId, _platform, credentials) => { - assert.equal(credentials.BUILD_CERTIFICATE_BASE64, 'PRIVATE_EXPORTED_P12') - }, - deleteProgress: async () => {}, - loadProgress: async () => null, -} -await assert.rejects(() => runIosEffect('saving-credentials', progress('import-existing'), { - ...saveDeps, - updateSavedCredentials: async () => { throw new Error('PRIVATE_SAVE_EXCEPTION') }, -}), /PRIVATE_SAVE_EXCEPTION/) -assert.equal(actions.length, 0, 'failed credential save emits no success') - -const saved = await runIosEffect('saving-credentials', progress('import-existing'), saveDeps) -trackImportedIosCertificateSaveResult(saved, carried, journeyId, trackAction, reportedSuccesses) -trackImportedIosCertificateSaveResult(saved, carried, journeyId, trackAction, reportedSuccesses) -assert.equal(saved.next, 'ask-build') -assert.deepEqual(actions.splice(0), [{ - action: 'certificate_prepared', - tags: { attempt_id: journeyId, source: 'keychain_import' }, - step: 'saving-credentials', -}]) - -const exportFailure = await runIosEffect('import-exporting', progress('import-existing'), { - ...importDeps, - exportP12FromKeychain: async () => { throw new Error('PRIVATE_EXPORT_EXCEPTION') }, -}) -trackIosKeychainExportResult(exportFailure, journeyId, trackAction) -assert.equal(exportFailure.next, 'error') -assert.deepEqual(actions.splice(0), [{ - action: 'certificate_preparation_failed', - tags: { attempt_id: journeyId, source: 'keychain_import', reason: 'export_failed' }, - step: 'import-exporting', -}]) - -const missingSelection = await runIosEffect('import-exporting', progress('import-existing'), { carried: {} }) -trackIosKeychainExportResult(missingSelection, journeyId, trackAction) -assert.equal(actions.length, 0, 'picker state alone is not an export failure') -assert.doesNotThrow(() => trackIosCertificateCreationThrow(journeyId, () => { throw new Error('telemetry unavailable') })) - -console.log('✅ iOS certificate actions follow prepared results and use safe tags') diff --git a/cli/test/test-ios-credential-action.mjs b/cli/test/test-ios-credential-action.mjs deleted file mode 100644 index 41c47c87b5..0000000000 --- a/cli/test/test-ios-credential-action.mjs +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict' -import { Buffer } from 'node:buffer' -import { runIosEffect } from '../src/build/onboarding/ios/flow.ts' -import { trackGuidedKeyValidationFailure, trackVerifiedIosKey, verifyIosKeyWithTelemetry } from '../src/build/onboarding/ui/ios-credential-action.ts' - -const journeyId = 'bj_test-journey' -const actions = [] -const emitted = [] -const trackAction = (action, tags, step) => { - const event = { action, tags, step } - actions.push(event) - emitted.push(event) -} -const progress = () => ({ - appId: 'com.example.app', - platform: 'ios', - startedAt: '2026-01-01T00:00:00.000Z', - completedSteps: {}, - keyId: 'SECRET_KEY_ID', - issuerId: 'SECRET_ISSUER_ID', - p8Path: '/secret/AuthKey_SECRET_KEY_ID.p8', -}) - -// The helper's own validation event is the only guided-path failure signal. -trackGuidedKeyValidationFailure('validation_failed', journeyId, trackAction) -assert.deepEqual(actions.shift(), { - action: 'credential_verification_failed', - tags: { - credential: 'ios_app_store_connect_api_key', - attempt_id: journeyId, - source: 'guided_helper', - }, - step: 'asc-key-generating', -}) -trackGuidedKeyValidationFailure('helper_cancelled', journeyId, trackAction) -trackGuidedKeyValidationFailure('validation_failed', journeyId, trackAction, true) -assert.equal(actions.length, 0, 'manual exit and cancellation emit no failure action') - -// The original Apple error still reaches the engine unchanged. Only the mapper's -// safe category reaches the action, even though the engine later drops status. -const appleError = Object.assign(new Error('PRIVATE_RAW_ERROR'), { status: 403 }) -const failure = await runIosEffect('verifying-key', progress(), { - carried: { p8Content: Buffer.from('PRIVATE_P8_CONTENT') }, - verifyApiKey: () => verifyIosKeyWithTelemetry( - async () => { throw appleError }, journeyId, trackAction, () => false, - ), -}) -assert.equal(failure.next, 'error') -assert.equal(failure.transient.retryStep, 'verifying-key', 'existing recovery route is preserved') -assert.deepEqual(actions.shift(), { - action: 'credential_verification_failed', - tags: { - credential: 'ios_app_store_connect_api_key', - attempt_id: journeyId, - source: 'cli_verifier', - error_category: 'apple_api_forbidden', - }, - step: 'verifying-key', -}) -assert.equal(actions.length, 0) - -const order = [] -const success = await runIosEffect('verifying-key', progress(), { - carried: { p8Content: Buffer.from('PRIVATE_P8_CONTENT') }, - verifyApiKey: () => verifyIosKeyWithTelemetry( - async () => { order.push('verified-with-apple'); return { teamId: 'SECRET_TEAM_ID' } }, - journeyId, trackAction, () => false, - ), - saveProgress: async (_appId, saved) => { - assert.deepEqual(saved.completedSteps.apiKeyVerified, { - keyId: 'SECRET_KEY_ID', - issuerId: 'SECRET_ISSUER_ID', - }) - order.push('saved-progress') - }, -}) -assert.equal(success.next, 'verify-app') -trackVerifiedIosKey(success, journeyId, (action, tags, step) => { - order.push('tracked-action') - trackAction(action, tags, step) -}) -assert.deepEqual(order, ['verified-with-apple', 'saved-progress', 'tracked-action']) -assert.deepEqual(actions.shift(), { - action: 'credential_verified', - tags: { credential: 'ios_app_store_connect_api_key', attempt_id: journeyId }, - step: 'verifying-key', -}) - -// A failed local save cannot turn a verified Apple response into a success event. -const saveFailure = await runIosEffect('verifying-key', progress(), { - carried: { p8Content: Buffer.from('PRIVATE_P8_CONTENT') }, - verifyApiKey: () => verifyIosKeyWithTelemetry( - async () => ({ teamId: 'SECRET_TEAM_ID' }), journeyId, trackAction, () => false, - ), - saveProgress: async () => { throw new Error('PRIVATE_SAVE_ERROR') }, -}) -trackVerifiedIosKey(saveFailure, journeyId, trackAction) -assert.equal(saveFailure.next, 'error') -assert.equal(actions.length, 0, 'local save failure emits neither action') - -// Missing key material fails before the shared verifier is invoked. -await assert.rejects(() => runIosEffect('verifying-key', progress(), { - readFile: async () => { throw new Error('ENOENT') }, - verifyApiKey: () => verifyIosKeyWithTelemetry( - async () => { throw new Error('should not run') }, journeyId, trackAction, () => false, - ), -}), /\.p8 content unavailable/) -assert.equal(actions.length, 0, 'missing file emits no action') - -await assert.rejects(() => verifyIosKeyWithTelemetry( - async () => { throw appleError }, journeyId, trackAction, () => true, -), error => error === appleError) -trackVerifiedIosKey({ next: 'verifying-key', progress: progress() }, journeyId, trackAction) -assert.equal(actions.length, 0, 'cancellation and merely opening verification emit no action') - -for (const forbidden of ['SECRET_KEY_ID', 'SECRET_ISSUER_ID', 'SECRET_TEAM_ID', 'PRIVATE_P8_CONTENT', 'PRIVATE_RAW_ERROR', 'PRIVATE_SAVE_ERROR', '/secret/']) - assert.equal(JSON.stringify(emitted).includes(forbidden), false, `action must omit ${forbidden}`) - -console.log('✅ iOS credential action boundaries and safe payloads') diff --git a/cli/test/test-ios-tui-routing.mjs b/cli/test/test-ios-tui-routing.mjs index 773aa7735e..5ca703ca1e 100644 --- a/cli/test/test-ios-tui-routing.mjs +++ b/cli/test/test-ios-tui-routing.mjs @@ -64,7 +64,6 @@ import process from 'node:process' const { applyIosInput, runIosEffect } = await import('../src/build/onboarding/ios/flow.ts') const { getIosResumeStep } = await import('../src/build/onboarding/ios/progress.ts') -const { routeFreshIosSetupMethod } = await import('../src/build/onboarding/ui/setup-method-route.ts') console.log('🧪 iOS choice/input ROUTING PARITY (applyIosInput → getIosResumeStep / resolver effect)\n') @@ -186,21 +185,6 @@ async function parityEffect({ step, progress, carried, deps: depsOverrides, besp // ════════════════════════════════════════════════════════════════════════════ // setup-method-select (app.tsx L3081-3103) — the macOS create-vs-import fork // ════════════════════════════════════════════════════════════════════════════ -test('fresh setup route · non-macOS skips the question; macOS shows it', () => { - const actions = [] - const trackAction = (...args) => actions.push(args) - assertEquals(routeFreshIosSetupMethod(false, 'bj_ios-setup', trackAction), 'api-key-instructions') - assertEquals(JSON.stringify(actions), JSON.stringify([['question_skipped', { - attempt_id: 'bj_ios-setup', - question_id: 'ios_setup_method', - choice: 'create-new', - reason: 'non_macos_auto_create_new', - }, 'setup-method-select']])) - actions.length = 0 - assertEquals(routeFreshIosSetupMethod(true, 'bj_ios-setup', trackAction), 'setup-method-select') - assertEquals(actions.length, 0) -}) - // Persists setupMethod. 'create' → getResumeStep (create-new, no .p8) lands on // api-key-instructions (MATCH the bespoke). 'import' → the bespoke jumps STRAIGHT // to import-scanning (the silent discovery); the engine resume, with no diff --git a/cli/test/test-notify-app-ready-background.mjs b/cli/test/test-notify-app-ready-background.mjs deleted file mode 100644 index c756a77d71..0000000000 --- a/cli/test/test-notify-app-ready-background.mjs +++ /dev/null @@ -1,753 +0,0 @@ -import assert from 'node:assert/strict' -import { spawn } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import { once } from 'node:events' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { createServer } from 'node:http' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterAll, test } from 'bun:test' -import { fileURLToPath } from 'node:url' -import { resolveNotifyAppReadyProject } from '../src/onboarding/notify-app-ready-project.ts' -import { scanNotifyAppReadySource } from '../src/onboarding/notify-app-ready-source.ts' -import { scanUpdaterInstalled } from '../src/onboarding/updater-installed.ts' -import { isTrustedOnboardingApiHost } from '../src/onboarding/background-api.ts' - -const fixtures = [] -const workerUrl = new URL('../dist/notify-app-ready-worker.js', import.meta.url) -const updaterWorkerUrl = new URL('../dist/updater-installed-worker.js', import.meta.url) -const combinedWorkerUrl = new URL('../dist/onboarding-worker.js', import.meta.url) -const call = "import { CapacitorUpdater } from '@capgo/capacitor-updater'; CapacitorUpdater.notifyAppReady()" - -async function workerHarness(worker = workerUrl) { - const requests = [] - const behavior = { events: 'ok', putStatus: 200, putError: false, putDelayMs: 0 } - const server = createServer(async (request, response) => { - let body = '' - for await (const chunk of request) - body += chunk - requests.push({ path: request.url, headers: request.headers, body: JSON.parse(body), method: request.method }) - if (request.method === 'POST') - behavior.onEvent?.(requests.at(-1).body) - if (request.method === 'PUT' && behavior.putError) { - request.socket.destroy() - return - } - if (behavior.redirectLocation && (request.method === 'POST' ? behavior.events === 'redirect' : behavior.putRedirect)) { - response.writeHead(307, { Location: behavior.redirectLocation }).end() - return - } - if (request.method === 'POST' && behavior.events === 'hang') - return - if (request.method === 'PUT' && behavior.putDelayMs) - await new Promise(resolve => setTimeout(resolve, behavior.putDelayMs)) - const status = request.method === 'PUT' ? behavior.putStatus : behavior.events === 'rejected' ? 503 : 200 - response.writeHead(status, { 'Content-Type': 'application/json' }).end('{"status":"ok"}') - }) - server.listen(0, '127.0.0.1') - await once(server, 'listening') - const api = `http://127.0.0.1:${server.address().port}` - const project = app(fixture(), '.', 'com.example.ready', { plugins: { CapacitorUpdater: { localApi: api } } }) - write(join(project.dir, 'src/main.ts'), call) - return { - api, project, requests, behavior, - async run(extra = {}, environment = {}) { - requests.length = 0 - const workerData = worker.href === combinedWorkerUrl.href - ? { cwd: project.dir, command: 'app list', apikey: 'fake-api-key', ...extra } - : { project: { dir: project.dir, workspaceRoot: project.workspaceRoot, appId: project.appId, webDir: project.webDir }, apiHost: api, command: 'app list', apikey: 'fake-api-key', attemptId: randomUUID(), ...extra } - const child = spawn('node', ['--input-type=module', '-e', ` - import { Worker } from 'node:worker_threads' - const worker = new Worker(new URL(${JSON.stringify(worker.href)}), { - workerData: ${JSON.stringify(workerData)}, stdout: true, stderr: true, execArgv: [] - }) - worker.on('error', () => process.exit(1)) - worker.on('exit', code => process.exit(code)) - `], { - stdio: 'ignore', - env: { ...process.env, CAPGO_DISABLE_TELEMETRY: '', CAPGO_DISABLE_POSTHOG: '', CAPGO_TRUSTED_API_ORIGINS: api, ...environment }, - }) - const timeout = setTimeout(() => child.kill(), 10_000) - try { - const [code, signal] = await once(child, 'exit') - assert.equal(signal, null, 'worker did not finish its bounded reporting') - assert.equal(code, 0) - } - finally { - clearTimeout(timeout) - } - }, - close() { - server.closeAllConnections() - server.close() - }, - } -} - -function scanEvents(requests, result, reportStatus, channel = 'notify-app-ready') { - const events = requests.filter(request => request.method === 'POST') - assert.deepEqual(events.map(request => request.body.event), ['scan_started', 'scan_ended']) - const [started, ended] = events.map(request => request.body) - assert.match(started.nonPersonTags.attempt_id, /^[0-9a-f-]{36}$/) - assert.equal(ended.nonPersonTags.attempt_id, started.nonPersonTags.attempt_id) - for (const request of events) { - assert.equal(request.path.endsWith('/private/events'), true) - assert.equal(request.headers.capgkey, 'fake-api-key') - assert.equal(request.headers['x-cli-command'], 'app list') - assert.equal(request.body.channel, channel) - assert.equal(request.body.tracking_version, 2) - assert.deepEqual(request.body.tags, { app_id: 'com.example.ready' }) - assert.equal(request.body.nonPersonTags.command_path, 'app list') - assert.equal(typeof request.body.nonPersonTags.cli_version, 'string') - assert.equal(Number.isFinite(Date.parse(request.body.timestamp)), true) - } - assert.equal(Date.parse(ended.timestamp) >= Date.parse(started.timestamp), true) - assert.equal(ended.nonPersonTags.result, result) - assert.equal(ended.nonPersonTags.todo_report_status, reportStatus) - assert.equal(typeof ended.nonPersonTags.duration_ms, 'number') - assert.equal(ended.nonPersonTags.duration_ms >= 0, true) - return started.nonPersonTags.attempt_id -} - -function write(path, content) { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, typeof content === 'string' ? content : JSON.stringify(content)) -} - -function fixture() { - const root = realpathSync(mkdtempSync(join(tmpdir(), 'capgo-ready-'))) - fixtures.push(root) - return root -} - -function app(root, path = '.', appId = 'com.example.ready', extra = {}) { - const dir = join(root, path) - const config = { appId, appName: 'Example', webDir: 'output', ...extra } - write(join(dir, 'package.json'), { name: `example-${appId}`, version: '1.0.0' }) - write(join(dir, 'capacitor.config.json'), config) - return { dir, workspaceRoot: root, appId, config, webDir: join(dir, config.webDir) } -} - -function scan(content, filename = 'main.ts') { - const project = app(fixture()) - write(join(project.dir, 'src', filename), content) - return scanNotifyAppReadySource(project) -} - -test('detects direct calls, import aliases, namespace imports, CommonJS, and optional/computed calls', () => { - for (const content of [ - call, - "import { CapacitorUpdater as Updater } from '@capgo/capacitor-updater'; Updater.notifyAppReady()", - "import * as updater from '@capgo/capacitor-updater'; updater.CapacitorUpdater.notifyAppReady()", - "const { CapacitorUpdater: Updater } = require('@capgo/capacitor-updater'); Updater.notifyAppReady()", - "import { CapacitorUpdater } from '@capgo/capacitor-updater'; CapacitorUpdater?.notifyAppReady?.()", - "import { CapacitorUpdater } from '@capgo/capacitor-updater'; CapacitorUpdater['notifyAppReady']()", - ]) { - assert.equal(scan(content), 'found', content) - } - assert.equal(scan(call, 'main.jsx'), 'found') - assert.equal(scan(call, 'main.mjs'), 'found') -}) - -test('ignores comments, strings, definitions, other packages, type imports, and shadowed bindings', () => { - for (const content of [ - `// ${call}`, - `const example = ${JSON.stringify(call)}`, - 'const CapacitorUpdater = { notifyAppReady() {} }; CapacitorUpdater.notifyAppReady()', - "import { CapacitorUpdater } from 'another-package'; CapacitorUpdater.notifyAppReady()", - "import type { CapacitorUpdater } from '@capgo/capacitor-updater'; CapacitorUpdater.notifyAppReady()", - "import { type CapacitorUpdater } from '@capgo/capacitor-updater'; CapacitorUpdater.notifyAppReady()", - "import { CapacitorUpdater } from '@capgo/capacitor-updater'; function example(CapacitorUpdater) { CapacitorUpdater.notifyAppReady() }", - "import * as updater from '@capgo/capacitor-updater'; function example(updater) { updater.CapacitorUpdater.notifyAppReady() }", - "function require() { return {} }; const { CapacitorUpdater } = require('@capgo/capacitor-updater'); CapacitorUpdater.notifyAppReady()", - ]) { - assert.equal(scan(content), 'not_found', content) - } - assert.equal(scan(`${call}; const broken = (`), 'unknown') -}) - -test('checks Vue script/setup blocks and ignores markup and HTML comments', () => { - assert.equal(scan(``, 'App.vue'), 'found') - assert.equal(scan(``, 'App.vue'), 'not_found') - assert.equal(scan(``, 'App.vue'), 'not_found') - assert.equal(scan(``, 'App.vue'), 'not_found') - assert.equal(scan(``, 'App.vue'), 'found') - assert.equal(scan(` - - diff --git a/playwright/fixtures/email-verification.ts b/playwright/fixtures/email-verification.ts deleted file mode 100644 index 3b4f642381..0000000000 --- a/playwright/fixtures/email-verification.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { Session } from '@supabase/supabase-js' -import { defaultConfig, plugin } from '@formkit/vue' -import { createPinia } from 'pinia' -import { createApp, defineComponent, h } from 'vue' -import { createRouter, createWebHistory, RouterView } from 'vue-router' -import { Toaster } from 'vue-sonner' -import { i18n } from '../../src/modules/i18n' -import { useSupabase } from '../../src/services/supabase' -import '../../src/styles/style.css' - -const state = { - sendError: null as { code: string, message: string, status: number } | null, - sendDelayMs: 0, - sends: [] as string[], - verificationError: false, - verifications: [] as string[], - captchaCallbacks: null as { error: () => void, unsupported: () => void } | null, -} -Object.assign(window, { emailVerificationPreview: state }) - -// Isolate the real page from external services; no emails or API writes occur. -window.fetch = async (input, init) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url - const verifying = url.includes('/verify_email_otp') - if (verifying) - state.verifications.push(JSON.parse(String(init?.body)).token) - const failed = verifying && state.verificationError - return new Response(JSON.stringify(verifying - ? failed ? { message: 'Invalid verification code' } : { verified_at: new Date().toISOString() } - : { email_otp_verified_at: null }), { - status: failed ? 400 : 200, - headers: { 'Content-Type': 'application/json' }, - }) -} -const supabase = useSupabase() -supabase.auth.getSession = async () => ({ data: { session: { - access_token: 'fixture-token', - user: { id: '00000000-0000-4000-8000-000000000001', email: 'verification-preview@example.com' }, -} as Session }, error: null }) -supabase.auth.signInWithOtp = async () => { - state.sends.push('send') - if (state.sendDelayMs) - await new Promise(resolve => setTimeout(resolve, state.sendDelayMs)) - return { data: { user: null, session: null }, error: state.sendError as any } -} -supabase.functions.invoke = async (_, options) => { - state.verifications.push((options?.body as { token: string }).token) - return state.verificationError - ? { data: null, error: new Error('Invalid verification code') } - : { data: { verified_at: new Date().toISOString() }, error: null } -} - -// A deterministic widget exercises the page's CAPTCHA transitions in the browser. -const widgets = new Map() -Object.assign(window, { turnstile: { - render(element: HTMLElement | null, options: { 'callback': (token: string) => void, 'error-callback': (code: string) => void, 'unsupported-callback': () => void }) { - if (!element) - return 'unmounted-fixture-widget' - state.captchaCallbacks = { - error: () => options['error-callback']('fixture-error'), - unsupported: () => options['unsupported-callback'](), - } - const id = `fixture-widget-${widgets.size}` - const label = document.createElement('label') - label.className = 'flex items-center gap-3 rounded border border-slate-300 bg-slate-50 p-4 text-slate-900' - const input = document.createElement('input') - input.type = 'checkbox' - input.setAttribute('aria-label', 'Verify you are human') - input.addEventListener('change', () => { - if (input.checked) - options.callback('fixture-captcha-token') - }) - label.append(input, 'Verify you are human') - element.append(label) - widgets.set(id, { element, input }) - return id - }, - reset() { - widgets.forEach(widget => widget.input.checked = false) - }, - remove(id: string) { - widgets.get(id)?.element.replaceChildren() - widgets.delete(id) - }, -} }) - -async function mountPreview() { - const { default: ResendEmailPage } = await import('../../src/pages/resend_email.vue') - const router = createRouter({ - history: createWebHistory(), - routes: [ - { path: '/playwright/fixtures/email-verification.html', component: ResendEmailPage }, - { path: '/settings/account', component: { render: () => h('h1', 'Account settings') } }, - { path: '/login', component: { render: () => h('h1', 'Login') } }, - ], - }) - const app = createApp(defineComponent({ setup: () => () => [h(RouterView), h(Toaster)] })) - app.use(createPinia()) - app.use(plugin, defaultConfig()) - app.use(i18n) - app.use(router) - await router.isReady() - app.mount('#app') -} - -void mountPreview() diff --git a/playwright/fixtures/onboarding-setup.html b/playwright/fixtures/onboarding-setup.html deleted file mode 100644 index 988f56fd44..0000000000 --- a/playwright/fixtures/onboarding-setup.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Capgo setup preview - - -
- - - diff --git a/playwright/fixtures/onboarding-setup.ts b/playwright/fixtures/onboarding-setup.ts deleted file mode 100644 index b19659b3a1..0000000000 --- a/playwright/fixtures/onboarding-setup.ts +++ /dev/null @@ -1,242 +0,0 @@ -import type { OnboardingChannelEvent, OnboardingChannelEventProperties } from '../../src/utils/onboardingChannelAnalytics' -import { createPinia } from 'pinia' -import { createApp, defineComponent, h, onMounted, ref } from 'vue' -import { createRouter, createWebHistory, RouterView } from 'vue-router' -import AppOnboardingBuilderChecklist from '../../src/components/dashboard/AppOnboardingBuilderChecklist.vue' -import AppOnboardingCliSteps from '../../src/components/dashboard/AppOnboardingCliSteps.vue' -import AppOnboardingFlow from '../../src/components/dashboard/AppOnboardingFlow.vue' -import AppOnboardingSetupChecklist from '../../src/components/dashboard/AppOnboardingSetupChecklist.vue' -import GettingStartedNav from '../../src/components/dashboard/GettingStartedNav.vue' -import OnboardingExploreBanner from '../../src/components/dashboard/OnboardingExploreBanner.vue' -import OnboardingExploreReminder from '../../src/components/dashboard/OnboardingExploreReminder.vue' -import DialogV2 from '../../src/components/DialogV2.vue' -import { i18n } from '../../src/modules/i18n' -import { install as installOnboardingSetupNavigation } from '../../src/modules/onboarding-setup' -import GettingStartedPage from '../../src/pages/app/[app].getting-started.vue' -import { BUILDER_STEP_IDS } from '../../src/services/builderOnboardingChecklist' -import { useSupabase } from '../../src/services/supabase' -import { useMainStore } from '../../src/stores/main' -import { useOrganizationStore } from '../../src/stores/organization' -import '../../src/styles/style.css' - -const params = new URLSearchParams(location.search) -const navigationView = params.get('view') === 'navigation' || location.pathname.startsWith('/app/') || location.pathname === '/onboarding/app' -const builderComponentView = params.get('view') === 'builder' -const assignment = params.get('assignment') -const previewAppId = 'com.example.onboarding-preview' -const savedChannelStatus = params.get('channelStatus') -const state = { - version: Number(params.get('version') ?? 3), - steps: (savedChannelStatus === 'done' || savedChannelStatus === 'skipped' - ? { add_channel: { status: savedChannelStatus } } - : {}) as Record, - builderSteps: { - ios: Object.fromEntries(BUILDER_STEP_IDS.ios.map(id => [id, { status: 'pending' }])) as Record, - android: Object.fromEntries(BUILDER_STEP_IDS.android.map(id => [id, { status: 'pending' }])) as Record, - }, - selectedBuilderPlatform: params.get('platform') === 'ios' || params.get('platform') === 'android' ? params.get('platform') : null, - outcome: 'in_progress', - error: false, - requests: 0, - polls: [] as Array<{ appId: string, N: number, initial: boolean }>, - channels: (params.get('channel') === '1' ? [{ id: 1, app_id: previewAppId, name: 'staging', public: true, allow_device_self_set: true }] : []) as Array<{ id: number, app_id: string, name: string, public?: boolean, allow_device_self_set?: boolean }>, - channelError: false, - channelRequests: 0, - channelQueries: [] as string[], - channelDelayMs: Number(params.get('channelDelay') ?? 0), - channelPermissions: true, - channelInsertError: false, - channelInsertDelayMs: 0, - channelInserts: [] as Record[], - appWrites: [] as Array<{ method: string, body: unknown }>, -} -const events: string[] = [] -const channelEvents: Array<{ event: OnboardingChannelEvent, properties: OnboardingChannelEventProperties }> = [] -const preview = { state, events, channelEvents, appId: ref(previewAppId), command: ref('npx @capgo/cli@latest i [API_KEY]'), hiding: ref(false), selectedOrgId: ref('') } -Object.assign(window, { onboardingSetupPreview: preview }) - -function previewOnboarding() { - const selectedPath = assignment === 'both-ota' || assignment === 'ota-only' ? 'ota' : 'builder' - const hasBuilder = builderComponentView || assignment === 'builder-only' || assignment === 'both-builder' || assignment === 'both-ota' - const hasOta = assignment === 'ota-only' || assignment === 'both-builder' || assignment === 'both-ota' - if (hasBuilder || hasOta) { - return { setup: { - todo_list_version: 4, - ...(hasBuilder ? { builder_todo_list_version: '1' } : {}), - ...(hasOta ? { ota_todo_list_version: '1' } : {}), - paths: [hasOta ? 'ota' : null, hasBuilder ? 'builder' : null].filter(Boolean), - ...(assignment === 'builder-only' ? {} : { selected_path: selectedPath }), - ...(state.selectedBuilderPlatform ? { selected_builder_platform: state.selectedBuilderPlatform } : {}), - steps: { - ...(hasOta ? { ota: {} } : {}), - ...(hasBuilder ? { builder: state.builderSteps } : {}), - }, - outcome: state.outcome, - } } - } - return { setup: { todo_list_version: state.version, steps: state.steps, outcome: state.outcome } } -} - -// This isolated component fixture never sends requests to production. -window.fetch = async (input, init) => { - const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url, location.origin) - const method = init?.method?.toUpperCase() ?? 'GET' - if (url.pathname.endsWith('/apps') && method !== 'GET' && method !== 'HEAD') { - state.appWrites.push({ - method, - body: init?.body ? JSON.parse(String(init.body)) : undefined, - }) - } - if (url.pathname.endsWith('/onboarding_progress')) { - const body = JSON.parse(String(init?.body)) - state.polls.push(body) - state.requests += 1 - const checkChannel = body.initial || body.N % 5 === 0 - const hasChannel = state.channels.some(channel => channel.app_id === body.appId) - const channelError = state.channelError - if (checkChannel) { - state.channelRequests += 1 - state.channelQueries.push(`eq.${body.appId}`) - if (state.channelDelayMs) - await new Promise(resolve => setTimeout(resolve, state.channelDelayMs)) - } - return new Response(JSON.stringify(state.error - ? { message: 'Progress unavailable' } - : { - onboarding: { setup: { todo_list_version: state.version, steps: state.steps, outcome: state.outcome } }, - ...(checkChannel && !channelError ? { hasChannel } : {}), - checkErrors: checkChannel && channelError ? ['add_channel'] : [], - }), { status: state.error ? 503 : 200, headers: { 'Content-Type': 'application/json' } }) - } - if (url.pathname.includes('/rpc/')) { - return new Response(JSON.stringify(state.channelPermissions), { headers: { 'Content-Type': 'application/json' } }) - } - if (url.pathname.endsWith('/channels')) { - if (init?.method === 'POST') { - const insert = JSON.parse(String(init.body)) - state.channelInserts.push(insert) - if (state.channelInsertDelayMs) - await new Promise(resolve => setTimeout(resolve, state.channelInsertDelayMs)) - if (state.channelInsertError) - return new Response(JSON.stringify({ message: 'Insert unavailable' }), { status: 400, headers: { 'Content-Type': 'application/json' } }) - state.channels.push({ id: state.channels.length + 1, ...insert }) - return new Response(null, { status: 201 }) - } - state.channelRequests += 1 - const filter = url.searchParams.get('app_id') ?? '' - state.channelQueries.push(filter) - const rows = state.channels.filter(channel => ( - (!filter || filter === `eq.${channel.app_id}`) - && (!url.searchParams.get('public') || url.searchParams.get('public') === `eq.${channel.public}`) - && (!url.searchParams.get('name') || url.searchParams.get('name') === `eq.${channel.name}`) - )).slice(0, Number(url.searchParams.get('limit') ?? state.channels.length)) - const error = state.channelError - if (state.channelDelayMs > 0) - await new Promise(resolve => setTimeout(resolve, state.channelDelayMs)) - return new Response(JSON.stringify(error ? { message: 'Channels unavailable' } : rows), { - status: error ? 503 : 200, - headers: { 'Content-Type': 'application/json' }, - }) - } - if (params.get('view') === 'flow' || navigationView) { - const app = { id: '00000000-0000-4000-8000-000000000003', app_id: previewAppId, name: 'My Capacitor app', icon_url: '', owner_org: '00000000-0000-4000-8000-000000000002', need_onboarding: true, onboarding: previewOnboarding() } - const user = { id: '00000000-0000-4000-8000-000000000001', email: 'preview@example.com', onboarding: { intent: assignment ? 'builder' : 'ota', status: 'in_progress', step: 'setup', flow: 'app', setup_stage: 'cli', app_id: previewAppId } } - const rows = url.pathname.endsWith('/apps') ? (!url.searchParams.get('owner_org') || url.searchParams.get('owner_org') === `eq.${app.owner_org}` ? [app] : []) : url.pathname.endsWith('/users') ? [user] : url.pathname.endsWith('/apikeys') ? [{ key: '00000000-0000-4000-8000-000000000004', rbac_id: '00000000-0000-4000-8000-000000000005', expires_at: null }] : url.pathname.endsWith('/role_bindings') ? [{ principal_id: '00000000-0000-4000-8000-000000000005', scope_type: 'org', roles: { name: 'org_super_admin' } }] : [] - const single = new Headers(init?.headers).get('Accept')?.includes('object') - return new Response(JSON.stringify(single ? rows[0] ?? {} : rows), { headers: { 'Content-Type': 'application/json' } }) - } - state.requests += 1 - return new Response(JSON.stringify(state.error - ? { message: 'Progress unavailable' } - : [{ - onboarding: previewOnboarding(), - }]), { - status: state.error ? 503 : 200, - headers: { 'Content-Type': 'application/json' }, - }) -} - -// Supply a fixture-only session. All fetch calls above are intercepted. -useSupabase().auth.getSession = async () => ({ data: { session: { access_token: 'fixture-token' } as any }, error: null }) - -const app = createApp(defineComponent({ - setup() { - return () => h('main', { - class: 'min-h-screen bg-slate-50 px-4 py-8 sm:px-6 lg:px-8 dark:bg-slate-950', - }, [ - h('div', { class: 'mx-auto max-w-6xl' }, [ - navigationView - ? h(RouterView) - : params.get('view') === 'flow' - ? h(AppOnboardingFlow, { onboarding: true }) - : builderComponentView - ? h(AppOnboardingBuilderChecklist, { - initialOnboarding: previewOnboarding(), - command: 'npx @capgo/cli@latest build init -a [API_KEY]', - hiding: preview.hiding.value, - leaving: false, - onCopyCommand: platform => events.push(`copy-builder-${platform}`), - onHide: () => events.push('hide'), - onExplore: () => events.push('explore'), - }) - : params.get('view') === 'compact' - ? h(AppOnboardingCliSteps, { appId: preview.appId.value }) - : h(AppOnboardingSetupChecklist, { - appId: preview.appId.value, - command: preview.command.value, - hiding: preview.hiding.value, - leaving: false, - onCopyCommand: async () => { - events.push('copy-command') - await navigator.clipboard.writeText(preview.command.value) - }, - onCopyAi: () => events.push('copy-ai'), - onHide: () => events.push('hide'), - onExplore: () => events.push('explore'), - onComplete: () => events.push('complete'), - onInviteOpened: () => events.push('invite-opened'), - onChannelAnalytics: (event: OnboardingChannelEvent, properties: OnboardingChannelEventProperties) => channelEvents.push({ event, properties }), - }), - ]), - h(DialogV2), - ]) - }, -})) -const pinia = createPinia() -app.use(pinia) -// Supply identity to the real channel form without starting dashboard store watchers. -Object.defineProperty(useMainStore(pinia), 'user', { value: { id: '00000000-0000-4000-8000-000000000001', email: 'preview@example.com', onboarding: { intent: assignment ? 'builder' : 'ota', status: 'in_progress', step: 'setup', flow: 'app', setup_stage: 'cli', app_id: previewAppId } } }) -Object.defineProperty(useMainStore(pinia), 'auth', { value: { id: '00000000-0000-4000-8000-000000000001' } }) -useMainStore(pinia).awaitInitialLoad = async () => true -const organization = useOrganizationStore(pinia) -const previewOrganization = { gid: '00000000-0000-4000-8000-000000000002' } -const selectedOrganization = ref(params.get('wrongOrg') === '1' ? { gid: '00000000-0000-4000-8000-000000000099' } : previewOrganization) -preview.selectedOrgId.value = selectedOrganization.value.gid -Object.defineProperty(organization, 'currentOrganization', { get: () => selectedOrganization.value }) -organization.getOrgByAppId = appId => appId === previewAppId ? previewOrganization as any : undefined -organization.setCurrentOrganization = (orgId) => { - if (orgId === previewOrganization.gid) - selectedOrganization.value = previewOrganization - preview.selectedOrgId.value = selectedOrganization.value.gid -} -organization.awaitInitialLoad = async () => true -organization.getAppsByOrgId = orgId => orgId === previewOrganization.gid ? [{ app_id: previewAppId, owner_org: orgId, name: 'My Capacitor app', icon_url: '', need_onboarding: true, onboarding: previewOnboarding() }] : [] -app.use(i18n) -const router = createRouter({ - history: createWebHistory(), - routes: navigationView - ? [ - { path: '/app/:app', component: defineComponent({ setup: () => () => h('section', { 'data-test': 'preview-app-dashboard' }, [h('h1', 'App dashboard'), h(GettingStartedNav), h(OnboardingExploreBanner, { appId: previewAppId }), h(OnboardingExploreReminder, { appId: previewAppId })]) }) }, - { path: '/app/:app/getting-started', name: '/app/[app].getting-started', component: defineComponent({ - setup() { - onMounted(() => events.push('getting-started-mounted')) - return () => h(GettingStartedPage) - }, - }) }, - { path: '/:pathMatch(.*)*', component: defineComponent({ setup: () => () => h(AppOnboardingFlow, { onboarding: true }) }) }, - ] - : [{ path: '/:pathMatch(.*)*', component: { render: () => null } }], -}) -installOnboardingSetupNavigation({ app, router, routes: router.options.routes }) -app.use(router) -void router.isReady().then(() => app.mount('#app')) diff --git a/playwright/visual-diff.config.ts b/playwright/visual-diff.config.ts index 6035201cde..daf25a5757 100644 --- a/playwright/visual-diff.config.ts +++ b/playwright/visual-diff.config.ts @@ -1,4 +1,5 @@ import type { Page } from '@playwright/test' +import { dismissSupportPrompt } from './support/dismissSupportPrompt' export interface VisualDiffRoute { slug: string @@ -17,29 +18,6 @@ export const visualDiffRoutes: VisualDiffRoute[] = [ { slug: 'login', path: '/login/', auth: false }, { slug: 'dashboard', path: '/dashboard', auth: true }, { slug: 'account-settings', path: '/settings/account', auth: true }, - { - slug: 'email-verification-send', - path: '/resend_email', - auth: true, - prepare: async (page) => { - await page.route('**/rest/v1/user_security?*', route => route.fulfill({ json: { email_otp_verified_at: null } })) - await page.goto('/resend_email?reason=email_not_verified&return_to=/settings/account') - await page.getByRole('button', { name: 'Send verification code', exact: true }).waitFor() - }, - }, - { - slug: 'email-verification-code', - path: '/resend_email', - auth: true, - prepare: async (page) => { - // Keep screenshots deterministic and never send a real verification email. - await page.route('**/rest/v1/user_security?*', route => route.fulfill({ json: { email_otp_verified_at: null } })) - await page.route('**/auth/v1/otp', route => route.fulfill({ json: { user: null, session: null } })) - await page.goto('/resend_email?reason=email_not_verified&return_to=/settings/account') - await page.getByRole('button', { name: 'Send verification code', exact: true }).click() - await page.getByLabel('Enter the verification code', { exact: true }).waitFor() - }, - }, { slug: 'organization-credits', path: '/settings/organization/credits', auth: true }, { slug: 'apps', path: '/apps', auth: true }, { @@ -47,6 +25,7 @@ export const visualDiffRoutes: VisualDiffRoute[] = [ path: '/apps', auth: true, prepare: async (page) => { + await dismissSupportPrompt(page) const toggle = page.locator('[data-test="sidebar-collapse-toggle"]') if (!(await toggle.count())) return @@ -65,26 +44,6 @@ export const visualDiffRoutes: VisualDiffRoute[] = [ { slug: 'app-dashboard-native', path: '/app/com.demo.app/native', auth: true }, { slug: 'app-dashboard-installs', path: '/app/com.demo.app/installs', auth: true }, { slug: 'app-dashboard-active-bundle', path: '/app/com.demo.app/active-bundle', auth: true }, - { - slug: 'onboarding-setup-v3', - path: '/apps', - auth: true, - prepare: async (page) => { - // Read-only response fixtures let both base and head render the same app. - // The base ignores version 3; the head shows the experiment treatment. - const onboarding = { setup: { todo_list_version: 3, source: 'manual', outcome: 'in_progress', steps: {} } } - await page.route('**/rest/v1/apps?*', async (route) => { - const response = await route.fetch() - const json = await response.json().catch(() => null) - const override = (row: any) => row?.app_id === 'com.demo.app' ? { ...row, need_onboarding: true, onboarding } : row - await route.fulfill({ response, json: Array.isArray(json) ? json.map(override) : override(json) }) - }) - await page.route('**/rpc/verify_getting_started', route => route.fulfill({ json: onboarding })) - await page.route('**/private/onboarding_progress', route => route.fulfill({ json: { onboarding, hasChannel: false, checkErrors: [] } })) - await page.goto('/app/new?resume=com.demo.app&step=setup') - await page.getByRole('heading', { name: /Start guided setup|Finish setup in your app/ }).waitFor() - }, - }, { slug: 'app-getting-started', path: '/app/com.demo.app/getting-started', auth: true }, { slug: 'app-settings', path: '/app/com.demo.app/settings', auth: true }, { slug: 'app-settings-access', path: '/app/com.demo.app/settings/access', auth: true }, diff --git a/read_replicate/schema_replicate.catalog.json b/read_replicate/schema_replicate.catalog.json index 365721a2d9..b7fcb969bb 100644 --- a/read_replicate/schema_replicate.catalog.json +++ b/read_replicate/schema_replicate.catalog.json @@ -2251,13 +2251,6 @@ "table": "apps", "valid": true }, - { - "constraintOwned": false, - "definition": "CREATE INDEX idx_apps_onboarding_login_creator ON public.apps USING btree (((onboarding ->> 'created_by_user_id'::text))) WHERE ((onboarding #>> '{setup,todo_list_version}'::text[]) = ANY (ARRAY['2'::text, '3'::text, '4'::text]))", - "name": "idx_apps_onboarding_login_creator", - "table": "apps", - "valid": true - }, { "constraintOwned": false, "definition": "CREATE INDEX idx_apps_onboarding_ota_stage ON public.apps USING btree (((((onboarding -> 'features'::text) -> 'ota'::text) ->> 'stage'::text)))", @@ -2267,15 +2260,15 @@ }, { "constraintOwned": false, - "definition": "CREATE INDEX idx_apps_onboarding_queued_refresh_at ON public.apps USING btree (COALESCE((onboarding ->> 'queued_refresh_at'::text), ''::text), COALESCE((onboarding ->> 'refreshed_at'::text), ''::text), app_id)", - "name": "idx_apps_onboarding_queued_refresh_at", + "definition": "CREATE INDEX idx_apps_onboarding_refreshed_at ON public.apps USING btree (COALESCE((onboarding ->> 'refreshed_at'::text), ''::text), app_id)", + "name": "idx_apps_onboarding_refreshed_at", "table": "apps", "valid": true }, { "constraintOwned": false, - "definition": "CREATE INDEX idx_apps_onboarding_refreshed_at ON public.apps USING btree (COALESCE((onboarding ->> 'refreshed_at'::text), ''::text), app_id)", - "name": "idx_apps_onboarding_refreshed_at", + "definition": "CREATE INDEX idx_apps_onboarding_v2_creator ON public.apps USING btree (((onboarding ->> 'created_by_user_id'::text))) WHERE ((onboarding #>> '{setup,todo_list_version}'::text[]) = '2'::text)", + "name": "idx_apps_onboarding_v2_creator", "table": "apps", "valid": true }, @@ -2421,8 +2414,8 @@ }, { "constraintOwned": false, - "definition": "CREATE INDEX idx_manifest_app_version_id_file_hash ON public.manifest USING btree (app_version_id, file_hash) INCLUDE (file_size)", - "name": "idx_manifest_app_version_id_file_hash", + "definition": "CREATE INDEX idx_manifest_app_version_id ON public.manifest USING btree (app_version_id)", + "name": "idx_manifest_app_version_id", "table": "manifest", "valid": true }, diff --git a/read_replicate/schema_replicate.sql b/read_replicate/schema_replicate.sql index 5606d743ac..0616329529 100644 --- a/read_replicate/schema_replicate.sql +++ b/read_replicate/schema_replicate.sql @@ -877,13 +877,6 @@ CREATE INDEX idx_apps_created_at ON public.apps USING btree (created_at); CREATE INDEX idx_apps_default_upload_channel ON public.apps USING btree (default_upload_channel); --- --- Name: idx_apps_onboarding_login_creator; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_apps_onboarding_login_creator ON public.apps USING btree (((onboarding ->> 'created_by_user_id'::text))) WHERE ((onboarding #>> '{setup,todo_list_version}'::text[]) = ANY (ARRAY['2'::text, '3'::text, '4'::text])); - - -- -- Name: idx_apps_onboarding_ota_stage; Type: INDEX; Schema: public; Owner: - -- @@ -892,17 +885,17 @@ CREATE INDEX idx_apps_onboarding_ota_stage ON public.apps USING btree (((((onboa -- --- Name: idx_apps_onboarding_queued_refresh_at; Type: INDEX; Schema: public; Owner: - +-- Name: idx_apps_onboarding_refreshed_at; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_apps_onboarding_queued_refresh_at ON public.apps USING btree (COALESCE((onboarding ->> 'queued_refresh_at'::text), ''::text), COALESCE((onboarding ->> 'refreshed_at'::text), ''::text), app_id); +CREATE INDEX idx_apps_onboarding_refreshed_at ON public.apps USING btree (COALESCE((onboarding ->> 'refreshed_at'::text), ''::text), app_id); -- --- Name: idx_apps_onboarding_refreshed_at; Type: INDEX; Schema: public; Owner: - +-- Name: idx_apps_onboarding_v2_creator; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_apps_onboarding_refreshed_at ON public.apps USING btree (COALESCE((onboarding ->> 'refreshed_at'::text), ''::text), app_id); +CREATE INDEX idx_apps_onboarding_v2_creator ON public.apps USING btree (((onboarding ->> 'created_by_user_id'::text))) WHERE ((onboarding #>> '{setup,todo_list_version}'::text[]) = '2'::text); -- @@ -955,10 +948,10 @@ CREATE INDEX idx_channels_rollout_version ON public.channels USING btree (rollou -- --- Name: idx_manifest_app_version_id_file_hash; Type: INDEX; Schema: public; Owner: - +-- Name: idx_manifest_app_version_id; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_manifest_app_version_id_file_hash ON public.manifest USING btree (app_version_id, file_hash) INCLUDE (file_size); +CREATE INDEX idx_manifest_app_version_id ON public.manifest USING btree (app_version_id); -- diff --git a/scripts/bench/manifest_size_lookup_results.json b/scripts/bench/manifest_size_lookup_results.json deleted file mode 100644 index a0d9606337..0000000000 --- a/scripts/bench/manifest_size_lookup_results.json +++ /dev/null @@ -1,209 +0,0 @@ -{ - "generatedAt": "2026-09-23T15:17:00.777Z", - "counts": { - "manifest_rows": 960000, - "versions": 480 - }, - "settings": [ - { - "name": "jit", - "setting": "off", - "unit": null - }, - { - "name": "server_version", - "setting": "17.11", - "unit": null - }, - { - "name": "shared_buffers", - "setting": "65536", - "unit": "8kB" - }, - { - "name": "work_mem", - "setting": "32768", - "unit": "kB" - } - ], - "reports": [ - { - "name": "fallback version id, 2000 hashes", - "side": "before", - "timing": { - "rows": 2000, - "timesMs": [ - 164.4, - 166.5, - 165.7, - 165.7, - 164.8 - ], - "medianMs": 165.7, - "minMs": 164.4, - "maxMs": 166.5 - }, - "skipped": null, - "explain": "GroupAggregate (cost=2556.39..2556.73 rows=17 width=48) (actual time=314.072..314.337 rows=2000 loops=1)\n Group Key: request_files.file_hash, app_versions.id\n Buffers: shared hit=46002\n -> Sort (cost=2556.39..2556.43 rows=17 width=48) (actual time=314.067..314.111 rows=2000 loops=1)\n Sort Key: request_files.file_hash, app_versions.id\n Sort Method: quicksort Memory: 141kB\n Buffers: shared hit=46002\n -> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.252..313.311 rows=2000 loops=1)\n Join Filter: (manifest.file_hash = request_files.file_hash)\n Rows Removed by Join Filter: 3998000\n Buffers: shared hit=46002\n -> Nested Loop (cost=4.77..150.97 rows=17 width=40) (actual time=0.246..9.374 rows=2000 loops=1)\n Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR ((request_files.version_id IS NULL) AND (app_versions.id = '1'::bigint)))\n Rows Removed by Join Filter: 158000\n Buffers: shared hit=2\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.237..0.410 rows=2000 loops=1)\n Filter: (file_hash IS NOT NULL)\n -> Materialize (cost=4.77..10.17 rows=80 width=8) (actual time=0.000..0.002 rows=80 loops=2000)\n Buffers: shared hit=2\n -> Bitmap Heap Scan on app_versions (cost=4.77..9.77 rows=80 width=8) (actual time=0.006..0.010 rows=80 loops=1)\n Recheck Cond: (app_id = 'com.bench.size'::text)\n Filter: (NOT deleted)\n Heap Blocks: exact=1\n Buffers: shared hit=2\n -> Bitmap Index Scan on idx_app_id_app_versions (cost=0.00..4.75 rows=80 width=0) (actual time=0.003..0.003 rows=80 loops=1)\n Index Cond: (app_id = 'com.bench.size'::text)\n Buffers: shared hit=1\n -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.082 rows=2000 loops=2000)\n Index Cond: (app_version_id = app_versions.id)\n Buffers: shared hit=46000\nPlanning:\n Buffers: shared hit=14\nPlanning Time: 0.192 ms\nExecution Time: 314.423 ms" - }, - { - "name": "fallback version id, 100 hashes", - "side": "before", - "timing": { - "rows": 100, - "timesMs": [ - 8.6, - 8.6, - 8.5, - 8.5, - 8.6 - ], - "medianMs": 8.6, - "minMs": 8.5, - "maxMs": 8.6 - }, - "skipped": null, - "explain": "GroupAggregate (cost=2556.39..2556.73 rows=17 width=48) (actual time=15.743..15.759 rows=100 loops=1)\n Group Key: request_files.file_hash, app_versions.id\n Buffers: shared hit=2302\n -> Sort (cost=2556.39..2556.43 rows=17 width=48) (actual time=15.742..15.745 rows=100 loops=1)\n Sort Key: request_files.file_hash, app_versions.id\n Sort Method: quicksort Memory: 28kB\n Buffers: shared hit=2302\n -> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.028..15.711 rows=100 loops=1)\n Join Filter: (manifest.file_hash = request_files.file_hash)\n Rows Removed by Join Filter: 199900\n Buffers: shared hit=2302\n -> Nested Loop (cost=4.77..150.97 rows=17 width=40) (actual time=0.022..0.486 rows=100 loops=1)\n Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR ((request_files.version_id IS NULL) AND (app_versions.id = '1'::bigint)))\n Rows Removed by Join Filter: 7900\n Buffers: shared hit=2\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.015..0.023 rows=100 loops=1)\n Filter: (file_hash IS NOT NULL)\n -> Materialize (cost=4.77..10.17 rows=80 width=8) (actual time=0.000..0.002 rows=80 loops=100)\n Buffers: shared hit=2\n -> Bitmap Heap Scan on app_versions (cost=4.77..9.77 rows=80 width=8) (actual time=0.004..0.007 rows=80 loops=1)\n Recheck Cond: (app_id = 'com.bench.size'::text)\n Filter: (NOT deleted)\n Heap Blocks: exact=1\n Buffers: shared hit=2\n -> Bitmap Index Scan on idx_app_id_app_versions (cost=0.00..4.75 rows=80 width=0) (actual time=0.001..0.002 rows=80 loops=1)\n Index Cond: (app_id = 'com.bench.size'::text)\n Buffers: shared hit=1\n -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.083 rows=2000 loops=100)\n Index Cond: (app_version_id = app_versions.id)\n Buffers: shared hit=2300\nPlanning:\n Buffers: shared hit=14\nPlanning Time: 0.126 ms\nExecution Time: 15.790 ms" - }, - { - "name": "fallback version name, 2000 hashes", - "side": "before", - "timing": { - "rows": 2000, - "timesMs": [ - 165.1, - 165.2, - 164.6, - 166.4, - 168.6 - ], - "medianMs": 165.2, - "minMs": 164.6, - "maxMs": 168.6 - }, - "skipped": null, - "explain": "GroupAggregate (cost=2556.39..2556.73 rows=17 width=48) (actual time=313.639..313.914 rows=2000 loops=1)\n Group Key: request_files.file_hash, app_versions.id\n Buffers: shared hit=46002\n -> Sort (cost=2556.39..2556.43 rows=17 width=48) (actual time=313.635..313.680 rows=2000 loops=1)\n Sort Key: request_files.file_hash, app_versions.id\n Sort Method: quicksort Memory: 141kB\n Buffers: shared hit=46002\n -> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.260..312.901 rows=2000 loops=1)\n Join Filter: (manifest.file_hash = request_files.file_hash)\n Rows Removed by Join Filter: 3998000\n Buffers: shared hit=46002\n -> Nested Loop (cost=4.77..150.97 rows=17 width=40) (actual time=0.254..9.946 rows=2000 loops=1)\n Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR ((request_files.version_id IS NULL) AND (app_versions.name = '1'::text)))\n Rows Removed by Join Filter: 158000\n Buffers: shared hit=2\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.242..0.397 rows=2000 loops=1)\n Filter: (file_hash IS NOT NULL)\n -> Materialize (cost=4.77..10.17 rows=80 width=11) (actual time=0.000..0.002 rows=80 loops=2000)\n Buffers: shared hit=2\n -> Bitmap Heap Scan on app_versions (cost=4.77..9.77 rows=80 width=11) (actual time=0.008..0.012 rows=80 loops=1)\n Recheck Cond: (app_id = 'com.bench.size'::text)\n Filter: (NOT deleted)\n Heap Blocks: exact=1\n Buffers: shared hit=2\n -> Bitmap Index Scan on idx_app_id_app_versions (cost=0.00..4.75 rows=80 width=0) (actual time=0.004..0.004 rows=80 loops=1)\n Index Cond: (app_id = 'com.bench.size'::text)\n Buffers: shared hit=1\n -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.081 rows=2000 loops=2000)\n Index Cond: (app_version_id = app_versions.id)\n Buffers: shared hit=46000\nPlanning:\n Buffers: shared hit=14\nPlanning Time: 0.210 ms\nExecution Time: 314.009 ms" - }, - { - "name": "per-file version id, 2000 hashes", - "side": "before", - "timing": { - "rows": 2000, - "timesMs": [ - 165.2, - 167.9, - 168.5, - 164.7, - 163.9 - ], - "medianMs": 165.2, - "minMs": 163.9, - "maxMs": 168.5 - }, - "skipped": null, - "explain": "GroupAggregate (cost=8277.41..8278.55 rows=57 width=48) (actual time=313.753..314.020 rows=2000 loops=1)\n Group Key: request_files.file_hash, app_versions.id\n Buffers: shared hit=46007\n -> Sort (cost=8277.41..8277.55 rows=57 width=48) (actual time=313.748..313.790 rows=2000 loops=1)\n Sort Key: request_files.file_hash, app_versions.id\n Sort Method: quicksort Memory: 141kB\n Buffers: shared hit=46007\n -> Nested Loop (cost=0.70..8275.75 rows=57 width=48) (actual time=0.278..312.968 rows=2000 loops=1)\n Join Filter: (manifest.file_hash = request_files.file_hash)\n Rows Removed by Join Filter: 3998000\n Buffers: shared hit=46007\n -> Nested Loop (cost=0.28..211.68 rows=57 width=40) (actual time=0.272..10.262 rows=2000 loops=1)\n Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR (request_files.version_id IS NULL))\n Rows Removed by Join Filter: 158000\n Buffers: shared hit=7\n -> Index Scan using app_versions_pkey on app_versions (cost=0.27..31.67 rows=80 width=8) (actual time=0.005..0.035 rows=80 loops=1)\n Filter: ((NOT deleted) AND (app_id = 'com.bench.size'::text))\n Rows Removed by Filter: 400\n Buffers: shared hit=7\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.003..0.062 rows=2000 loops=80)\n Filter: (file_hash IS NOT NULL)\n -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.081 rows=2000 loops=2000)\n Index Cond: (app_version_id = app_versions.id)\n Buffers: shared hit=46000\nPlanning:\n Buffers: shared hit=14\nPlanning Time: 0.174 ms\nExecution Time: 314.111 ms" - }, - { - "name": "no version, 2000 hashes", - "side": "before", - "timing": { - "rows": 160000, - "timesMs": [ - 12804, - 12741.6, - 12659.9, - 12751, - 12845.7 - ], - "medianMs": 12751, - "minMs": 12659.9, - "maxMs": 12845.7 - }, - "skipped": null, - "explain": "GroupAggregate (cost=8277.41..8278.55 rows=57 width=48) (actual time=24584.193..24604.666 rows=160000 loops=1)\n Group Key: request_files.file_hash, app_versions.id\n Buffers: shared hit=3898007\n -> Sort (cost=8277.41..8277.55 rows=57 width=48) (actual time=24584.186..24587.946 rows=160000 loops=1)\n Sort Key: request_files.file_hash, app_versions.id\n Sort Method: quicksort Memory: 13583kB\n Buffers: shared hit=3898007\n -> Nested Loop (cost=0.70..8275.75 rows=57 width=48) (actual time=0.274..24501.270 rows=160000 loops=1)\n Join Filter: (manifest.file_hash = request_files.file_hash)\n Rows Removed by Join Filter: 319840000\n Buffers: shared hit=3898007\n -> Nested Loop (cost=0.28..211.68 rows=57 width=40) (actual time=0.266..29.543 rows=160000 loops=1)\n Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR (request_files.version_id IS NULL))\n Buffers: shared hit=7\n -> Index Scan using app_versions_pkey on app_versions (cost=0.27..31.67 rows=80 width=8) (actual time=0.007..0.175 rows=80 loops=1)\n Filter: ((NOT deleted) AND (app_id = 'com.bench.size'::text))\n Rows Removed by Filter: 400\n Buffers: shared hit=7\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.003..0.168 rows=2000 loops=80)\n Filter: (file_hash IS NOT NULL)\n -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.082 rows=2000 loops=160000)\n Index Cond: (app_version_id = app_versions.id)\n Buffers: shared hit=3898000\nPlanning:\n Buffers: shared hit=14\nPlanning Time: 0.223 ms\nExecution Time: 24608.058 ms" - }, - { - "name": "fallback version id, 2000 hashes", - "side": "after", - "timing": { - "rows": 2000, - "timesMs": [ - 4.9, - 5, - 5, - 5, - 5.3 - ], - "medianMs": 5, - "minMs": 4.9, - "maxMs": 5.3 - }, - "skipped": null, - "explain": "GroupAggregate (cost=17.27..17.29 rows=1 width=48) (actual time=3.927..4.191 rows=2000 loops=1)\n Group Key: r.file_hash\n Buffers: shared hit=12001\n CTE requested\n -> HashAggregate (cost=1.50..2.50 rows=100 width=40) (actual time=0.491..0.593 rows=2000 loops=1)\n Group Key: request_files.file_hash, request_files.version_id\n Batches: 1 Memory Usage: 257kB\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.218..0.286 rows=2000 loops=1)\n Filter: (file_hash IS NOT NULL)\n -> Sort (cost=14.77..14.77 rows=1 width=48) (actual time=3.926..3.969 rows=2000 loops=1)\n Sort Key: r.file_hash\n Sort Method: quicksort Memory: 141kB\n Buffers: shared hit=12001\n -> Nested Loop (cost=0.70..14.76 rows=1 width=48) (actual time=0.503..3.211 rows=2000 loops=1)\n Buffers: shared hit=12001\n -> Nested Loop (cost=0.42..6.45 rows=1 width=48) (actual time=0.499..2.374 rows=2000 loops=1)\n Buffers: shared hit=6001\n -> CTE Scan on requested r (cost=0.00..2.00 rows=1 width=32) (actual time=0.491..0.770 rows=2000 loops=1)\n Filter: (version_id IS NULL)\n -> Index Only Scan using idx_manifest_app_version_id_file_hash on manifest m (cost=0.42..4.44 rows=1 width=25) (actual time=0.001..0.001 rows=1 loops=2000)\n Index Cond: ((app_version_id = '1'::bigint) AND (file_hash = r.file_hash))\n Heap Fetches: 0\n Buffers: shared hit=6001\n -> Index Scan using app_versions_pkey on app_versions av (cost=0.27..8.29 rows=1 width=8) (actual time=0.000..0.000 rows=1 loops=2000)\n Index Cond: (id = '1'::bigint)\n Filter: ((NOT deleted) AND (app_id = 'com.bench.size'::text))\n Buffers: shared hit=6000\nPlanning Time: 0.081 ms\nExecution Time: 4.275 ms" - }, - { - "name": "fallback version id, 100 hashes", - "side": "after", - "timing": { - "rows": 100, - "timesMs": [ - 0.5, - 0.5, - 0.6, - 0.5, - 0.5 - ], - "medianMs": 0.5, - "minMs": 0.5, - "maxMs": 0.6 - }, - "skipped": null, - "explain": "GroupAggregate (cost=17.27..17.29 rows=1 width=48) (actual time=0.181..0.195 rows=100 loops=1)\n Group Key: r.file_hash\n Buffers: shared hit=601\n CTE requested\n -> HashAggregate (cost=1.50..2.50 rows=100 width=40) (actual time=0.025..0.029 rows=100 loops=1)\n Group Key: request_files.file_hash, request_files.version_id\n Batches: 1 Memory Usage: 24kB\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.012..0.017 rows=100 loops=1)\n Filter: (file_hash IS NOT NULL)\n -> Sort (cost=14.77..14.77 rows=1 width=48) (actual time=0.181..0.183 rows=100 loops=1)\n Sort Key: r.file_hash\n Sort Method: quicksort Memory: 28kB\n Buffers: shared hit=601\n -> Nested Loop (cost=0.70..14.76 rows=1 width=48) (actual time=0.033..0.159 rows=100 loops=1)\n Buffers: shared hit=601\n -> Nested Loop (cost=0.42..6.45 rows=1 width=48) (actual time=0.030..0.115 rows=100 loops=1)\n Buffers: shared hit=301\n -> CTE Scan on requested r (cost=0.00..2.00 rows=1 width=32) (actual time=0.025..0.039 rows=100 loops=1)\n Filter: (version_id IS NULL)\n -> Index Only Scan using idx_manifest_app_version_id_file_hash on manifest m (cost=0.42..4.44 rows=1 width=25) (actual time=0.001..0.001 rows=1 loops=100)\n Index Cond: ((app_version_id = '1'::bigint) AND (file_hash = r.file_hash))\n Heap Fetches: 0\n Buffers: shared hit=301\n -> Index Scan using app_versions_pkey on app_versions av (cost=0.27..8.29 rows=1 width=8) (actual time=0.000..0.000 rows=1 loops=100)\n Index Cond: (id = '1'::bigint)\n Filter: ((NOT deleted) AND (app_id = 'com.bench.size'::text))\n Buffers: shared hit=300\nPlanning Time: 0.043 ms\nExecution Time: 0.218 ms" - }, - { - "name": "fallback version name, 2000 hashes", - "side": "after", - "timing": { - "rows": 2000, - "timesMs": [ - 4.6, - 4.2, - 5.2, - 4.5, - 4.3 - ], - "medianMs": 4.5, - "minMs": 4.2, - "maxMs": 5.2 - }, - "skipped": null, - "explain": "GroupAggregate (cost=17.27..17.29 rows=1 width=48) (actual time=3.176..3.452 rows=2000 loops=1)\n Group Key: r.file_hash, av.id\n Buffers: shared hit=6004\n CTE requested\n -> HashAggregate (cost=1.50..2.50 rows=100 width=40) (actual time=0.500..0.608 rows=2000 loops=1)\n Group Key: request_files.file_hash, request_files.version_id\n Batches: 1 Memory Usage: 257kB\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.210..0.282 rows=2000 loops=1)\n Filter: (file_hash IS NOT NULL)\n -> Sort (cost=14.77..14.77 rows=1 width=48) (actual time=3.174..3.218 rows=2000 loops=1)\n Sort Key: r.file_hash, av.id\n Sort Method: quicksort Memory: 141kB\n Buffers: shared hit=6004\n -> Nested Loop (cost=0.70..14.76 rows=1 width=48) (actual time=0.511..2.460 rows=2000 loops=1)\n Buffers: shared hit=6004\n -> Index Scan using idx_app_id_name_app_versions on app_versions av (cost=0.27..8.29 rows=1 width=8) (actual time=0.005..0.006 rows=1 loops=1)\n Index Cond: ((app_id = 'com.bench.size'::text) AND (name = '1'::text))\n Filter: (NOT deleted)\n Buffers: shared hit=3\n -> Nested Loop (cost=0.42..6.45 rows=1 width=48) (actual time=0.505..2.361 rows=2000 loops=1)\n Buffers: shared hit=6001\n -> CTE Scan on requested r (cost=0.00..2.00 rows=1 width=32) (actual time=0.500..0.789 rows=2000 loops=1)\n Filter: (version_id IS NULL)\n -> Index Only Scan using idx_manifest_app_version_id_file_hash on manifest m (cost=0.42..4.44 rows=1 width=25) (actual time=0.001..0.001 rows=1 loops=2000)\n Index Cond: ((app_version_id = av.id) AND (file_hash = r.file_hash))\n Heap Fetches: 0\n Buffers: shared hit=6001\nPlanning:\n Buffers: shared hit=14\nPlanning Time: 0.161 ms\nExecution Time: 3.564 ms" - }, - { - "name": "per-file version id, 2000 hashes", - "side": "after", - "timing": { - "rows": 2000, - "timesMs": [ - 4.6, - 5.1, - 4.5, - 4.3, - 4.5 - ], - "medianMs": 4.5, - "minMs": 4.3, - "maxMs": 5.1 - }, - "skipped": null, - "explain": "GroupAggregate (cost=91.66..92.00 rows=17 width=48) (actual time=3.213..3.487 rows=2000 loops=1)\n Group Key: r.file_hash, av.id\n Buffers: shared hit=6003\n CTE requested\n -> HashAggregate (cost=1.50..2.50 rows=100 width=40) (actual time=0.551..0.658 rows=2000 loops=1)\n Group Key: request_files.file_hash, request_files.version_id\n Batches: 1 Memory Usage: 385kB\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.265..0.336 rows=2000 loops=1)\n Filter: (file_hash IS NOT NULL)\n -> Sort (cost=89.15..89.20 rows=17 width=48) (actual time=3.211..3.259 rows=2000 loops=1)\n Sort Key: r.file_hash, av.id\n Sort Method: quicksort Memory: 141kB\n Buffers: shared hit=6003\n -> Nested Loop (cost=11.19..88.81 rows=17 width=48) (actual time=0.573..2.553 rows=2000 loops=1)\n Join Filter: (av.id = m.app_version_id)\n Buffers: shared hit=6003\n -> Hash Join (cost=10.77..13.03 rows=17 width=48) (actual time=0.567..0.996 rows=2000 loops=1)\n Hash Cond: (r.version_id = av.id)\n Buffers: shared hit=2\n -> CTE Scan on requested r (cost=0.00..2.00 rows=100 width=40) (actual time=0.552..0.851 rows=2000 loops=1)\n Filter: (version_id IS NOT NULL)\n -> Hash (cost=9.77..9.77 rows=80 width=8) (actual time=0.013..0.013 rows=80 loops=1)\n Buckets: 1024 Batches: 1 Memory Usage: 12kB\n Buffers: shared hit=2\n -> Bitmap Heap Scan on app_versions av (cost=4.77..9.77 rows=80 width=8) (actual time=0.005..0.008 rows=80 loops=1)\n Recheck Cond: (app_id = 'com.bench.size'::text)\n Filter: (NOT deleted)\n Heap Blocks: exact=1\n Buffers: shared hit=2\n -> Bitmap Index Scan on idx_app_id_app_versions (cost=0.00..4.75 rows=80 width=0) (actual time=0.002..0.002 rows=80 loops=1)\n Index Cond: (app_id = 'com.bench.size'::text)\n Buffers: shared hit=1\n -> Index Only Scan using idx_manifest_app_version_id_file_hash on manifest m (cost=0.42..4.44 rows=1 width=25) (actual time=0.001..0.001 rows=1 loops=2000)\n Index Cond: ((app_version_id = r.version_id) AND (file_hash = r.file_hash))\n Heap Fetches: 0\n Buffers: shared hit=6001\nPlanning:\n Buffers: shared hit=14\nPlanning Time: 0.166 ms\nExecution Time: 3.603 ms" - }, - { - "name": "no version, 2000 hashes", - "side": "after", - "timing": null, - "skipped": "handler returns size_unknown and does not query", - "explain": null - } - ] -} diff --git a/scripts/bench/manifest_size_lookup_summary.md b/scripts/bench/manifest_size_lookup_summary.md deleted file mode 100644 index c7d080c3ef..0000000000 --- a/scripts/bench/manifest_size_lookup_summary.md +++ /dev/null @@ -1,384 +0,0 @@ -# Manifest size lookup before / after - -Local Postgres 17.11. Not production. - -Seed: 80 versions of `com.bench.size` and 400 versions of `com.bench.noise`, 2000 identical hashes per version (960,000 manifest rows). Same hash is repeated on every version so `idx_manifest_file_hash` is not selective. - -Before uses the old `OR` join and `idx_manifest_app_version_id` plus `idx_manifest_file_hash`. After uses the split lookup and `idx_manifest_app_version_id_file_hash (app_version_id, file_hash) INCLUDE (file_size)`. `idx_manifest_file_hash` stays. - -Timings are the median of 5 hot-cache client round trips after one warmup. `jit` is off. `statement_timeout` is 120s. - -| Scenario | Before median | After median | Before rows | After rows | -| --- | ---: | ---: | ---: | ---: | -| fallback version id, 2000 hashes | 165.7 ms | 5 ms | 2000 | 2000 | -| fallback version id, 100 hashes | 8.6 ms | 0.5 ms | 100 | 100 | -| fallback version name, 2000 hashes | 165.2 ms | 4.5 ms | 2000 | 2000 | -| per-file version id, 2000 hashes | 165.2 ms | 4.5 ms | 2000 | 2000 | -| no version, 2000 hashes | 12751 ms | no query | 160000 | 0 | - -## Plans - -### before — fallback version id, 2000 hashes - --> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.252..313.311 rows=2000 loops=1) - -``` -GroupAggregate (cost=2556.39..2556.73 rows=17 width=48) (actual time=314.072..314.337 rows=2000 loops=1) - Group Key: request_files.file_hash, app_versions.id - Buffers: shared hit=46002 - -> Sort (cost=2556.39..2556.43 rows=17 width=48) (actual time=314.067..314.111 rows=2000 loops=1) - Sort Key: request_files.file_hash, app_versions.id - Sort Method: quicksort Memory: 141kB - Buffers: shared hit=46002 - -> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.252..313.311 rows=2000 loops=1) - Join Filter: (manifest.file_hash = request_files.file_hash) - Rows Removed by Join Filter: 3998000 - Buffers: shared hit=46002 - -> Nested Loop (cost=4.77..150.97 rows=17 width=40) (actual time=0.246..9.374 rows=2000 loops=1) - Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR ((request_files.version_id IS NULL) AND (app_versions.id = '1'::bigint))) - Rows Removed by Join Filter: 158000 - Buffers: shared hit=2 - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.237..0.410 rows=2000 loops=1) - Filter: (file_hash IS NOT NULL) - -> Materialize (cost=4.77..10.17 rows=80 width=8) (actual time=0.000..0.002 rows=80 loops=2000) - Buffers: shared hit=2 - -> Bitmap Heap Scan on app_versions (cost=4.77..9.77 rows=80 width=8) (actual time=0.006..0.010 rows=80 loops=1) - Recheck Cond: (app_id = 'com.bench.size'::text) - Filter: (NOT deleted) - Heap Blocks: exact=1 - Buffers: shared hit=2 - -> Bitmap Index Scan on idx_app_id_app_versions (cost=0.00..4.75 rows=80 width=0) (actual time=0.003..0.003 rows=80 loops=1) - Index Cond: (app_id = 'com.bench.size'::text) - Buffers: shared hit=1 - -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.082 rows=2000 loops=2000) - Index Cond: (app_version_id = app_versions.id) - Buffers: shared hit=46000 -Planning: - Buffers: shared hit=14 -Planning Time: 0.192 ms -Execution Time: 314.423 ms -``` - -### before — fallback version id, 100 hashes - --> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.028..15.711 rows=100 loops=1) - -``` -GroupAggregate (cost=2556.39..2556.73 rows=17 width=48) (actual time=15.743..15.759 rows=100 loops=1) - Group Key: request_files.file_hash, app_versions.id - Buffers: shared hit=2302 - -> Sort (cost=2556.39..2556.43 rows=17 width=48) (actual time=15.742..15.745 rows=100 loops=1) - Sort Key: request_files.file_hash, app_versions.id - Sort Method: quicksort Memory: 28kB - Buffers: shared hit=2302 - -> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.028..15.711 rows=100 loops=1) - Join Filter: (manifest.file_hash = request_files.file_hash) - Rows Removed by Join Filter: 199900 - Buffers: shared hit=2302 - -> Nested Loop (cost=4.77..150.97 rows=17 width=40) (actual time=0.022..0.486 rows=100 loops=1) - Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR ((request_files.version_id IS NULL) AND (app_versions.id = '1'::bigint))) - Rows Removed by Join Filter: 7900 - Buffers: shared hit=2 - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.015..0.023 rows=100 loops=1) - Filter: (file_hash IS NOT NULL) - -> Materialize (cost=4.77..10.17 rows=80 width=8) (actual time=0.000..0.002 rows=80 loops=100) - Buffers: shared hit=2 - -> Bitmap Heap Scan on app_versions (cost=4.77..9.77 rows=80 width=8) (actual time=0.004..0.007 rows=80 loops=1) - Recheck Cond: (app_id = 'com.bench.size'::text) - Filter: (NOT deleted) - Heap Blocks: exact=1 - Buffers: shared hit=2 - -> Bitmap Index Scan on idx_app_id_app_versions (cost=0.00..4.75 rows=80 width=0) (actual time=0.001..0.002 rows=80 loops=1) - Index Cond: (app_id = 'com.bench.size'::text) - Buffers: shared hit=1 - -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.083 rows=2000 loops=100) - Index Cond: (app_version_id = app_versions.id) - Buffers: shared hit=2300 -Planning: - Buffers: shared hit=14 -Planning Time: 0.126 ms -Execution Time: 15.790 ms -``` - -### before — fallback version name, 2000 hashes - --> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.260..312.901 rows=2000 loops=1) - -``` -GroupAggregate (cost=2556.39..2556.73 rows=17 width=48) (actual time=313.639..313.914 rows=2000 loops=1) - Group Key: request_files.file_hash, app_versions.id - Buffers: shared hit=46002 - -> Sort (cost=2556.39..2556.43 rows=17 width=48) (actual time=313.635..313.680 rows=2000 loops=1) - Sort Key: request_files.file_hash, app_versions.id - Sort Method: quicksort Memory: 141kB - Buffers: shared hit=46002 - -> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.260..312.901 rows=2000 loops=1) - Join Filter: (manifest.file_hash = request_files.file_hash) - Rows Removed by Join Filter: 3998000 - Buffers: shared hit=46002 - -> Nested Loop (cost=4.77..150.97 rows=17 width=40) (actual time=0.254..9.946 rows=2000 loops=1) - Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR ((request_files.version_id IS NULL) AND (app_versions.name = '1'::text))) - Rows Removed by Join Filter: 158000 - Buffers: shared hit=2 - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.242..0.397 rows=2000 loops=1) - Filter: (file_hash IS NOT NULL) - -> Materialize (cost=4.77..10.17 rows=80 width=11) (actual time=0.000..0.002 rows=80 loops=2000) - Buffers: shared hit=2 - -> Bitmap Heap Scan on app_versions (cost=4.77..9.77 rows=80 width=11) (actual time=0.008..0.012 rows=80 loops=1) - Recheck Cond: (app_id = 'com.bench.size'::text) - Filter: (NOT deleted) - Heap Blocks: exact=1 - Buffers: shared hit=2 - -> Bitmap Index Scan on idx_app_id_app_versions (cost=0.00..4.75 rows=80 width=0) (actual time=0.004..0.004 rows=80 loops=1) - Index Cond: (app_id = 'com.bench.size'::text) - Buffers: shared hit=1 - -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.081 rows=2000 loops=2000) - Index Cond: (app_version_id = app_versions.id) - Buffers: shared hit=46000 -Planning: - Buffers: shared hit=14 -Planning Time: 0.210 ms -Execution Time: 314.009 ms -``` - -### before — per-file version id, 2000 hashes - --> Nested Loop (cost=0.70..8275.75 rows=57 width=48) (actual time=0.278..312.968 rows=2000 loops=1) - -``` -GroupAggregate (cost=8277.41..8278.55 rows=57 width=48) (actual time=313.753..314.020 rows=2000 loops=1) - Group Key: request_files.file_hash, app_versions.id - Buffers: shared hit=46007 - -> Sort (cost=8277.41..8277.55 rows=57 width=48) (actual time=313.748..313.790 rows=2000 loops=1) - Sort Key: request_files.file_hash, app_versions.id - Sort Method: quicksort Memory: 141kB - Buffers: shared hit=46007 - -> Nested Loop (cost=0.70..8275.75 rows=57 width=48) (actual time=0.278..312.968 rows=2000 loops=1) - Join Filter: (manifest.file_hash = request_files.file_hash) - Rows Removed by Join Filter: 3998000 - Buffers: shared hit=46007 - -> Nested Loop (cost=0.28..211.68 rows=57 width=40) (actual time=0.272..10.262 rows=2000 loops=1) - Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR (request_files.version_id IS NULL)) - Rows Removed by Join Filter: 158000 - Buffers: shared hit=7 - -> Index Scan using app_versions_pkey on app_versions (cost=0.27..31.67 rows=80 width=8) (actual time=0.005..0.035 rows=80 loops=1) - Filter: ((NOT deleted) AND (app_id = 'com.bench.size'::text)) - Rows Removed by Filter: 400 - Buffers: shared hit=7 - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.003..0.062 rows=2000 loops=80) - Filter: (file_hash IS NOT NULL) - -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.081 rows=2000 loops=2000) - Index Cond: (app_version_id = app_versions.id) - Buffers: shared hit=46000 -Planning: - Buffers: shared hit=14 -Planning Time: 0.174 ms -Execution Time: 314.111 ms -``` - -### before — no version, 2000 hashes - --> Nested Loop (cost=0.70..8275.75 rows=57 width=48) (actual time=0.274..24501.270 rows=160000 loops=1) - -``` -GroupAggregate (cost=8277.41..8278.55 rows=57 width=48) (actual time=24584.193..24604.666 rows=160000 loops=1) - Group Key: request_files.file_hash, app_versions.id - Buffers: shared hit=3898007 - -> Sort (cost=8277.41..8277.55 rows=57 width=48) (actual time=24584.186..24587.946 rows=160000 loops=1) - Sort Key: request_files.file_hash, app_versions.id - Sort Method: quicksort Memory: 13583kB - Buffers: shared hit=3898007 - -> Nested Loop (cost=0.70..8275.75 rows=57 width=48) (actual time=0.274..24501.270 rows=160000 loops=1) - Join Filter: (manifest.file_hash = request_files.file_hash) - Rows Removed by Join Filter: 319840000 - Buffers: shared hit=3898007 - -> Nested Loop (cost=0.28..211.68 rows=57 width=40) (actual time=0.266..29.543 rows=160000 loops=1) - Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR (request_files.version_id IS NULL)) - Buffers: shared hit=7 - -> Index Scan using app_versions_pkey on app_versions (cost=0.27..31.67 rows=80 width=8) (actual time=0.007..0.175 rows=80 loops=1) - Filter: ((NOT deleted) AND (app_id = 'com.bench.size'::text)) - Rows Removed by Filter: 400 - Buffers: shared hit=7 - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.003..0.168 rows=2000 loops=80) - Filter: (file_hash IS NOT NULL) - -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.082 rows=2000 loops=160000) - Index Cond: (app_version_id = app_versions.id) - Buffers: shared hit=3898000 -Planning: - Buffers: shared hit=14 -Planning Time: 0.223 ms -Execution Time: 24608.058 ms -``` - -### after — fallback version id, 2000 hashes - --> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.218..0.286 rows=2000 loops=1) - -``` -GroupAggregate (cost=17.27..17.29 rows=1 width=48) (actual time=3.927..4.191 rows=2000 loops=1) - Group Key: r.file_hash - Buffers: shared hit=12001 - CTE requested - -> HashAggregate (cost=1.50..2.50 rows=100 width=40) (actual time=0.491..0.593 rows=2000 loops=1) - Group Key: request_files.file_hash, request_files.version_id - Batches: 1 Memory Usage: 257kB - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.218..0.286 rows=2000 loops=1) - Filter: (file_hash IS NOT NULL) - -> Sort (cost=14.77..14.77 rows=1 width=48) (actual time=3.926..3.969 rows=2000 loops=1) - Sort Key: r.file_hash - Sort Method: quicksort Memory: 141kB - Buffers: shared hit=12001 - -> Nested Loop (cost=0.70..14.76 rows=1 width=48) (actual time=0.503..3.211 rows=2000 loops=1) - Buffers: shared hit=12001 - -> Nested Loop (cost=0.42..6.45 rows=1 width=48) (actual time=0.499..2.374 rows=2000 loops=1) - Buffers: shared hit=6001 - -> CTE Scan on requested r (cost=0.00..2.00 rows=1 width=32) (actual time=0.491..0.770 rows=2000 loops=1) - Filter: (version_id IS NULL) - -> Index Only Scan using idx_manifest_app_version_id_file_hash on manifest m (cost=0.42..4.44 rows=1 width=25) (actual time=0.001..0.001 rows=1 loops=2000) - Index Cond: ((app_version_id = '1'::bigint) AND (file_hash = r.file_hash)) - Heap Fetches: 0 - Buffers: shared hit=6001 - -> Index Scan using app_versions_pkey on app_versions av (cost=0.27..8.29 rows=1 width=8) (actual time=0.000..0.000 rows=1 loops=2000) - Index Cond: (id = '1'::bigint) - Filter: ((NOT deleted) AND (app_id = 'com.bench.size'::text)) - Buffers: shared hit=6000 -Planning Time: 0.081 ms -Execution Time: 4.275 ms -``` - -### after — fallback version id, 100 hashes - --> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.012..0.017 rows=100 loops=1) - -``` -GroupAggregate (cost=17.27..17.29 rows=1 width=48) (actual time=0.181..0.195 rows=100 loops=1) - Group Key: r.file_hash - Buffers: shared hit=601 - CTE requested - -> HashAggregate (cost=1.50..2.50 rows=100 width=40) (actual time=0.025..0.029 rows=100 loops=1) - Group Key: request_files.file_hash, request_files.version_id - Batches: 1 Memory Usage: 24kB - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.012..0.017 rows=100 loops=1) - Filter: (file_hash IS NOT NULL) - -> Sort (cost=14.77..14.77 rows=1 width=48) (actual time=0.181..0.183 rows=100 loops=1) - Sort Key: r.file_hash - Sort Method: quicksort Memory: 28kB - Buffers: shared hit=601 - -> Nested Loop (cost=0.70..14.76 rows=1 width=48) (actual time=0.033..0.159 rows=100 loops=1) - Buffers: shared hit=601 - -> Nested Loop (cost=0.42..6.45 rows=1 width=48) (actual time=0.030..0.115 rows=100 loops=1) - Buffers: shared hit=301 - -> CTE Scan on requested r (cost=0.00..2.00 rows=1 width=32) (actual time=0.025..0.039 rows=100 loops=1) - Filter: (version_id IS NULL) - -> Index Only Scan using idx_manifest_app_version_id_file_hash on manifest m (cost=0.42..4.44 rows=1 width=25) (actual time=0.001..0.001 rows=1 loops=100) - Index Cond: ((app_version_id = '1'::bigint) AND (file_hash = r.file_hash)) - Heap Fetches: 0 - Buffers: shared hit=301 - -> Index Scan using app_versions_pkey on app_versions av (cost=0.27..8.29 rows=1 width=8) (actual time=0.000..0.000 rows=1 loops=100) - Index Cond: (id = '1'::bigint) - Filter: ((NOT deleted) AND (app_id = 'com.bench.size'::text)) - Buffers: shared hit=300 -Planning Time: 0.043 ms -Execution Time: 0.218 ms -``` - -### after — fallback version name, 2000 hashes - --> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.210..0.282 rows=2000 loops=1) - -``` -GroupAggregate (cost=17.27..17.29 rows=1 width=48) (actual time=3.176..3.452 rows=2000 loops=1) - Group Key: r.file_hash, av.id - Buffers: shared hit=6004 - CTE requested - -> HashAggregate (cost=1.50..2.50 rows=100 width=40) (actual time=0.500..0.608 rows=2000 loops=1) - Group Key: request_files.file_hash, request_files.version_id - Batches: 1 Memory Usage: 257kB - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.210..0.282 rows=2000 loops=1) - Filter: (file_hash IS NOT NULL) - -> Sort (cost=14.77..14.77 rows=1 width=48) (actual time=3.174..3.218 rows=2000 loops=1) - Sort Key: r.file_hash, av.id - Sort Method: quicksort Memory: 141kB - Buffers: shared hit=6004 - -> Nested Loop (cost=0.70..14.76 rows=1 width=48) (actual time=0.511..2.460 rows=2000 loops=1) - Buffers: shared hit=6004 - -> Index Scan using idx_app_id_name_app_versions on app_versions av (cost=0.27..8.29 rows=1 width=8) (actual time=0.005..0.006 rows=1 loops=1) - Index Cond: ((app_id = 'com.bench.size'::text) AND (name = '1'::text)) - Filter: (NOT deleted) - Buffers: shared hit=3 - -> Nested Loop (cost=0.42..6.45 rows=1 width=48) (actual time=0.505..2.361 rows=2000 loops=1) - Buffers: shared hit=6001 - -> CTE Scan on requested r (cost=0.00..2.00 rows=1 width=32) (actual time=0.500..0.789 rows=2000 loops=1) - Filter: (version_id IS NULL) - -> Index Only Scan using idx_manifest_app_version_id_file_hash on manifest m (cost=0.42..4.44 rows=1 width=25) (actual time=0.001..0.001 rows=1 loops=2000) - Index Cond: ((app_version_id = av.id) AND (file_hash = r.file_hash)) - Heap Fetches: 0 - Buffers: shared hit=6001 -Planning: - Buffers: shared hit=14 -Planning Time: 0.161 ms -Execution Time: 3.564 ms -``` - -### after — per-file version id, 2000 hashes - --> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.265..0.336 rows=2000 loops=1) - -``` -GroupAggregate (cost=91.66..92.00 rows=17 width=48) (actual time=3.213..3.487 rows=2000 loops=1) - Group Key: r.file_hash, av.id - Buffers: shared hit=6003 - CTE requested - -> HashAggregate (cost=1.50..2.50 rows=100 width=40) (actual time=0.551..0.658 rows=2000 loops=1) - Group Key: request_files.file_hash, request_files.version_id - Batches: 1 Memory Usage: 385kB - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.265..0.336 rows=2000 loops=1) - Filter: (file_hash IS NOT NULL) - -> Sort (cost=89.15..89.20 rows=17 width=48) (actual time=3.211..3.259 rows=2000 loops=1) - Sort Key: r.file_hash, av.id - Sort Method: quicksort Memory: 141kB - Buffers: shared hit=6003 - -> Nested Loop (cost=11.19..88.81 rows=17 width=48) (actual time=0.573..2.553 rows=2000 loops=1) - Join Filter: (av.id = m.app_version_id) - Buffers: shared hit=6003 - -> Hash Join (cost=10.77..13.03 rows=17 width=48) (actual time=0.567..0.996 rows=2000 loops=1) - Hash Cond: (r.version_id = av.id) - Buffers: shared hit=2 - -> CTE Scan on requested r (cost=0.00..2.00 rows=100 width=40) (actual time=0.552..0.851 rows=2000 loops=1) - Filter: (version_id IS NOT NULL) - -> Hash (cost=9.77..9.77 rows=80 width=8) (actual time=0.013..0.013 rows=80 loops=1) - Buckets: 1024 Batches: 1 Memory Usage: 12kB - Buffers: shared hit=2 - -> Bitmap Heap Scan on app_versions av (cost=4.77..9.77 rows=80 width=8) (actual time=0.005..0.008 rows=80 loops=1) - Recheck Cond: (app_id = 'com.bench.size'::text) - Filter: (NOT deleted) - Heap Blocks: exact=1 - Buffers: shared hit=2 - -> Bitmap Index Scan on idx_app_id_app_versions (cost=0.00..4.75 rows=80 width=0) (actual time=0.002..0.002 rows=80 loops=1) - Index Cond: (app_id = 'com.bench.size'::text) - Buffers: shared hit=1 - -> Index Only Scan using idx_manifest_app_version_id_file_hash on manifest m (cost=0.42..4.44 rows=1 width=25) (actual time=0.001..0.001 rows=1 loops=2000) - Index Cond: ((app_version_id = r.version_id) AND (file_hash = r.file_hash)) - Heap Fetches: 0 - Buffers: shared hit=6001 -Planning: - Buffers: shared hit=14 -Planning Time: 0.166 ms -Execution Time: 3.603 ms -``` - -### after — no version, 2000 hashes - -no query - -handler returns size_unknown and does not query - -## Settings - -``` -jit=off -server_version=17.11 -shared_buffers=512 MiB -work_mem=32 MiB -``` - diff --git a/scripts/bench/zod_454_version_bump_comparison.md b/scripts/bench/zod_454_version_bump_comparison.md deleted file mode 100644 index 7010a3c858..0000000000 --- a/scripts/bench/zod_454_version_bump_comparison.md +++ /dev/null @@ -1,43 +0,0 @@ -# Zod 4.4.3 → 4.5.4 version-only benchmark (no z.compile) - -Measured on the same VM with existing validation paths only: production `.is` -predicate, `zod-compiler` AOT, and Zod runtime `safeParse`. Does **not** use -Zod 4.5 `z.compile()` — plugin hot paths keep the existing `zod-compiler` flow. - -## Summary - -| Area | 4.4.3 | 4.5.4 | Verdict | -| --- | ---: | ---: | --- | -| Plugin `.is` predicate (valid) | 91.4 ns/op | 91.9 ns/op | Flat | -| `zod-compiler` `.is` (valid) | 92.0 ns/op | 90.5 ns/op | ~1.6% faster | -| Zod runtime `safeParse` (valid) | 652.5 ns/op | 627.1 ns/op | **~3.9% faster** | -| Zod runtime `safeParse` (75% valid mixed) | 2359.6 ns/op | 1201.7 ns/op | **~49% faster** | -| Backend org schema `safeParse` (valid) | 20045 ns/op | 20846 ns/op | ~4% slower | -| 80k runtime parse heap Δ | 0.339 MB | 0.331 MB | ~2% lower | -| 80k runtime parse RSS Δ | ~20.2 MB | ~17.0 MB | ~16% lower | -| 100× `z.string()` heap Δ | 0.042 MB | 0.045 MB | Noisy / flat | - -## Method - -- CPU: 5 runs × 80k iterations, `process.cpuUsage()` (see `scripts/bench_zod_stable_cpu.ts`) -- Memory: 5 runs, heap/RSS delta with `Bun.gc()` between samples (no `z.compile`) -- Fixtures: plugin update-request body shape (mock data, same as `bench_plugin_validation_cpu.ts`) and backend org row shape from `organization/get.ts` -- `bun test:unit` — 2548 tests passed on 4.5.4 - -## Recommendation - -Upgrade is justified for backend Zod runtime `safeParse` validation (~40 Supabase -function files, including Deno edge functions on the same `zod` import): measurable -CPU win on valid and especially mixed valid/invalid paths on the representative -`updateRequestSchemaZod` fixture, plus lower RSS during parse bursts. - -Plugin `/updates` production path uses extracted `.is` predicates (flat in this -bench). `/stats` and `/channel_self` were not separately benchmarked here — keep -`zod-compiler` + extracted `.is` predicates on those hot paths. - -Re-run: - -```bash -bun scripts/bench_plugin_validation_cpu.ts -BENCH_RUNS=5 bun scripts/bench_zod_stable_cpu.ts -``` diff --git a/scripts/bench_manifest_size_lookup.ts b/scripts/bench_manifest_size_lookup.ts deleted file mode 100644 index 6ed3d48e53..0000000000 --- a/scripts/bench_manifest_size_lookup.ts +++ /dev/null @@ -1,309 +0,0 @@ -/** - * Before/after timings for POST /updates/manifest_size. - * - * Local Postgres only. Setup drops the public schema. - * - * docker run -d --name capgo-manifest-size-bench-pg \ - * -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres \ - * -e POSTGRES_DB=manifest_size_bench -p 55433:5432 postgres:17-alpine \ - * -c shared_buffers=512MB -c work_mem=32MB -c maintenance_work_mem=512MB \ - * -c max_wal_size=2GB -c jit=off - * - * bun scripts/bench_manifest_size_lookup.ts - */ -import { writeFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { performance } from 'node:perf_hooks' -import { Client } from 'pg' -import { buildManifestSizeLookupQuery } from '../supabase/functions/_backend/utils/manifest_size.ts' - -const DATABASE_URL = 'postgres://postgres:postgres@127.0.0.1:55433/manifest_size_bench' -const APP_ID = 'com.bench.size' -const NOISE_APP_ID = 'com.bench.noise' -const TARGET_VERSIONS = 80 -const NOISE_VERSIONS = 400 -const FILES_PER_VERSION = 2000 -const RUNS = 5 -const ROOT = resolve(import.meta.dirname, '..') - -const OLD_SQL = ` -WITH requested AS ( - SELECT file_hash, version_id - FROM jsonb_to_recordset($1::jsonb) AS request_files(file_hash text, version_id bigint) - WHERE file_hash IS NOT NULL -) -SELECT - requested.file_hash, - app_versions.id AS version_id, - MAX(manifest.file_size) AS file_size -FROM requested -INNER JOIN public.app_versions - ON app_versions.app_id = $2 - AND app_versions.deleted = false - AND ( - ( - requested.version_id IS NOT NULL - AND app_versions.id = requested.version_id - ) OR ( - requested.version_id IS NULL - AND ( - ( - $3::bigint IS NOT NULL - AND app_versions.id = $3 - ) OR ( - $3::bigint IS NULL - AND ($4::text IS NULL OR app_versions.name = $4) - ) - ) - ) - ) -INNER JOIN public.manifest - ON manifest.app_version_id = app_versions.id - AND manifest.file_hash = requested.file_hash -GROUP BY requested.file_hash, app_versions.id -` - -interface Timing { - rows: number - timesMs: number[] - medianMs: number - minMs: number - maxMs: number -} - -interface ScenarioReport { - name: string - side: 'before' | 'after' - timing: Timing | null - skipped: string | null - explain: string | null -} - -function assertSafeUrl(databaseUrl: string) { - const parsed = new URL(databaseUrl) - const dbName = decodeURIComponent(parsed.pathname.replace(/^\/+/, '')) - if (parsed.hostname !== '127.0.0.1' || parsed.port !== '55433' || dbName !== 'manifest_size_bench') - throw new Error(`Refusing bench against ${parsed.hostname}:${parsed.port}/${dbName}`) -} - -function hashes(count: number, versionId: number | null) { - const files = [] - for (let i = 1; i <= count; i++) - files.push({ file_name: `f${i}`, file_hash: `hash-${i}`, download_url: null, version_id: versionId }) - return files -} - -function median(values: number[]): number { - const sorted = [...values].sort((a, b) => a - b) - return sorted[Math.floor(sorted.length / 2)] ?? 0 -} - -async function timeQuery(client: Client, sql: string, params: unknown[]): Promise { - await client.query(sql, params) - const timesMs: number[] = [] - let rows = 0 - for (let i = 0; i < RUNS; i++) { - const started = performance.now() - const result = await client.query(sql, params) - timesMs.push(performance.now() - started) - rows = result.rowCount ?? 0 - } - return { - rows, - timesMs: timesMs.map(value => Math.round(value * 10) / 10), - medianMs: Math.round(median(timesMs) * 10) / 10, - minMs: Math.round(Math.min(...timesMs) * 10) / 10, - maxMs: Math.round(Math.max(...timesMs) * 10) / 10, - } -} - -async function explain(client: Client, sql: string, params: unknown[]): Promise { - const result = await client.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${sql}`, params) - return result.rows.map((row: { 'QUERY PLAN': string }) => row['QUERY PLAN']).join('\n') -} - -async function setup(client: Client) { - await client.query(` - DROP SCHEMA IF EXISTS public CASCADE; - CREATE SCHEMA public; - CREATE TABLE public.app_versions ( - id bigint PRIMARY KEY, - app_id text NOT NULL, - name text NOT NULL, - deleted boolean NOT NULL DEFAULT false - ); - CREATE TABLE public.manifest ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - app_version_id bigint NOT NULL REFERENCES public.app_versions(id), - file_name text NOT NULL, - file_hash text NOT NULL, - file_size bigint NOT NULL, - s3_path text NOT NULL - ); - CREATE INDEX idx_app_id_app_versions ON public.app_versions (app_id); - CREATE INDEX idx_app_id_name_app_versions ON public.app_versions (app_id, name); - `) - - await client.query(` - INSERT INTO public.app_versions (id, app_id, name, deleted) - SELECT v, $1, v::text, false - FROM generate_series(1, $2::int) AS v - `, [APP_ID, TARGET_VERSIONS]) - await client.query(` - INSERT INTO public.app_versions (id, app_id, name, deleted) - SELECT v, $1, v::text, false - FROM generate_series($2::int + 1, $2::int + $3::int) AS v - `, [NOISE_APP_ID, TARGET_VERSIONS, NOISE_VERSIONS]) - - await client.query(` - INSERT INTO public.manifest (app_version_id, file_name, file_hash, file_size, s3_path) - SELECT v, 'f' || f, 'hash-' || f, f * 10, v::text || '/' || f::text - FROM generate_series(1, $1::int + $2::int) AS v - CROSS JOIN generate_series(1, $3::int) AS f - `, [TARGET_VERSIONS, NOISE_VERSIONS, FILES_PER_VERSION]) - - await client.query(` - CREATE INDEX idx_manifest_app_version_id ON public.manifest (app_version_id); - CREATE INDEX idx_manifest_file_hash ON public.manifest (file_hash); - ANALYZE public.app_versions; - ANALYZE public.manifest; - `) -} - -async function swapToNewIndex(client: Client) { - await client.query(` - CREATE INDEX idx_manifest_app_version_id_file_hash - ON public.manifest (app_version_id, file_hash) INCLUDE (file_size); - DROP INDEX public.idx_manifest_app_version_id; - ANALYZE public.manifest; - `) -} - -function formatPgSetting(row: { name: string, setting: string, unit: string | null }): string { - const value = Number(row.setting) - if (row.unit === '8kB' && Number.isFinite(value)) - return `${row.name}=${value * 8 / 1024} MiB` - if (row.unit === 'kB' && Number.isFinite(value)) - return `${row.name}=${value / 1024} MiB` - return `${row.name}=${row.setting}${row.unit ? ` ${row.unit}` : ''}` -} - -function planHeadline(explainText: string | null): string { - if (!explainText) - return 'no query' - const scan = explainText.split('\n').find(line => /Scan|Nested Loop|Hash Join|Bitmap/.test(line) && !line.includes('Planning')) - return (scan ?? explainText.split('\n')[0] ?? '').trim() -} - -async function main() { - assertSafeUrl(DATABASE_URL) - const client = new Client({ connectionString: DATABASE_URL, statement_timeout: 120_000 }) - await client.connect() - await client.query('SET jit = off') - - console.log('Seeding…') - const seedStarted = performance.now() - await setup(client) - const counts = await client.query(` - SELECT - (SELECT count(*)::int FROM public.manifest) AS manifest_rows, - (SELECT count(*)::int FROM public.app_versions) AS versions - `) - console.log(`Seeded in ${Math.round((performance.now() - seedStarted) / 1000)}s`, counts.rows[0]) - - const fullFiles = hashes(FILES_PER_VERSION, null) - const partialFiles = hashes(100, null) - const perFileVersion = hashes(FILES_PER_VERSION, 1) - const scenarios: Array<{ name: string, files: ReturnType, versionName?: string, versionId?: number }> = [ - { name: 'fallback version id, 2000 hashes', files: fullFiles, versionId: 1 }, - { name: 'fallback version id, 100 hashes', files: partialFiles, versionId: 1 }, - { name: 'fallback version name, 2000 hashes', files: fullFiles, versionName: '1' }, - { name: 'per-file version id, 2000 hashes', files: perFileVersion }, - { name: 'no version, 2000 hashes', files: fullFiles }, - ] - - const reports: ScenarioReport[] = [] - for (const scenario of scenarios) { - console.log(`BEFORE ${scenario.name}`) - const params = [JSON.stringify(scenario.files), APP_ID, scenario.versionId ?? null, scenario.versionName ?? null] - const timing = await timeQuery(client, OLD_SQL, params) - const plan = await explain(client, OLD_SQL, params) - reports.push({ name: scenario.name, side: 'before', timing, skipped: null, explain: plan }) - console.log(` median ${timing.medianMs}ms rows ${timing.rows}`) - } - - console.log('Building composite index…') - const indexStarted = performance.now() - await swapToNewIndex(client) - console.log(`Index swap ${Math.round(performance.now() - indexStarted)}ms`) - - for (const scenario of scenarios) { - console.log(`AFTER ${scenario.name}`) - const lookup = buildManifestSizeLookupQuery(APP_ID, scenario.versionName, scenario.versionId, scenario.files) - if (!lookup) { - reports.push({ - name: scenario.name, - side: 'after', - timing: null, - skipped: 'handler returns size_unknown and does not query', - explain: null, - }) - console.log(' skipped') - continue - } - const timing = await timeQuery(client, lookup.text, lookup.values) - const plan = await explain(client, lookup.text, lookup.values) - reports.push({ name: scenario.name, side: 'after', timing, skipped: null, explain: plan }) - console.log(` median ${timing.medianMs}ms rows ${timing.rows}`) - } - - const settings = await client.query(` - SELECT name, setting, unit - FROM pg_settings - WHERE name IN ('shared_buffers', 'work_mem', 'jit', 'server_version') - `) - await client.end() - - const lines = [ - '# Manifest size lookup before / after', - '', - `Local Postgres ${settings.rows.find((row: { name: string }) => row.name === 'server_version')?.setting ?? '17'}. Not production.`, - '', - `Seed: ${TARGET_VERSIONS} versions of \`${APP_ID}\` and ${NOISE_VERSIONS} versions of \`${NOISE_APP_ID}\`, ${FILES_PER_VERSION} identical hashes per version (${((TARGET_VERSIONS + NOISE_VERSIONS) * FILES_PER_VERSION).toLocaleString()} manifest rows). Same hash is repeated on every version so \`idx_manifest_file_hash\` is not selective.`, - '', - 'Before uses the old `OR` join and `idx_manifest_app_version_id` plus `idx_manifest_file_hash`. After uses the split lookup and `idx_manifest_app_version_id_file_hash (app_version_id, file_hash) INCLUDE (file_size)`. `idx_manifest_file_hash` stays.', - '', - `Timings are the median of ${RUNS} hot-cache client round trips after one warmup. \`jit\` is off. \`statement_timeout\` is 120s.`, - '', - '| Scenario | Before median | After median | Before rows | After rows |', - '| --- | ---: | ---: | ---: | ---: |', - ] - - for (const scenario of scenarios) { - const before = reports.find(report => report.name === scenario.name && report.side === 'before') - const after = reports.find(report => report.name === scenario.name && report.side === 'after') - const afterCell = after?.skipped ? 'no query' : `${after?.timing?.medianMs} ms` - lines.push(`| ${scenario.name} | ${before?.timing?.medianMs} ms | ${afterCell} | ${before?.timing?.rows ?? ''} | ${after?.skipped ? '0' : after?.timing?.rows ?? ''} |`) - } - - lines.push('', '## Plans', '') - for (const report of reports) { - lines.push(`### ${report.side} — ${report.name}`, '', planHeadline(report.explain), '') - if (report.explain) - lines.push('```', report.explain, '```', '') - else - lines.push(report.skipped ?? '', '') - } - - lines.push('## Settings', '', '```', settings.rows.map(row => formatPgSetting(row)).join('\n'), '```', '') - - const summaryPath = resolve(ROOT, 'scripts/bench/manifest_size_lookup_summary.md') - writeFileSync(summaryPath, `${lines.join('\n')}\n`) - writeFileSync(resolve(ROOT, 'scripts/bench/manifest_size_lookup_results.json'), `${JSON.stringify({ generatedAt: new Date().toISOString(), counts: counts.rows[0], settings: settings.rows, reports }, null, 2)}\n`) - console.log(`Wrote ${summaryPath}`) -} - -main().catch((error) => { - console.error(error) - process.exit(1) -}) diff --git a/scripts/bench_zod_stable_cpu.ts b/scripts/bench_zod_stable_cpu.ts deleted file mode 100644 index 388d297f6f..0000000000 --- a/scripts/bench_zod_stable_cpu.ts +++ /dev/null @@ -1,305 +0,0 @@ -#!/usr/bin/env bun -/** - * Stable CPU + memory bench for Zod version bumps (no z.compile). - * - * Measures existing validation paths only: - * - production `.is` predicate - * - zod-compiler AOT `.is` - * - Zod runtime `safeParse` - * - representative backend org schema `safeParse` - * - * Usage: - * bun scripts/bench_zod_stable_cpu.ts - * BENCH_RUNS=5 bun scripts/bench_zod_stable_cpu.ts /workspace - */ - -import { spawnSync } from 'node:child_process' -import { resolve } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { performance } from 'node:perf_hooks' - -const SCRIPT_PATH = fileURLToPath(import.meta.url) -const cliArgs = process.argv.slice(2) -const memWorkerCase = cliArgs[0] === '--mem-worker' ? cliArgs[1] : undefined -const ROOT = resolve(memWorkerCase ? cliArgs[2] : cliArgs[0] ?? resolve(import.meta.dirname, '..')) -const RUNS = Math.max(1, Number.parseInt(process.env.BENCH_RUNS ?? '1', 10) || 1) -const ITERATIONS = 80_000 - -interface CpuRow { - name: string - runs: number - iterations: number - nsCpuPerOp: number - nsCpuPerOpStd: number -} - -interface MemRow { - name: string - runs: number - heapUsedDeltaMB: number - rssDeltaMB: number -} - -function forceGc() { - if (typeof Bun !== 'undefined' && 'gc' in Bun && typeof Bun.gc === 'function') - Bun.gc(true) - else if (globalThis.gc) - globalThis.gc() -} - -async function readZodVersion(root: string) { - const mod = await import(pathToFileURL(resolve(root, 'node_modules/zod/package.json')).href) as { - default?: { version?: string } - version?: string - } - return mod.default?.version ?? mod.version ?? 'unknown' -} - -async function importZodFromRoot(root: string) { - return import(pathToFileURL(resolve(root, 'node_modules/zod/index.js')).href) -} - -function ensureCompiledZod() { - const out = resolve(ROOT, 'scripts/bench/validation/plugin_schemas.zod.compiled.ts') - const src = resolve(ROOT, 'scripts/bench/validation/plugin_schemas.zod.ts') - const result = spawnSync(process.execPath, ['x', 'zod-compiler', 'generate', src, '-o', out, '--emit', 'bag'], { - cwd: ROOT, - encoding: 'utf8', - }) - if (result.status === null) - throw new Error(`zod-compiler generate failed to start: ${result.error?.message ?? 'unknown error'}`) - if (result.status !== 0) - throw new Error(`zod-compiler generate failed:\n${result.stdout}\n${result.stderr}`) -} - -function validUpdatePayload() { - return { - app_id: 'com.demo.app', - device_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - version_name: '1.2.3', - version_build: '1.2.3', - is_emulator: false, - is_prod: true, - platform: 'ios' as const, - plugin_version: '6.8.1', - defaultChannel: 'production', - key_id: 'key_1', - } -} - -function invalidUpdatePayload() { - return { - ...validUpdatePayload(), - app_id: 'not a domain', - device_id: 'bad', - plugin_version: 'nope', - } -} - -function validOrgPayload() { - return { - id: '11111111-1111-4111-8111-111111111111', - created_by: '22222222-2222-4222-8222-222222222222', - created_at: '2026-01-01T00:00:00.000Z', - updated_at: '2026-01-01T00:00:00.000Z', - logo: null, - name: 'Example Org', - management_email: 'org-admin@example.com', - customer_id: null, - website: null, - } -} - -function mixedUpdateInputs(validRatio = 0.75) { - const valid = validUpdatePayload() - const invalid = invalidUpdatePayload() - const invalidEvery = Math.max(1, Math.round(1 / (1 - validRatio))) - return Array.from({ length: 16 }, (_, i) => (i % invalidEvery === 0 ? invalid : valid)) -} - -function benchCpuOnce(name: string, iterations: number, fn: (input: unknown) => boolean, inputs: unknown[]) { - for (let i = 0; i < Math.min(2000, iterations); i++) - fn(inputs[i % inputs.length]) - - const cpu0 = process.cpuUsage() - const t0 = performance.now() - for (let i = 0; i < iterations; i++) - fn(inputs[i % inputs.length]) - const wallMs = performance.now() - t0 - const cpu = process.cpuUsage(cpu0) - const cpuMs = (cpu.user + cpu.system) / 1000 - return { - name, - iterations, - wallMs, - cpuMs, - nsCpuPerOp: (cpuMs * 1e6) / iterations, - } -} - -function summarizeCpu(name: string, iterations: number, samples: number[]) { - const mean = samples.reduce((sum, value) => sum + value, 0) / samples.length - const variance = samples.reduce((sum, value) => sum + (value - mean) ** 2, 0) / samples.length - return { - name, - runs: samples.length, - iterations, - nsCpuPerOp: mean, - nsCpuPerOpStd: Math.sqrt(variance), - } -} - -function summarizeMem(name: string, samples: Array<{ heapUsedDeltaMB: number, rssDeltaMB: number }>) { - const heapUsedDeltaMB = samples.reduce((sum, row) => sum + row.heapUsedDeltaMB, 0) / samples.length - const rssDeltaMB = samples.reduce((sum, row) => sum + row.rssDeltaMB, 0) / samples.length - return { - name, - runs: samples.length, - heapUsedDeltaMB, - rssDeltaMB, - } -} - -function measureMemInChild(caseName: string) { - const result = spawnSync(process.execPath, [SCRIPT_PATH, '--mem-worker', caseName, ROOT], { - cwd: ROOT, - encoding: 'utf8', - env: process.env, - }) - if (result.status === null) - throw new Error(`memory worker failed to start for ${caseName}: ${result.error?.message ?? 'unknown error'}`) - if (result.status !== 0) - throw new Error(`memory worker ${caseName} failed:\n${result.stdout}\n${result.stderr}`) - return JSON.parse(result.stdout.trim()) as { heapUsedDeltaMB: number, rssDeltaMB: number } -} - -async function runMemWorker(caseName: string, root: string) { - const zodRuntime = await import(pathToFileURL(resolve(root, 'scripts/bench/validation/plugin_schemas.zod.ts')).href) - const { z } = await importZodFromRoot(root) - const valid = validUpdatePayload() - const retained: unknown[] = [] - - forceGc() - const before = process.memoryUsage() - - if (caseName === '80k_runtime_parse_heap_rss') { - for (let i = 0; i < ITERATIONS; i++) - retained.push(zodRuntime.updateRequestSchemaZod.safeParse(valid)) - } - else if (caseName === '100x_z_string_heap') { - for (let i = 0; i < 100; i++) - retained.push(z.string()) - } - else { - throw new Error(`unknown memory worker case: ${caseName}`) - } - - forceGc() - const after = process.memoryUsage() - void retained.length - - console.log(JSON.stringify({ - heapUsedDeltaMB: (after.heapUsed - before.heapUsed) / 1024 / 1024, - rssDeltaMB: (after.rss - before.rss) / 1024 / 1024, - })) -} - -async function main() { - ensureCompiledZod() - - const prodIs = await import(pathToFileURL(resolve(ROOT, 'supabase/functions/_backend/plugin_runtime/utils/plugin_schemas/update_request.is.ts')).href) - const zodRuntime = await import(pathToFileURL(resolve(ROOT, 'scripts/bench/validation/plugin_schemas.zod.ts')).href) - const zodCompiled = await import(pathToFileURL(resolve(ROOT, 'scripts/bench/validation/plugin_schemas.zod.compiled.ts')).href) - const { z } = await importZodFromRoot(ROOT) - - const orgSchema = z.object({ - id: z.uuid(), - created_by: z.uuid(), - created_at: z.union([z.string(), z.date()]), - updated_at: z.union([z.string(), z.date()]), - logo: z.string().nullable(), - name: z.string(), - management_email: z.email(), - customer_id: z.string().nullable(), - website: z.string().nullable(), - }) - - const valid = validUpdatePayload() - const mixed = mixedUpdateInputs(0.75) - const orgValid = validOrgPayload() - - const cpuCases: Array<{ name: string, fn: (input: unknown) => boolean, inputs: unknown[] }> = [ - { - name: 'plugin_is_predicate_valid', - fn: input => prodIs.isUpdateRequestBody(input), - inputs: [valid], - }, - { - name: 'zod_compiler_is_valid', - fn: input => zodCompiled.updateRequestSchemaZod.is(input), - inputs: [valid], - }, - { - name: 'zod_runtime_safeParse_valid', - fn: input => zodRuntime.updateRequestSchemaZod.safeParse(input).success, - inputs: [valid], - }, - { - name: 'zod_runtime_safeParse_mixed_75pct_valid', - fn: input => zodRuntime.updateRequestSchemaZod.safeParse(input).success, - inputs: mixed, - }, - { - name: 'backend_org_schema_safeParse_valid', - fn: input => orgSchema.safeParse(input).success, - inputs: [orgValid], - }, - ] - - const cpuRows: CpuRow[] = [] - for (const testCase of cpuCases) { - const samples: number[] = [] - for (let run = 0; run < RUNS; run++) - samples.push(benchCpuOnce(testCase.name, ITERATIONS, testCase.fn, testCase.inputs).nsCpuPerOp) - cpuRows.push(summarizeCpu(testCase.name, ITERATIONS, samples)) - } - - const memCaseNames = ['80k_runtime_parse_heap_rss', '100x_z_string_heap'] as const - const memRows: MemRow[] = [] - for (const caseName of memCaseNames) { - const samples = [] - for (let run = 0; run < RUNS; run++) - samples.push(measureMemInChild(caseName)) - memRows.push(summarizeMem(caseName, samples)) - } - - const zodVersion = await readZodVersion(ROOT) - - console.log(`\n=== Zod stable CPU + memory bench (zod@${zodVersion}, runs=${RUNS}) ===`) - console.log('\nCPU (lower ns/op is better):') - for (const row of cpuRows) { - console.log( - `${row.name.padEnd(40)} ${row.nsCpuPerOp.toFixed(1).padStart(10)} ns/op ± ${row.nsCpuPerOpStd.toFixed(1)}`, - ) - } - - console.log('\nMemory (lower delta is better; each case runs in a fresh child process):') - for (const row of memRows) { - console.log( - `${row.name.padEnd(40)} heap Δ ${row.heapUsedDeltaMB.toFixed(3).padStart(8)} MB | rss Δ ${row.rssDeltaMB.toFixed(3).padStart(8)} MB`, - ) - } -} - -if (memWorkerCase) { - runMemWorker(memWorkerCase, ROOT).catch((error) => { - console.error(error) - process.exit(1) - }) -} -else { - main().catch((error) => { - console.error(error) - process.exit(1) - }) -} diff --git a/scripts/check_r2_big_files.ts b/scripts/check_r2_big_files.ts index f71e0b983e..534a63d971 100644 --- a/scripts/check_r2_big_files.ts +++ b/scripts/check_r2_big_files.ts @@ -631,7 +631,7 @@ export function getDatabaseURL(): string { return DEFAULT_DB_URL } -export function getPgClient(c: Context) { +export function await getPgClient(c: Context) { const dbUrl = getDatabaseURL() console.log({ message: 'getPgClient', dbUrl }) return new Pool({ @@ -646,7 +646,7 @@ async function get_app_versions() { // Create a mock context for getPgClient const mockContext = {} as Context - const pool = getPgClient(mockContext) + const pool = await getPgClient(mockContext) try { console.log('📊 Fetching all app versions...') @@ -745,7 +745,7 @@ async function get_big_orgs() { if (orgsOver100MB.length > 0) { // Create a mock context for getPgClient const mockContext = {} as Context - const pool = getPgClient(mockContext) + const pool = await getPgClient(mockContext) try { // Create all the plan queries @@ -1083,7 +1083,7 @@ async function prepare_cleanup_zip() { // Connect to database console.log('🔗 Connecting to database...') const mockContext = {} as Context - const pool = getPgClient(mockContext) + const pool = await getPgClient(mockContext) // Define types for cleanup data interface CleanupCandidate { diff --git a/scripts/ops/manifest_version_hash_lookup_index.sql b/scripts/ops/manifest_version_hash_lookup_index.sql deleted file mode 100644 index 73d594ed51..0000000000 --- a/scripts/ops/manifest_version_hash_lookup_index.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Required before migration 20260923143039 when public.manifest is large. --- That migration refuses a regular CREATE INDEX once the table estimate is --- above 100000 rows, because that build blocks manifest writes. --- Run the SINGLE statement below alone in SQL Editor (or psql). Do not mix --- with other statements in one Editor run if the Editor wraps a transaction — --- CREATE INDEX CONCURRENTLY cannot run in a transaction. --- --- Example (psql): --- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f scripts/ops/manifest_version_hash_lookup_index.sql - -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_manifest_app_version_id_file_hash - ON public.manifest USING btree (app_version_id, file_hash) - INCLUDE (file_size); diff --git a/scripts/playwright-frontend-preview.ts b/scripts/playwright-frontend-preview.ts index 830be891bf..c4d2bb1a59 100644 --- a/scripts/playwright-frontend-preview.ts +++ b/scripts/playwright-frontend-preview.ts @@ -13,7 +13,6 @@ const env = { ...process.env, API_DOMAIN: apiDomain, CAPTCHA_KEY: '', - CAPGO_PLAYWRIGHT_FIXTURES: 'true', ENV: 'local', SUPA_ANON: process.env.SUPABASE_ANON_KEY || '', SUPA_URL: supabaseUrl, diff --git a/scripts/visual-diff.ts b/scripts/visual-diff.ts index a4831a8043..de780d9a00 100644 --- a/scripts/visual-diff.ts +++ b/scripts/visual-diff.ts @@ -11,7 +11,6 @@ import { createClient } from '@supabase/supabase-js' import pixelmatch from 'pixelmatch' import { PNG } from 'pngjs' import type { VisualDiffRoute } from '../playwright/visual-diff.config' -import { dismissSupportPrompt } from '../playwright/support/dismissSupportPrompt' import { getSupabaseStatus } from './supabase-worktree-status' import { getPlaywrightStripeApiBaseUrl, getStripeEmulatorPort } from './playwright-stripe' import { getSupabaseWorktreeConfig } from './supabase-worktree-config' @@ -576,8 +575,6 @@ async function captureRouteScreenshot( await page.goto(route.path, { waitUntil: 'domcontentloaded', timeout: visualDiffActionTimeoutMs }) console.log(`[visual-diff] settling ${phase} ${route.slug}`) await settlePage(page) - if (route.auth) - await dismissSupportPrompt(page) if (route.prepare) { console.log(`[visual-diff] preparing ${phase} ${route.slug}`) await route.prepare(page) diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts index 33e40761db..3853527d67 100644 --- a/src/auto-imports.d.ts +++ b/src/auto-imports.d.ts @@ -133,7 +133,6 @@ declare global { const useAdminDashboardStore: typeof import('./stores/adminDashboard').useAdminDashboardStore const useAnimate: typeof import('@vueuse/core').useAnimate const useAppDetailStore: typeof import('./stores/appDetail').useAppDetailStore - const useAppOnboardingCliProgress: typeof import('./composables/useAppOnboardingCliProgress').useAppOnboardingCliProgress const useAppPage: typeof import('./composables/useAppPage').useAppPage const useArrayDifference: typeof import('@vueuse/core').useArrayDifference const useArrayEvery: typeof import('@vueuse/core').useArrayEvery @@ -506,7 +505,6 @@ declare module 'vue' { readonly useAdminDashboardStore: UnwrapRef readonly useAnimate: UnwrapRef readonly useAppDetailStore: UnwrapRef - readonly useAppOnboardingCliProgress: UnwrapRef readonly useAppPage: UnwrapRef readonly useArrayDifference: UnwrapRef readonly useArrayEvery: UnwrapRef diff --git a/src/components.d.ts b/src/components.d.ts index f871307edb..20d77ac7d5 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -35,11 +35,9 @@ declare module 'vue' { AppAccess: typeof import('./components/dashboard/AppAccess.vue')['default'] AppDashboardPage: typeof import('./components/dashboard/AppDashboardPage.vue')['default'] AppNotFoundModal: typeof import('./components/AppNotFoundModal.vue')['default'] - AppOnboardingBuilderChecklist: typeof import('./components/dashboard/AppOnboardingBuilderChecklist.vue')['default'] AppOnboardingCliSteps: typeof import('./components/dashboard/AppOnboardingCliSteps.vue')['default'] AppOnboardingFlow: typeof import('./components/dashboard/AppOnboardingFlow.vue')['default'] AppOnboardingIconInput: typeof import('./components/dashboard/AppOnboardingIconInput.vue')['default'] - AppOnboardingSetupChecklist: typeof import('./components/dashboard/AppOnboardingSetupChecklist.vue')['default'] AppOnboardingWelcome: typeof import('./components/dashboard/AppOnboardingWelcome.vue')['default'] AppPageFrame: typeof import('./components/dashboard/AppPageFrame.vue')['default'] AppPageNotFound: typeof import('./components/dashboard/AppPageNotFound.vue')['default'] @@ -58,7 +56,6 @@ declare module 'vue' { BuildTimeCard: typeof import('./components/dashboard/BuildTimeCard.vue')['default'] BuildTimeChart: typeof import('./components/dashboard/BuildTimeChart.vue')['default'] BundleAdoptionCard: typeof import('./components/bundle/BundleAdoptionCard.vue')['default'] - BundleChannelsPopover: typeof import('./components/tables/BundleChannelsPopover.vue')['default'] BundleCompareSelect: typeof import('./components/bundle/BundleCompareSelect.vue')['default'] BundleInstallStatsPanel: typeof import('./components/dashboard/BundleInstallStatsPanel.vue')['default'] BundleMultiFilter: typeof import('./components/tables/BundleMultiFilter.vue')['default'] @@ -77,7 +74,6 @@ declare module 'vue' { ChannelPermissionOverridesPanel: typeof import('./components/permissions/ChannelPermissionOverridesPanel.vue')['default'] ChannelSelfAssignMockup: typeof import('./components/dashboard/ChannelSelfAssignMockup.vue')['default'] ChannelSelfAssignOnboarding: typeof import('./components/dashboard/ChannelSelfAssignOnboarding.vue')['default'] - ChannelSetupOnboardingDialog: typeof import('./components/dashboard/ChannelSetupOnboardingDialog.vue')['default'] ChannelTable: typeof import('./components/tables/ChannelTable.vue')['default'] ChartCard: typeof import('./components/dashboard/ChartCard.vue')['default'] ChartLegend: typeof import('./components/dashboard/ChartLegend.vue')['default'] @@ -116,7 +112,6 @@ declare module 'vue' { NativePlatformTrendChart: typeof import('./components/dashboard/NativePlatformTrendChart.vue')['default'] Navbar: typeof import('./components/Navbar.vue')['default'] OnboardingExploreBanner: typeof import('./components/dashboard/OnboardingExploreBanner.vue')['default'] - OnboardingExploreReminder: typeof import('./components/dashboard/OnboardingExploreReminder.vue')['default'] OnboardingPublishIntentIcon: typeof import('./components/dashboard/OnboardingPublishIntentIcon.vue')['default'] OnboardingPublishIntentIconMobileApp: typeof import('./components/dashboard/OnboardingPublishIntentIconMobileApp.vue')['default'] OnboardingPublishIntentIconWebPage: typeof import('./components/dashboard/OnboardingPublishIntentIconWebPage.vue')['default'] diff --git a/src/components/dashboard/AppOnboardingBuilderChecklist.vue b/src/components/dashboard/AppOnboardingBuilderChecklist.vue deleted file mode 100644 index 0d4604428e..0000000000 --- a/src/components/dashboard/AppOnboardingBuilderChecklist.vue +++ /dev/null @@ -1,225 +0,0 @@ - - - diff --git a/src/components/dashboard/AppOnboardingCliSteps.vue b/src/components/dashboard/AppOnboardingCliSteps.vue index 6b394759f9..0c6c2b25da 100644 --- a/src/components/dashboard/AppOnboardingCliSteps.vue +++ b/src/components/dashboard/AppOnboardingCliSteps.vue @@ -1,12 +1,12 @@ - - - -
-import type { OnboardingChannelEvent, OnboardingChannelEventProperties } from '~/utils/onboardingChannelAnalytics' -import { computed, ref, useId, watch } from 'vue' -import { useI18n } from 'vue-i18n' -import IconCopy from '~icons/ion/copy-outline' -import IconArrowRight from '~icons/lucide/arrow-right' -import IconCheck from '~icons/lucide/check' -import IconChevronDown from '~icons/lucide/chevron-down' -import IconCircle from '~icons/lucide/circle' -import IconFileText from '~icons/lucide/file-text' -import IconInfo from '~icons/lucide/info' -import IconLoader from '~icons/lucide/loader-2' -import IconMessageCircle from '~icons/lucide/message-circle' -import IconMinus from '~icons/lucide/minus' -import { useAppOnboardingCliProgress } from '~/composables/useAppOnboardingCliProgress' -import { getAppOnboardingStepIds } from '~/services/appOnboarding' -import { isAppOnboardingChecklistStep } from '~/utils/appOnboardingChecklist' -import { APP_ONBOARDING_STEP_GUIDES } from '~/utils/appOnboardingGuides' -import ChannelSetupOnboardingDialog from './ChannelSetupOnboardingDialog.vue' -import TechnicalTeammateInviteCard from './TechnicalTeammateInviteCard.vue' - -const props = defineProps<{ - appId: string - initialOnboarding?: unknown - command: string - hiding: boolean - leaving: boolean -}>() - -const emit = defineEmits<{ - copyCommand: [] - copyAi: [] - hide: [] - explore: [] - complete: [] - inviteOpened: [] - inviteSucceeded: [invite: { email: string, firstName: string, lastName: string }] - channelAnalytics: [event: OnboardingChannelEvent, properties: OnboardingChannelEventProperties] -}>() - -const { t } = useI18n() -const panelId = useId() -const { onboarding, refreshError, refreshOnboarding } = useAppOnboardingCliProgress(() => props.appId, () => props.initialOnboarding) -const steps = computed(() => getAppOnboardingStepIds(onboarding.value.todo_list_version, onboarding.value.ota_todo_list_version).filter(isAppOnboardingChecklistStep).map((id, index) => ({ - id, - index, - status: onboarding.value.steps[id]?.status, - title: t(`setup-checklist-step-${id}`), - description: t(`setup-checklist-description-${id}`), -}))) -const doneCount = computed(() => steps.value.filter(step => step.status === 'done' || step.status === 'skipped').length) -const currentStep = computed(() => steps.value.find(step => step.status !== 'done' && step.status !== 'skipped')) -const selectedId = ref(null) -const selectedStep = computed(() => steps.value.find(step => step.id === selectedId.value) ?? currentStep.value ?? steps.value.at(-1)!) -const isSelectedCurrent = computed(() => selectedStep.value.id === currentStep.value?.id) -const isFirstStep = computed(() => selectedStep.value.index === 0) -const guideHref = computed(() => APP_ONBOARDING_STEP_GUIDES[selectedStep.value.id]) -const guideLabel = computed(() => isFirstStep.value ? t('setup-checklist-manual-guide') : t('setup-checklist-task-guide', { task: selectedStep.value.title })) -const waitingMessageKey = computed(() => { - if (isFirstStep.value) - return 'setup-checklist-waiting-start' - if (['add_channel', 'run_device', 'upload_bundle', 'test_update'].includes(selectedStep.value.id)) - return `setup-checklist-waiting-${selectedStep.value.id}` - return 'setup-checklist-waiting-step' -}) -const completed = computed(() => onboarding.value.outcome === 'completed') -const checklistOpen = ref(false) -const channelFlowOpen = ref(false) - -watch(() => props.appId, () => { - channelFlowOpen.value = false -}) - -function closeChannelFlow() { - channelFlowOpen.value = false - void refreshOnboarding() -} - -function trackChannelEvent(event: OnboardingChannelEvent, properties: OnboardingChannelEventProperties) { - emit('channelAnalytics', event, properties) -} - -watch(() => currentStep.value?.id, (id, previous) => { - if (selectedId.value === null || selectedId.value === previous) - selectedId.value = id ?? null -}, { immediate: true }) - -function selectStep(id: string) { - selectedId.value = id - checklistOpen.value = false -} - -function statusLabel(status: string | undefined) { - if (status === 'done') - return t('app-onboarding-cli-step-done') - if (status === 'skipped') - return t('app-onboarding-cli-step-skipped') - return t('app-onboarding-cli-step-pending') -} - - - diff --git a/src/components/dashboard/ChannelCreateOnboarding.vue b/src/components/dashboard/ChannelCreateOnboarding.vue index 2491fb4ac0..5a536ab031 100644 --- a/src/components/dashboard/ChannelCreateOnboarding.vue +++ b/src/components/dashboard/ChannelCreateOnboarding.vue @@ -330,8 +330,6 @@ function continueOnboarding() { onMounted(() => { void initialize() }) - -defineExpose({ isSubmitting })