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.
-
- )
- : {
- if (choice === 'retry')
- retryCandidate()
- else {
- setMethod(services.browserAvailable ? undefined : 'paste')
- setError(undefined)
- setView(services.browserAvailable ? 'choice' : 'entry')
- }
- }}
- footer={compact ? undefined : footer}
- />
- )}
- {view === 'choice' && (compact
- ? (
-
- How would you like to log in?
-
-
- )
- :
- )}
- {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(`${call}`, '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(``, 'App.vue'), 'found')
- assert.equal(scan(`
-