diff --git a/.github/workflows/build_and_deploy.yml b/.github/workflows/build_and_deploy.yml
index 12fa454b43..ad1ff5692f 100644
--- a/.github/workflows/build_and_deploy.yml
+++ b/.github/workflows/build_and_deploy.yml
@@ -1,8 +1,8 @@
name: Build source code and deploy
concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
+ group: ${{ github.workflow }}-${{ contains(github.ref_name, '-alpha.') && 'alpha' || 'production' }}
+ cancel-in-progress: false
on:
push:
@@ -24,6 +24,10 @@ jobs:
plugins: ${{ steps.scope.outputs.plugins }}
supabase: ${{ steps.scope.outputs.supabase }}
translation: ${{ steps.scope.outputs.translation }}
+ deploy_tag: ${{ steps.target.outputs.deploy_tag }}
+ deploy_sha: ${{ steps.target.outputs.deploy_sha }}
+ is_alpha: ${{ steps.target.outputs.is_alpha }}
+ requires_schema_types_sync: ${{ steps.scope.outputs.requires_schema_types_sync }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -34,11 +38,20 @@ jobs:
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
+ - name: Resolve triggering deployment target
+ id: target
+ run: bun scripts/resolve-deploy-tag.ts --resolve "${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
+ - name: Reject stale queued deployment
+ run: bun scripts/resolve-deploy-tag.ts --assert-current "${{ github.ref_name }}"
+ - name: Checkout deployment target
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ steps.target.outputs.deploy_sha }}
+ fetch-depth: 0
+ filter: blob:none
- name: Resolve deploy scope
id: scope
- env:
- REF_NAME: ${{ github.ref_name }}
- run: bun scripts/deploy-scope.ts "$REF_NAME" >> "$GITHUB_OUTPUT"
+ run: bun scripts/deploy-scope.ts "${{ steps.target.outputs.deploy_tag }}" >> "$GITHUB_OUTPUT"
# Tests already passed before tag was created, so just build and deploy
supabase_deploy:
@@ -56,13 +69,19 @@ jobs:
node-version: 24.x
- name: Checkout
uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.changes.outputs.deploy_sha }}
+ fetch-depth: 0
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
+ - name: Reject stale deployment retry
+ if: ${{ github.run_attempt > 1 }}
+ run: bun scripts/resolve-deploy-tag.ts --assert-current "${{ needs.changes.outputs.deploy_tag }}"
- name: Set environment variable
run: |
- if [[ ${{ github.ref }} == *-alpha* ]]; then
+ if [ "${{ needs.changes.outputs.is_alpha }}" = "true" ]; then
echo "SUPA_ENV=ALPHA" >> $GITHUB_ENV
else
echo "SUPA_ENV=PROD" >> $GITHUB_ENV
@@ -116,7 +135,7 @@ jobs:
read_replica_schema:
needs: changes
- if: ${{ needs.changes.result == 'success' && needs.changes.outputs.supabase == 'true' && !contains(github.ref_name, '-alpha') }}
+ if: ${{ needs.changes.result == 'success' && needs.changes.outputs.supabase == 'true' && needs.changes.outputs.is_alpha != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 40
name: Reconcile production read-replica schema
@@ -125,10 +144,16 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.changes.outputs.deploy_sha }}
+ fetch-depth: 0
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
+ - name: Reject stale deployment retry
+ if: ${{ github.run_attempt > 1 }}
+ run: bun scripts/resolve-deploy-tag.ts --assert-current "${{ needs.changes.outputs.deploy_tag }}"
- name: Setup Google Cloud CLI
uses: google-github-actions/setup-gcloud@v2
- name: Install dependencies
@@ -156,6 +181,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
+ ref: ${{ needs.changes.outputs.deploy_sha }}
fetch-depth: 0
filter: blob:none
- uses: actions/setup-node@v6
@@ -165,11 +191,14 @@ jobs:
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
+ - name: Reject stale deployment retry
+ if: ${{ github.run_attempt > 1 }}
+ run: bun scripts/resolve-deploy-tag.ts --assert-current "${{ needs.changes.outputs.deploy_tag }}"
- name: Install dependencies
run: bun install
- name: Set environment variable
run: |
- if [[ ${{ github.ref }} == *-alpha* ]]; then
+ if [ "${{ needs.changes.outputs.is_alpha }}" = "true" ]; then
echo "ENV=dev" >> $GITHUB_ENV
echo "CHANNEL=dev" >> $GITHUB_ENV
else
@@ -227,10 +256,11 @@ jobs:
---
- π **Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.changelog.outputs.from_tag || github.ref_name }}...${{ steps.changelog.outputs.to_tag || github.ref_name }}
+ π **Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.changelog.outputs.from_tag || needs.changes.outputs.deploy_tag }}...${{ steps.changelog.outputs.to_tag || needs.changes.outputs.deploy_tag }}
make_latest: true
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
- prerelease: ${{ contains(github.ref, '-alpha.') }}
+ tag_name: ${{ needs.changes.outputs.deploy_tag }}
+ prerelease: ${{ needs.changes.outputs.is_alpha == 'true' }}
deploy_api:
needs: [changes, supabase_deploy, read_replica_schema]
@@ -240,6 +270,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.changes.outputs.deploy_sha }}
+ fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: 24
@@ -247,11 +280,14 @@ jobs:
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
+ - name: Reject stale deployment retry
+ if: ${{ github.run_attempt > 1 }}
+ run: bun scripts/resolve-deploy-tag.ts --assert-current "${{ needs.changes.outputs.deploy_tag }}"
- name: Install dependencies
run: bun install
- name: Set environment variable
run: |
- if [[ ${{ github.ref }} == *-alpha* ]]; then
+ if [ "${{ needs.changes.outputs.is_alpha }}" = "true" ]; then
echo "ENV=dev" >> $GITHUB_ENV
else
echo "ENV=prod" >> $GITHUB_ENV
@@ -270,6 +306,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.changes.outputs.deploy_sha }}
+ fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: 24
@@ -277,11 +316,14 @@ jobs:
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
+ - name: Reject stale deployment retry
+ if: ${{ github.run_attempt > 1 }}
+ run: bun scripts/resolve-deploy-tag.ts --assert-current "${{ needs.changes.outputs.deploy_tag }}"
- name: Install dependencies
run: bun install
- name: Set environment variable
run: |
- if [[ ${{ github.ref }} == *-alpha* ]]; then
+ if [ "${{ needs.changes.outputs.is_alpha }}" = "true" ]; then
echo "ENV=dev" >> $GITHUB_ENV
else
echo "ENV=prod" >> $GITHUB_ENV
@@ -300,6 +342,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.changes.outputs.deploy_sha }}
+ fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: 24
@@ -307,11 +352,14 @@ jobs:
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
+ - name: Reject stale deployment retry
+ if: ${{ github.run_attempt > 1 }}
+ run: bun scripts/resolve-deploy-tag.ts --assert-current "${{ needs.changes.outputs.deploy_tag }}"
- name: Install dependencies
run: bun install
- name: Set environment variable
run: |
- if [[ ${{ github.ref }} == *-alpha* ]]; then
+ if [ "${{ needs.changes.outputs.is_alpha }}" = "true" ]; then
echo "ENV=dev" >> $GITHUB_ENV
else
echo "ENV=prod" >> $GITHUB_ENV
@@ -363,6 +411,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.changes.outputs.deploy_sha }}
+ fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: 24
@@ -370,11 +421,14 @@ jobs:
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
+ - name: Reject stale deployment retry
+ if: ${{ github.run_attempt > 1 }}
+ run: bun scripts/resolve-deploy-tag.ts --assert-current "${{ needs.changes.outputs.deploy_tag }}"
- name: Install dependencies
run: bun install
- name: Set environment variable
run: |
- if [[ ${{ github.ref }} == *-alpha* ]]; then
+ if [ "${{ needs.changes.outputs.is_alpha }}" = "true" ]; then
echo "ENV=dev" >> $GITHUB_ENV
else
echo "ENV=prod" >> $GITHUB_ENV
@@ -400,13 +454,19 @@ jobs:
node-version: 24.x
- name: Checkout
uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.changes.outputs.deploy_sha }}
+ fetch-depth: 0
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
+ - name: Reject stale deployment retry
+ if: ${{ github.run_attempt > 1 }}
+ run: bun scripts/resolve-deploy-tag.ts --assert-current "${{ needs.changes.outputs.deploy_tag }}"
- name: Set environment variable
run: |
- if [[ ${{ github.ref }} == *-alpha* ]]; then
+ if [ "${{ needs.changes.outputs.is_alpha }}" = "true" ]; then
echo "BUILD_SCRIPT=build:dev" >> $GITHUB_ENV
else
echo "BUILD_SCRIPT=build" >> $GITHUB_ENV
@@ -446,13 +506,19 @@ jobs:
node-version: 24.x
- name: Checkout
uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.changes.outputs.deploy_sha }}
+ fetch-depth: 0
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
+ - name: Reject stale deployment retry
+ if: ${{ github.run_attempt > 1 }}
+ run: bun scripts/resolve-deploy-tag.ts --assert-current "${{ needs.changes.outputs.deploy_tag }}"
- name: Set environment variable
run: |
- if [[ ${{ github.ref }} == *-alpha* ]]; then
+ if [ "${{ needs.changes.outputs.is_alpha }}" = "true" ]; then
echo "BUILD_SCRIPT=build:dev" >> $GITHUB_ENV
else
echo "BUILD_SCRIPT=build" >> $GITHUB_ENV
@@ -474,3 +540,109 @@ jobs:
BUILD_OUTPUT_UPLOAD_ENABLED: "true"
BUILD_OUTPUT_RETENTION_SECONDS: "604800"
run: bunx @capgo/cli@latest build request --platform android --path . --sync-android-version --ai-analytics
+
+ sync_schema_types:
+ needs: [changes, read_replica_schema, supabase_deploy]
+ if: ${{ needs.changes.result == 'success' && needs.changes.outputs.is_alpha != 'true' && needs.changes.outputs.requires_schema_types_sync == 'true' && needs.read_replica_schema.result == 'success' && needs.supabase_deploy.result == 'success' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ name: Sync production schema and generated types
+ permissions:
+ contents: write
+ steps:
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24.x
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.changes.outputs.deploy_sha }}
+ token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
+ fetch-depth: 0
+ filter: blob:none
+ - name: Setup bun
+ run: bash scripts/setup-bun.sh
+ - name: Prepare production schema access
+ env:
+ SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_TOKEN }}
+ SUPABASE_PROJECT_ID_PROD: ${{ secrets.SUPABASE_PROJECT_ID_PROD }}
+ run: |
+ if [ -z "$SUPABASE_PROJECT_ID_PROD" ]; then
+ echo "SUPABASE_PROJECT_ID_PROD is required for schema/types sync" >&2
+ exit 1
+ fi
+
+ bunx supabase --version
+ bunx supabase link --project-ref "$SUPABASE_PROJECT_ID_PROD"
+ - name: Sync generated schema and types
+ env:
+ DEPLOY_TAG: ${{ needs.changes.outputs.deploy_tag }}
+ SCHEMA_TYPES_REMOTE_URL: https://x-access-token:${{ secrets.PERSONAL_ACCESS_TOKEN }}@github.com/${{ github.repository }}.git
+ SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_TOKEN }}
+ run: |
+ set -euo pipefail
+
+ generated_files=(
+ supabase/schemas/prod.sql
+ src/types/supabase.types.ts
+ supabase/functions/_backend/utils/supabase.types.ts
+ )
+ remote_repo="$SCHEMA_TYPES_REMOTE_URL"
+
+ git config --local user.name "github-actions[bot]"
+ git config --local user.email "github-actions[bot]@users.noreply.github.com"
+
+ for attempt in 1 2 3; do
+ echo "Schema/types publication attempt $attempt of 3"
+ git fetch --tags "$remote_repo" "+refs/heads/main:refs/remotes/schema-sync/main"
+ main_sha="$(git rev-parse refs/remotes/schema-sync/main)"
+
+ if ! bun scripts/resolve-deploy-tag.ts --assert-current "$DEPLOY_TAG"; then
+ echo "Schema/types sync deferred: $DEPLOY_TAG is no longer the current production tag."
+ exit 0
+ fi
+
+ pending_migrations="$(git diff --name-only "$DEPLOY_TAG" "$main_sha" -- supabase/migrations)"
+ if [ -n "$pending_migrations" ]; then
+ echo "Schema/types sync deferred: main contains migrations that are not present in $DEPLOY_TAG."
+ printf '%s\n' "$pending_migrations"
+ exit 0
+ fi
+
+ git checkout --detach "$main_sha"
+ bun install --frozen-lockfile
+ bun schemas
+ BRANCH=main bun types
+
+ if git diff --quiet -- "${generated_files[@]}"; then
+ echo "No schema/types drift detected."
+ exit 0
+ fi
+
+ echo "Schema/types drift detected; verifying regenerated types compile against current main."
+ if ! bun typecheck; then
+ echo "Schema/types sync deferred: production-generated types do not compile against current main." >&2
+ exit 0
+ fi
+
+ git add -- "${generated_files[@]}"
+ git commit -m "chore(auto-sync): update supabase schema and generated types"
+
+ git fetch --tags "$remote_repo"
+ if ! bun scripts/resolve-deploy-tag.ts --assert-current "$DEPLOY_TAG"; then
+ echo "Schema/types sync deferred: a newer production tag was published during generation."
+ exit 0
+ fi
+
+ status="$(bun scripts/publish-schema-types.ts main "$main_sha")"
+ if [ "$status" = "published" ]; then
+ echo "Schema/types commit published from main at $main_sha."
+ exit 0
+ fi
+
+ echo "Main moved during schema/types generation; regenerating from the new head."
+ done
+
+ echo "Schema/types publication could not obtain a stable main snapshot after 3 attempts." >&2
+ exit 1
diff --git a/.github/workflows/bump_version.yml b/.github/workflows/bump_version.yml
index 75f6295191..831854e4ef 100644
--- a/.github/workflows/bump_version.yml
+++ b/.github/workflows/bump_version.yml
@@ -1,5 +1,9 @@
name: Bump version
+concurrency:
+ group: ${{ github.run_attempt == 1 && !startsWith(github.event.head_commit.message, 'chore(release):') && !startsWith(github.event.head_commit.message, 'chore(auto-sync):') && format('bump-version-{0}', github.ref) || format('bump-version-isolated-{0}-{1}', github.run_id, github.run_attempt) }}
+ cancel-in-progress: true
+
on:
push:
branches:
@@ -10,7 +14,7 @@ permissions: {}
jobs:
changes:
- if: ${{ !startsWith(github.event.head_commit.message, 'chore(release):') && !startsWith(github.event.head_commit.message, 'chore(auto-sync):') }}
+ if: ${{ !startsWith(github.event.head_commit.message, 'chore(release):') }}
permissions:
contents: read
runs-on: ubuntu-latest
@@ -21,7 +25,6 @@ jobs:
cli_release_as: ${{ steps.cli.outputs.release_as }}
run_notifications: ${{ steps.notifications.outputs.should_release }}
notifications_release_as: ${{ steps.notifications.outputs.release_as }}
- has_migration_changes: ${{ steps.migration_scope.outputs.has_migration_changes }}
run_any: ${{ steps.capgo.outputs.should_release == 'true' || steps.cli.outputs.should_release == 'true' || steps.notifications.outputs.should_release == 'true' }}
steps:
- name: Setup Node.js
@@ -33,26 +36,48 @@ jobs:
with:
fetch-depth: 0
filter: blob:none
+ - name: Validate safe version workflow re-run
+ if: ${{ github.run_attempt > 1 }}
+ run: |
+ set -euo pipefail
+
+ current_sha="$(git ls-remote --heads origin "refs/heads/$GITHUB_REF_NAME" | cut -f1)"
+ if [ "$current_sha" != "$GITHUB_SHA" ]; then
+ echo "::error::$GITHUB_REF_NAME advanced after this workflow started. Retry the latest commit instead."
+ exit 1
+ fi
+
+ git fetch --force --prune --prune-tags origin '+refs/tags/*:refs/tags/*'
+ if [ "$GITHUB_REF_NAME" = "main" ]; then
+ all_release_tags="$(git tag --contains "$GITHUB_SHA" --list 'capgo-[0-9]*' 'cli-[0-9]*' 'notifications-[0-9]*')"
+ release_tags="$(printf '%s\n' "$all_release_tags" | grep -v -- '-alpha\.' || true)"
+ else
+ release_tags="$(git tag --contains "$GITHUB_SHA" --list 'capgo-*-alpha.*' 'cli-*-alpha.*' 'notifications-*-alpha.*')"
+ fi
+ if [ -n "$release_tags" ]; then
+ echo "::error::A release tag already contains $GITHUB_SHA; this workflow must not publish it again."
+ printf '%s\n' "$release_tags"
+ exit 1
+ fi
- name: Setup bun
run: bash scripts/setup-bun.sh
+ - name: Resolve release environment
+ id: release_environment
+ run: |
+ if [ "$GITHUB_REF" = "refs/heads/main" ]; then
+ echo "scope_mode=--latest-stable" >> "$GITHUB_OUTPUT"
+ else
+ echo "scope_mode=--latest-alpha" >> "$GITHUB_OUTPUT"
+ fi
- name: Resolve Capgo release scope
id: capgo
- run: bun scripts/release-scope.ts capgo "${{ github.event.before }}" "${{ github.sha }}" >> "$GITHUB_OUTPUT"
+ run: bun scripts/release-scope.ts capgo "${{ steps.release_environment.outputs.scope_mode }}" "${{ github.sha }}" >> "$GITHUB_OUTPUT"
- name: Resolve CLI release scope
id: cli
- run: bun scripts/release-scope.ts cli "${{ github.event.before }}" "${{ github.sha }}" >> "$GITHUB_OUTPUT"
+ run: bun scripts/release-scope.ts cli "${{ steps.release_environment.outputs.scope_mode }}" "${{ github.sha }}" >> "$GITHUB_OUTPUT"
- name: Resolve notifications plugin release scope
id: notifications
- run: bun scripts/release-scope.ts notifications "${{ github.event.before }}" "${{ github.sha }}" >> "$GITHUB_OUTPUT"
- - name: Resolve migration scope
- id: migration_scope
- run: |
- if git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" | grep -q '^supabase/migrations/'; then
- echo "has_migration_changes=true" >> "$GITHUB_OUTPUT"
- else
- echo "has_migration_changes=false" >> "$GITHUB_OUTPUT"
- fi
-
+ run: bun scripts/release-scope.ts notifications "${{ steps.release_environment.outputs.scope_mode }}" "${{ github.sha }}" >> "$GITHUB_OUTPUT"
# Run only the relevant test scope before creating tags
test:
needs: changes
@@ -71,12 +96,14 @@ jobs:
# Only bump the changed release scopes and create matching tags after tests pass
bump-version:
needs: [changes, test]
- if: ${{ !startsWith(github.event.head_commit.message, 'chore(release):') && !startsWith(github.event.head_commit.message, 'chore(auto-sync):') && needs.changes.outputs.run_any == 'true' && needs.test.result == 'success' }}
+ if: ${{ !startsWith(github.event.head_commit.message, 'chore(release):') && needs.changes.outputs.run_any == 'true' && needs.test.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 30
name: Bump versions and create tags
permissions:
contents: write
+ outputs:
+ published: ${{ steps.publish.outputs.published }}
steps:
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -88,11 +115,38 @@ jobs:
fetch-depth: 0
filter: blob:none
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
+ - name: Validate safe version workflow re-run
+ if: ${{ github.run_attempt > 1 }}
+ run: |
+ set -euo pipefail
+
+ current_sha="$(git ls-remote --heads origin "refs/heads/$GITHUB_REF_NAME" | cut -f1)"
+ if [ "$current_sha" != "$GITHUB_SHA" ]; then
+ echo "::error::$GITHUB_REF_NAME advanced after this workflow started. Retry the latest commit instead."
+ exit 1
+ fi
+
+ git fetch --force --prune --prune-tags origin '+refs/tags/*:refs/tags/*'
+ if [ "$GITHUB_REF_NAME" = "main" ]; then
+ all_release_tags="$(git tag --contains "$GITHUB_SHA" --list 'capgo-[0-9]*' 'cli-[0-9]*' 'notifications-[0-9]*')"
+ release_tags="$(printf '%s\n' "$all_release_tags" | grep -v -- '-alpha\.' || true)"
+ else
+ release_tags="$(git tag --contains "$GITHUB_SHA" --list 'capgo-*-alpha.*' 'cli-*-alpha.*' 'notifications-*-alpha.*')"
+ fi
+ if [ -n "$release_tags" ]; then
+ echo "::error::A release tag already contains $GITHUB_SHA; this workflow must not publish it again."
+ printf '%s\n' "$release_tags"
+ exit 1
+ fi
- name: Setup bun
run: bash scripts/setup-bun.sh
- name: Install dependencies
if: ${{ needs.changes.outputs.run_capgo == 'true' || needs.changes.outputs.run_cli == 'true' || needs.changes.outputs.run_notifications == 'true' }}
run: bun install --frozen-lockfile
+ - name: Record tested release state
+ run: |
+ git rev-parse HEAD > "$RUNNER_TEMP/release-base-sha"
+ git tag --list | sort > "$RUNNER_TEMP/release-tags-before"
- name: Resolve Capgo native release bump
id: capgo_version
if: ${{ needs.changes.outputs.run_capgo == 'true' }}
@@ -228,74 +282,19 @@ jobs:
fi
fi
fi
- - name: Push to origin
- run: |
- set -e
-
- CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
- remote_repo="https://${GITHUB_ACTOR}:${{ secrets.PERSONAL_ACCESS_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git"
- git pull --rebase=false $remote_repo $CURRENT_BRANCH
- git push $remote_repo HEAD:$CURRENT_BRANCH --follow-tags --tags
-
- sync_schema_types:
- needs: [changes, bump-version]
- if: ${{ github.ref == 'refs/heads/main' && needs.changes.outputs.has_migration_changes == 'true' && !startsWith(github.event.head_commit.message, 'chore(release):') && !startsWith(github.event.head_commit.message, 'chore(auto-sync):') }}
- runs-on: ubuntu-latest
- timeout-minutes: 30
- permissions:
- contents: write
- steps:
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version: 24.x
- - name: Checkout
- uses: actions/checkout@v6
- with:
- ref: main
- token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
- fetch-depth: 0
- filter: blob:none
- - name: Setup bun
- run: bash scripts/setup-bun.sh
- - name: Install dependencies
- run: bun install --frozen-lockfile
- - name: Sync generated schema and types
+ - name: Publish release atomically
+ id: publish
env:
- SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_TOKEN }}
+ RELEASE_REMOTE_URL: https://x-access-token:${{ secrets.PERSONAL_ACCESS_TOKEN }}@github.com/${{ github.repository }}.git
run: |
- if [ -z "${{ secrets.SUPABASE_PROJECT_ID_PROD }}" ]; then
- echo "SUPABASE_PROJECT_ID_PROD is required for schema/types sync" >&2
- exit 1
- fi
-
- bunx supabase --version
- bunx supabase link --project-ref ${{ secrets.SUPABASE_PROJECT_ID_PROD }}
- bun schemas
- BRANCH=main bun types
-
- if git diff --quiet supabase/schemas/prod.sql src/types/supabase.types.ts supabase/functions/_backend/utils/supabase.types.ts; then
- echo "No schema/types drift detected."
- exit 0
- fi
+ set -e
- # Gate the auto-sync on its own output: the regenerated types are only
- # trustworthy if they still compile against the backend that consumes
- # them. Prod is often behind local migrations, so a regenerated types
- # file can drop columns the code still references. Skip the push in
- # that case instead of landing an unverified commit that reddens
- # `main` β and exit 0 so migration-touching releases are not blocked.
- echo "Schema/types drift detected; verifying regenerated types compile against the codebase."
- if ! bun typecheck; then
- echo "Regenerated schema/types do not compile (prod likely behind local migrations). Skipping auto-sync push." >&2
- git checkout -- supabase/schemas/prod.sql src/types/supabase.types.ts supabase/functions/_backend/utils/supabase.types.ts
- exit 0
+ status="$(bun scripts/publish-release.ts \
+ "$GITHUB_REF_NAME" \
+ "$(< "$RUNNER_TEMP/release-base-sha")" \
+ "$RUNNER_TEMP/release-tags-before")"
+ if [ "$status" = "published" ]; then
+ echo "published=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "published=false" >> "$GITHUB_OUTPUT"
fi
-
- git config --local user.name "github-actions[bot]"
- git config --local user.email "github-actions[bot]@users.noreply.github.com"
- git add supabase/schemas/prod.sql src/types/supabase.types.ts supabase/functions/_backend/utils/supabase.types.ts
- git commit -m "chore(auto-sync): update supabase schema and generated types"
-
- remote_repo="https://x-access-token:${{ secrets.PERSONAL_ACCESS_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git"
- git push "$remote_repo" HEAD:main
diff --git a/.github/workflows/publish_notifications.yml b/.github/workflows/publish_notifications.yml
index a0e9c6e58c..387516db04 100644
--- a/.github/workflows/publish_notifications.yml
+++ b/.github/workflows/publish_notifications.yml
@@ -35,6 +35,8 @@ jobs:
run: bun install --frozen-lockfile
- name: Typecheck notifications plugin
run: bun run --cwd packages/capacitor-notifications typecheck
+ - name: Check Cap 9 deprecated APIs (notifications plugin)
+ run: bun run --cwd packages/capacitor-notifications check:cap9-deprecated
- name: Build notifications plugin
run: bun run --cwd packages/capacitor-notifications build
- name: Resolve previous successful notifications release
@@ -56,16 +58,40 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
FROM_TAG: ${{ steps.changelog_base.outputs.from_tag }}
run: bun run changelog:ai
- - name: Publish notifications plugin to npm
+ - name: Setup Node.js for npm publish
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24.x
+ registry-url: 'https://registry.npmjs.org'
+ - name: Install npm CLI for staged publishing
+ run: npm install -g npm@^11.15.0
+ - name: Stage notifications plugin to npm
if: ${{ !contains(github.ref, '-alpha.') }}
+ working-directory: packages/capacitor-notifications
env:
- NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: bun publish --cwd packages/capacitor-notifications --access public
- - name: Publish notifications plugin to npm with next tag
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ run: |
+ npm --version
+ npm stage publish --tag latest --provenance --access public --ignore-scripts
+ - name: Stage notifications plugin to npm with next tag
if: ${{ contains(github.ref, '-alpha.') }}
+ working-directory: packages/capacitor-notifications
env:
- NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: bun publish --cwd packages/capacitor-notifications --tag next --access public
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ run: |
+ npm --version
+ npm stage publish --tag next --provenance --access public --ignore-scripts
+ - name: Request stage approval
+ continue-on-error: true
+ working-directory: packages/capacitor-notifications
+ env:
+ GH_TOKEN: ${{ secrets.NPM_STAGE_DISPATCH_TOKEN }}
+ run: |
+ gh api --method POST repos/Cap-go/automations/dispatches \
+ -f event_type=npm-stage-approve \
+ -f "client_payload[repository]=${GITHUB_REPOSITORY}" \
+ -f "client_payload[run_id]=${GITHUB_RUN_ID}" \
+ -f "client_payload[package]=$(node -p "require('./package.json').name")"
- name: Create GitHub release
id: create_release
uses: softprops/action-gh-release@v2
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 705daa09e2..d2b541cd14 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -221,6 +221,8 @@ jobs:
run: bun install --frozen-lockfile
- name: Typecheck notifications plugin
run: bun run --cwd packages/capacitor-notifications typecheck
+ - name: Check Cap 9 deprecated APIs (notifications plugin)
+ run: bun run --cwd packages/capacitor-notifications check:cap9-deprecated
- name: Build notifications plugin
run: bun run --cwd packages/capacitor-notifications build
- name: Verify notifications publish access
@@ -450,6 +452,9 @@ jobs:
warm_post /organization
warm_post /organization/members
warm_post /apikey
+ warm_post /private/events
+ warm_get '/app?limit=1'
+ warm_get /private/role_bindings
- name: Run backend integration tests
env:
VITEST_SHARD: ${{ matrix.shard }}
diff --git a/.github/workflows/upload_screenshots.yml b/.github/workflows/upload_screenshots.yml
index abbb1d6be2..aff0385fee 100644
--- a/.github/workflows/upload_screenshots.yml
+++ b/.github/workflows/upload_screenshots.yml
@@ -39,7 +39,7 @@ jobs:
} > "$BUNDLE_GEMFILE"
- uses: ruby/setup-ruby@v1
with:
- ruby-version: '3.4'
+ ruby-version: '3.4.10'
- name: Install fastlane
run: |
bundle config set path .bundle/vendor
@@ -72,7 +72,7 @@ jobs:
} > "$BUNDLE_GEMFILE"
- uses: ruby/setup-ruby@v1
with:
- ruby-version: '3.4'
+ ruby-version: '3.4.10'
- name: Install fastlane
run: |
bundle config set path .bundle/vendor
diff --git a/.typos.toml b/.typos.toml
index bf2ec9759c..0d1a46ad28 100644
--- a/.typos.toml
+++ b/.typos.toml
@@ -80,4 +80,5 @@ unparseable = "unparseable" # Valid English synonym of "unparsable".
CIPS = "CIPS" # real App Store Connect session role value (ASC key helper)
ITMS = "ITMS" # App Store Connect upload error-code prefix (ITMS-90704, ITMS-90474, ...) cited in iOS prescan findings
loca = "loca" # localtunnel host suffix *.loca.lt detected by the iOS capacitor-server-url check
+adress = "adress" # Preserve the requested request_actor_email_adress RPC name.
# Add more project-specific terms as needed
diff --git a/AGENTS.md b/AGENTS.md
index 5459dafcb5..1491685929 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -295,6 +295,13 @@ Capgo relies on two layered caches for plugin endpoints (`/updates`, `/stats`, `
**Implication:** Keep the `429` + error payloads for on-prem and plan-upgrade responses; otherwise the edge caches and status cache effectiveness are broken.
+### Files read cache (deleted bundle gate)
+
+`isAttachmentVersionDeleted` in `file_read_cache.ts` is the durable gate for deleted bundle
+`.zip` reads on `/files/read`. It must **fail open** on Postgres/Hyperdrive lookup errors
+(treat as not deleted) so transient DB blips do not 404 live bundles that still exist in R2
+or edge cache. Still return deleted when the DB row is deleted or a deleted marker is set.
+
### Key Frontend Directories
- **`src/components/`** - Reusable Vue components
@@ -1190,6 +1197,32 @@ You are not allowed to deploy on your own, unless if asked. Same for git you
never git push on main branch, add or commit unless asked.
You can do it in others branches
+### Release and deployment retry contract
+
+Release generation and production deployment are handled by
+`.github/workflows/bump_version.yml` and `.github/workflows/build_and_deploy.yml`.
+When changing either workflow or the helpers below, preserve this contract:
+
+- The complete post-merge test workflow remains mandatory before any release
+ commit or tag is published.
+- Release scope is cumulative per component: pending work is calculated from the
+ latest successful component tag (`capgo-*`, `cli-*`, `notifications-*`) to the
+ tested commit, not from the push event range.
+- Release publication uses `scripts/publish-release.ts` with an atomic push and
+ compare-and-swap semantics. Do not reintroduce `git pull` merges into generated
+ release output.
+- Failed production deployments recover with GitHub **Re-run all jobs**, not
+ **Re-run failed jobs**. Full reruns re-resolve the newest stable or alpha Capgo
+ tag through `scripts/resolve-deploy-tag.ts` and deploy that immutable target.
+- Design reference: `docs/superpowers/specs/2026-09-09-reliable-release-retries-design.md`
+
+## Frontend security operations
+
+Console CSP, SRI maintenance, sanitization helpers, and redirect validation are
+documented in [docs/frontend-security.md](docs/frontend-security.md). Review
+that checklist when touching `public/_headers`, external scripts, or user-controlled
+HTML/URL rendering.
+
## Graphify
The project-scoped Graphify skill lives at `.agents/skills/graphify/SKILL.md`.
diff --git a/BOUNTY.md b/BOUNTY.md
index 58bc3a6272..ab08e12408 100644
--- a/BOUNTY.md
+++ b/BOUNTY.md
@@ -25,7 +25,8 @@
Anyone from the community can review the pull request and leave comments.
-Review are rewarded with a tip of $20. On merged pull request.
+Review are rewarded with a tip of $20 when requested on merged pull request.
+AI review does not qualify.
## What is a good review?
diff --git a/bun.lock b/bun.lock
index 7e7cea7f9d..bc7c0ef313 100644
--- a/bun.lock
+++ b/bun.lock
@@ -203,15 +203,16 @@
"vite-plugin-vue-layouts": "0.11.0",
"vite-plugin-webfont-dl": "^3.12.0",
"vitest": "^4.1.10",
- "vue-tsc": "3.3.7",
+ "vue-tsc": "3.3.11",
"wrangler": "^4.113.0",
+ "yaml": "^2.9.0",
"zod": "^4.4.3",
"zod-compiler": "^1.15.0",
},
},
"cli": {
"name": "@capgo/cli",
- "version": "8.46.0",
+ "version": "8.47.1",
"bin": {
"capgo": "dist/index.js",
},
@@ -259,6 +260,7 @@
"@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",
@@ -1479,7 +1481,7 @@
"@vue/devtools-shared": ["@vue/devtools-shared@8.1.5", "", {}, "sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ=="],
- "@vue/language-core": ["@vue/language-core@3.3.7", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-LzmkKinXAMMoh8Jfi/jMUSDUjuPdv8mynH5WJGKfXyZtDw3hQ6GBaoI6Bcnl/Xqlu32q/0Z6i/trp4VXykzyLw=="],
+ "@vue/language-core": ["@vue/language-core@3.3.11", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw=="],
"@vue/reactivity": ["@vue/reactivity@3.5.40", "", { "dependencies": { "@vue/shared": "3.5.40" } }, "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA=="],
@@ -2741,7 +2743,7 @@
"vue-sonner": ["vue-sonner@2.0.9", "", { "peerDependencies": { "@nuxt/kit": "^4.0.3", "@nuxt/schema": "^4.0.3", "nuxt": "^4.0.3" }, "optionalPeers": ["@nuxt/kit", "@nuxt/schema", "nuxt"] }, "sha512-i6BokNlNDL93fpzNxN/LZSn6D6MzlO+i3qXt6iVZne3x1k7R46d5HlFB4P8tYydhgqOrRbIZEsnRd3kG7qGXyw=="],
- "vue-tsc": ["vue-tsc@3.3.7", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.7" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-+C+rgD49wAQ5bUTl2sp5a8Bzg4YoldMNXM+g7CFe604MYcQ8PrZPMQhIjJSzKXtPBCa+C5ayMipqjbA7splekQ=="],
+ "vue-tsc": ["vue-tsc@3.3.11", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.11" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ=="],
"vue-turnstile": ["vue-turnstile@1.0.11", "", { "peerDependencies": { "vue": "^3.2.45" } }, "sha512-iaTBoZ5oUqtNRto6bmbn6FQvW0h/sK7mPUJc1Qn4em+cELXN59U2FQTcpWfKssV3OY6lEZzmCpcn/zrb7htK3A=="],
diff --git a/cli/README.md b/cli/README.md
index 8417e60ea1..17f27d332a 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -86,6 +86,10 @@ npx @capgo/cli@latest bundle upload com.example.app \
Add `--fail-on-incompatible` when CI must stop instead of uploading a bundle that
cannot safely update the current native build.
+Add `--accept-incompatible` when the mismatch is intentional (for example your
+JavaScript already checks that a native plugin exists before using it). The
+upload still warns, but Capgo will not send the crash-warning email.
+
## Documentation
The most complete [documentation is here](https://capgo.app/docs/).
@@ -138,6 +142,7 @@ 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)
@@ -152,7 +157,7 @@ Capgo continues to load the root config while writing only the selected source.
- [Create](#key-create)
- [Delete_old](#key-delete_old)
- π€ [Account](#account)
- - [Id](#account-id)
+ - [Whoami](#account-whoami)
- πΉ [Organization](#organization)
- [List](#organization-list)
- [Add](#organization-add)
@@ -173,6 +178,7 @@ Capgo continues to load the root config while writing only the selected source.
- [Last-output](#build-last-output)
- [Credentials](#build-credentials)
- [Apple-key](#build-credentials-apple-key)
+ - [Ios-provisioning](#build-credentials-ios-provisioning)
- [Save](#build-credentials-save)
- [List](#build-credentials-list)
- [Clear](#build-credentials-clear)
@@ -183,6 +189,13 @@ Capgo continues to load the root config while writing only the selected source.
- πΉ [Notifications](#notifications)
- [Setup](#notifications-setup)
- πΉ [Probe](#probe)
+- π [Observe](#observe)
+ - [Summary](#observe-summary)
+ - [Metrics](#observe-metrics)
+ - [Events](#observe-events)
+ - [Device](#observe-device)
+ - [Versions](#observe-versions)
+ - [Routes](#observe-routes)
- πΉ [Generate-docs](#generate-docs)
- πΉ [Mcp](#mcp)
@@ -224,7 +237,7 @@ npx @capgo/cli@latest init YOUR_API_KEY com.example.app
π± Run Capacitor apps on devices from the CLI.
-### πΉ **Device**
+### π± **Device**
```bash
npx @capgo/cli@latest run device
@@ -412,7 +425,8 @@ npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel prod
| **--min-update-version** | string | Minimal version required to update to this version. Used only if the disable auto update is set to metadata in channel |
| **--auto-min-update-version** | boolean | Set the min update version based on native packages |
| **--ignore-metadata-check** | boolean | Ignores the metadata (node_modules) check when uploading |
-| **--fail-on-incompatible** | boolean | Fail the upload (exit non-zero) instead of uploading when the bundle is incompatible with the channel's current native packages. In an interactive terminal you can still choose a native build; declining fails. Cannot be combined with --ignore-metadata-check. |
+| **--fail-on-incompatible** | boolean | Fail the upload (exit non-zero) instead of uploading when the bundle is incompatible with the channel's current native packages. In an interactive terminal you can still choose a native build; declining fails. Cannot be combined with --ignore-metadata-check or --accept-incompatible. |
+| **--accept-incompatible** | boolean | 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. Cannot be combined with --fail-on-incompatible or --ignore-metadata-check. |
| **--ignore-checksum-check** | boolean | Ignores the checksum check when uploading |
| **--force-crc32-checksum** | boolean | Force CRC32 checksum for upload (override auto-detection) |
| **--timeout** | string | Timeout for the upload process in seconds |
@@ -730,6 +744,31 @@ 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
@@ -990,6 +1029,7 @@ npx @capgo/cli@latest channel set production com.example.app --bundle 1.0.0 --st
| **--send-update-notification** | boolean | Send a native update-check notification to devices after updating the linked channel bundle |
| **--package-json** | string | Paths to package.json files for monorepos (comma-separated) |
| **--ignore-metadata-check** | boolean | Ignore checking node_modules compatibility if present in the bundle |
+| **--accept-incompatible** | boolean | 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. Cannot be combined with --ignore-metadata-check. |
| **--supa-host** | string | Custom Supabase host URL (for self-hosting or Capgo development) |
| **--supa-anon** | string | Custom Supabase anon key (for self-hosting) |
@@ -1071,18 +1111,20 @@ npx @capgo/cli@latest key delete_old
π€ Manage your Capgo account details and retrieve information for support or collaboration.
-### πΉ **Id**
+### πΉ **Whoami**
+
+**Alias:** `id`
```bash
-npx @capgo/cli@latest account id
+npx @capgo/cli@latest account whoami
```
-πͺͺ Retrieve your account ID, safe to share for collaboration or support purposes in Discord or other platforms.
+πͺͺ Retrieve your account ID and email address.
**Example:**
```bash
-npx @capgo/cli@latest account id
+npx @capgo/cli@latest account whoami
```
**Options:**
@@ -1342,21 +1384,12 @@ npx @capgo/cli@latest organisation delete
## πΉ **Build**
-ποΈ Manage native iOS/Android builds through Capgo Cloud.
-β οΈ Native cloud build requests are currently in LIMITED BETA. Access is restricted.
- π SECURITY GUARANTEE:
- Build credentials are NEVER stored on Capgo servers.
- They are used only during the build and auto-deleted after.
- Build outputs may optionally be uploaded for time-limited download links.
-π BEFORE BUILDING:
- Save your credentials first:
- npx @capgo/cli build credentials save --appId --platform ios
- npx @capgo/cli build credentials save --appId --platform android
-π€ CAPTURE THE OUTPUT URL FROM CI:
- Pass --output-record to persist the download URL + QR code, then read it
- back with `build last-output`:
- npx @capgo/cli build request --platform android --output-upload --output-record /tmp/build.json
- URL=$(npx @capgo/cli build last-output --path /tmp/build.json --field outputUrl)
+Build native iOS and Android apps with Capgo Cloud.
+Native cloud builds are currently in limited beta.
+Quick start:
+ npx @capgo/cli@latest build init
+ npx @capgo/cli@latest build request --platform
+Run npx @capgo/cli@latest build --help for command-specific options.
### πΉ **Needed**
@@ -1364,12 +1397,13 @@ npx @capgo/cli@latest organisation delete
npx @capgo/cli@latest build needed
```
-π§ Print "yes" and exit with code 1 if a native build is required; otherwise print "no" and exit with code 0. Command failures exit with code 2.
+Print "yes" and exit with code 1 if a native build is required. Otherwise print "no" and exit with code 0. Command failures exit with code 2.
+ npx @capgo/cli@latest build needed com.example.app --channel production --verbose
**Example:**
```bash
-npx @capgo/cli@latest build needed com.example.app --channel production --verbose
+Example:
```
**Options:**
@@ -1411,21 +1445,26 @@ npx @capgo/cli@latest build request
```
Request a native build from Capgo Cloud.
-This command zips your project and uploads it to Capgo for a remote native build.
-By default the finished artifact can go to the app store (when store credentials are saved)
-and/or to Capgo storage as a time-limited download link (--output-upload).
- π SECURITY: Credentials are never stored on Capgo servers. They are auto-deleted
- after build completion. Build outputs may optionally be uploaded for time-limited download links.
-π PREREQUISITE: Save credentials first with:
- `npx @capgo/cli@latest build credentials save --appId --platform `
-Android AAB only (no Play upload): npx @capgo/cli@latest build request com.example.app --platform android --no-playstore-upload --output-upload
-iOS IPA only (no TestFlight upload): npx @capgo/cli@latest build request com.example.app --platform ios --ios-distribution ad_hoc --output-upload
-
-**Example:**
-
-```bash
-npx @capgo/cli@latest build request com.example.app --platform ios --path .
-```
+The project is zipped and uploaded for a remote native build. The finished artifact can be sent to the app store, uploaded to Capgo for a time-limited download, or both.
+Credentials are uploaded only for the build. Their temporary server copies are deleted after it finishes.
+Before your first build:
+ npx @capgo/cli@latest build init
+Examples:
+ iOS build:
+ npx @capgo/cli@latest build request com.example.app \
+ --platform ios --path .
+ Android artifact without Play upload:
+ npx @capgo/cli@latest build request com.example.app \
+ --platform android --no-playstore-upload --output-upload
+ iOS artifact without TestFlight upload:
+ npx @capgo/cli@latest build request com.example.app \
+ --platform ios --ios-distribution ad_hoc --output-upload
+ Disable the Xcode compilation cache:
+ npx @capgo/cli@latest build request com.example.app \
+ --platform ios --no-cache
+ Use a separate compilation cache:
+ npx @capgo/cli@latest build request com.example.app \
+ --platform ios --cache-key prod
**Options:**
@@ -1448,6 +1487,8 @@ npx @capgo/cli@latest build request com.example.app --platform ios --path .
| **--ios-target** | string | iOS: Xcode target for reading build settings (default: same as scheme) |
| **--ios-distribution** | string | iOS: Distribution mode. app_store (default) uploads to TestFlight/App Store; ad_hoc skips store upload and builds an Ad Hoc IPA for device install. Use ad_hoc with --output-upload when the App Store app does not exist yet or you only need an IPA download. |
| **--ios-provisioning-profile** | string | iOS: Provisioning profile path or bundleId=path mapping (repeatable) |
+| **--no-cache** | boolean | Disable Xcode compilation cache for this build (default: cache enabled) |
+| **--cache-key** | string | Custom compilation cache key for this build (e.g. rc, prod, feature-branch). Use to share or isolate cache between environments. Precedence over the default appId-only key; ignored when --no-cache is set. |
| **--android-keystore-file** | string | Android: Base64-encoded keystore file |
| **--keystore-key-alias** | string | Android: Keystore key alias |
| **--keystore-key-password** | string | Android: Keystore key password |
@@ -1483,10 +1524,10 @@ npx @capgo/cli@latest build request com.example.app --platform ios --path .
| **--fail-on-warnings** | boolean | Treat prescan warnings as fatal |
| **--send-logs-to-support** | boolean | On a CI/CD build failure, automatically upload the build logs to Capgo support (no email required). Capgo support is notified and will follow up by email. Additive to --ai-analytics. |
| **--send-logs** | boolean | Deprecated alias for --send-logs-to-support |
+| **--verbose** | boolean | Enable verbose output with detailed logging |
| **-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) |
-| **--verbose** | boolean | Enable verbose output with detailed logging |
### πΉ **Sync-ios-version**
@@ -1496,11 +1537,12 @@ npx @capgo/cli@latest build sync-ios-version
Sync the local iOS app version from package.json.
Updates MARKETING_VERSION or CFBundleShortVersionString based on the Xcode Info.plist configuration.
+ npx @capgo/cli@latest build sync-ios-version --path .
**Example:**
```bash
-npx @capgo/cli@latest build sync-ios-version --path .
+Example:
```
**Options:**
@@ -1517,7 +1559,15 @@ npx @capgo/cli@latest build prescan
```
Scan your project and saved credentials for problems that would fail a cloud build β before uploading anything.
-Checks credentials (expiry, passwords, profile pairing), project state (cap sync, node_modules layout), and platform config. Runs automatically inside `build request`; this command runs it standalone (e.g. in CI).
+Checks credentials, project state, and platform configuration. This scan runs
+automatically inside `build request`; use this command to run it separately.
+ npx @capgo/cli@latest build prescan com.example.app --platform ios
+
+**Example:**
+
+```bash
+Example:
+```
**Options:**
@@ -1548,9 +1598,9 @@ Prints the full JSON by default, a single field with --field, or the ASCII QR
code with --qr. Useful in CI to grab the download URL or QR for posting back
to a PR or issue.
Examples:
- npx @capgo/cli build last-output --path /tmp/build.json
- npx @capgo/cli build last-output --path /tmp/build.json --field outputUrl
- npx @capgo/cli build last-output --path /tmp/build.json --qr
+ npx @capgo/cli@latest build last-output --path /tmp/build.json
+ npx @capgo/cli@latest build last-output --path /tmp/build.json --field outputUrl
+ npx @capgo/cli@latest build last-output --path /tmp/build.json --qr
**Options:**
@@ -1562,15 +1612,14 @@ Examples:
### πΉ **Credentials**
-Manage build credentials stored locally on your machine.
-π SECURITY:
- - Credentials saved to ~/.capgo-credentials/credentials.json (global) or .capgo-credentials.json (local)
- - When building, sent to Capgo but NEVER stored permanently
- - Deleted from Capgo immediately after build
- - Build outputs may optionally be uploaded for time-limited download links
-π DOCUMENTATION:
- iOS setup: https://capgo.app/docs/cli/cloud-build/ios/
- Android setup: https://capgo.app/docs/cli/cloud-build/android/
+Manage locally saved build credentials.
+Credentials are stored in ~/.capgo-credentials/credentials.json globally, or
+in .capgo-credentials.json for one project. They are uploaded only for a build;
+their temporary server copies are deleted after it finishes.
+Setup guides:
+ iOS: https://capgo.app/docs/cli/cloud-build/ios/
+ Android: https://capgo.app/docs/cli/cloud-build/android/
+Run npx @capgo/cli@latest build credentials --help for command-specific options.
#### πΉ **Apple-key**
@@ -1585,7 +1634,7 @@ Opens a native window that walks you through Apple's App Store Connect UI in an
embedded browser, auto-captures the Issuer ID + Key ID, intercepts the one-time
.p8, validates it against Apple, and saves it to ~/.appstoreconnect/private_keys.
Progress statistics are forwarded to Capgo analytics (disable with CAPGO_DISABLE_TELEMETRY).
- npx @capgo/cli build credentials apple-key --appId com.example.app
+ npx @capgo/cli@latest build credentials apple-key --appId com.example.app
**Example:**
@@ -1602,51 +1651,65 @@ Example:
| **--local** | boolean | Save into the per-project .capgo-credentials.json instead of the global file |
| **--json** | boolean | Print the captured Key ID / Issuer ID / .p8 path as JSON |
-#### πΉ **Save**
+#### πΉ **Ios-provisioning**
```bash
-npx @capgo/cli@latest build credentials save
+npx @capgo/cli@latest build credentials ios-provisioning
```
-Save build credentials locally for iOS or Android.
-Credentials are stored in:
- - ~/.capgo-credentials/credentials.json (default, global)
- - .capgo-credentials.json in project root (with --local flag)
-β οΈ REQUIRED BEFORE BUILDING: You must save credentials before requesting a build.
-π These credentials are NEVER stored on Capgo servers permanently.
- They are deleted immediately after the build completes.
-π Setup guides:
- iOS: https://capgo.app/docs/cli/cloud-build/ios/
- Android: https://capgo.app/docs/cli/cloud-build/android/
- npx @capgo/cli build credentials save --platform ios \
- --certificate ./cert.p12 --p12-password "password" \
- --ios-provisioning-profile ./profile.mobileprovision \
- --apple-key ./AuthKey.p8 --apple-key-id "KEY123" \
- --apple-issuer-id "issuer-uuid" --apple-team-id "team-id"
-Multi-target Example (app + widget extension):
- npx @capgo/cli build credentials save --platform ios \
- --ios-provisioning-profile ./App.mobileprovision \
- --ios-provisioning-profile com.example.widget=./Widget.mobileprovision \
- ...
- npx @capgo/cli build credentials save --platform android \
- --keystore ./release.keystore --keystore-alias "my-key" \
- --keystore-key-password "key-pass" \
- --play-config ./service-account.json
-Local storage (per-project):
- npx @capgo/cli build credentials save --local --platform ios ...
+Set up provisioning profiles for every signable iOS target.
+Reuses an eligible saved wildcard profile after confirmation, or generates
+missing App Store profiles with the saved App Store Connect .p8 key.
+ npx @capgo/cli@latest build credentials ios-provisioning
**Example:**
```bash
-iOS Example:
+Example:
+```
+
+**Options:**
+
+| Param | Type | Description |
+| -------------- | ------------- | -------------------- |
+| **--local** | boolean | Use credentials from the current project |
+| **--global** | boolean | Use credentials from the global store |
+
+#### πΉ **Save**
+
+```bash
+npx @capgo/cli@latest build credentials save
```
+Save iOS or Android build credentials on this machine.
+Credentials are stored globally by default. Use --local to store them in the
+current project's .capgo-credentials.json file instead.
+Setup guides:
+ iOS: https://capgo.app/docs/cli/cloud-build/ios/
+ Android: https://capgo.app/docs/cli/cloud-build/android/
+Examples:
+ iOS app with a widget extension:
+ npx @capgo/cli@latest build credentials save \
+ --appId com.example.app --platform ios \
+ --certificate ./cert.p12 --p12-password "password" \
+ --ios-provisioning-profile ./profile.mobileprovision \
+ --ios-provisioning-profile com.example.widget=./Widget.mobileprovision \
+ --apple-key ./AuthKey.p8 --apple-key-id "KEY123" \
+ --apple-issuer-id "issuer-uuid" --apple-team-id "team-id"
+ Android:
+ npx @capgo/cli@latest build credentials save \
+ --appId com.example.app --platform android \
+ --keystore ./release.keystore --keystore-alias "my-key" \
+ --keystore-key-password "key-pass" \
+ --play-config ./service-account.json
+
**Options:**
| Param | Type | Description |
| -------------- | ------------- | -------------------- |
| **--appId** | string | App ID (e.g., com.example.app) (required) |
| **--platform** | string | Platform: ios or android (required) |
+| **--local** | boolean | Save to .capgo-credentials.json in project root instead of global ~/.capgo-credentials/ |
| **--certificate** | string | iOS: Path to .p12 certificate file |
| **--ios-provisioning-profile** | string | iOS: Provisioning profile path or bundleId=path (repeatable) |
| **--p12-password** | string | iOS: Certificate password (optional if cert has no password) |
@@ -1665,7 +1728,6 @@ iOS Example:
| **--play-config** | string | Android: Path to Play Store service account JSON |
| **--android-flavor** | string | Android: Product flavor to build (e.g. production). Required if your project has multiple flavors. |
| **--in-app-update-priority** | string | Android: Google Play in-app update priority for future releases (integer 0β5; higher = more urgent). Omit to leave Playβs existing value untouched. |
-| **--local** | boolean | Save to .capgo-credentials.json in project root instead of global ~/.capgo-credentials/ |
| **--output-upload** | boolean | Upload build outputs (IPA/APK/AAB) to Capgo storage and print download links |
| **--no-output-upload** | boolean | Do not upload build outputs (IPA/APK/AAB) to Capgo storage |
| **--output-retention** | string | Output link TTL: 1h to 7d (default: 1h). Examples: 1h, 6h, 2d |
@@ -1683,8 +1745,9 @@ npx @capgo/cli@latest build credentials list
List saved build credentials (passwords masked).
Shows what credentials are currently saved (both global and local).
Examples:
- npx @capgo/cli build credentials list # List all apps
- npx @capgo/cli build credentials list --appId com.example.app # List specific app
+ npx @capgo/cli@latest build credentials list
+ npx @capgo/cli@latest build credentials list --appId com.example.app
+ npx @capgo/cli@latest build credentials list --local
**Options:**
@@ -1703,9 +1766,9 @@ Clear saved build credentials.
Remove credentials from storage.
Use --appId and --platform to target specific credentials.
Examples:
- npx @capgo/cli build credentials clear # Clear all apps (global)
- npx @capgo/cli build credentials clear --local # Clear local credentials
- npx @capgo/cli build credentials clear --appId com.example.app --platform ios
+ npx @capgo/cli@latest build credentials clear
+ npx @capgo/cli@latest build credentials clear --local
+ npx @capgo/cli@latest build credentials clear --appId com.example.app --platform ios
**Options:**
@@ -1725,8 +1788,8 @@ Update specific credentials without providing all of them again.
Update existing credentials by providing only the fields you want to change.
Platform is auto-detected from the options you provide.
Examples:
- npx @capgo/cli build credentials update --ios-provisioning-profile ./new-profile.mobileprovision
- npx @capgo/cli build credentials update --local --keystore ./new-keystore.jks
+ npx @capgo/cli@latest build credentials update --ios-provisioning-profile ./new-profile.mobileprovision
+ npx @capgo/cli@latest build credentials update --local --keystore ./new-keystore.jks
**Options:**
@@ -1772,10 +1835,10 @@ Interactively manage saved build credentials.
Browse stored credentials, view what's configured, export a CI/CD-ready .env file,
or delete a platform's credentials. Reuses the same TUI as `capgo init`.
Examples:
- npx @capgo/cli build credentials manage
- npx @capgo/cli build credentials manage --appId com.example.app
- npx @capgo/cli build credentials manage --appId com.example.app --platform ios
- npx @capgo/cli build credentials manage --local
+ npx @capgo/cli@latest build credentials manage
+ npx @capgo/cli@latest build credentials manage --appId com.example.app
+ npx @capgo/cli@latest build credentials manage --appId com.example.app --platform ios
+ npx @capgo/cli@latest build credentials manage --local
**Options:**
@@ -1795,6 +1858,13 @@ Export one saved Builder credential or configuration value.
Raw mode prints only the exact stored value to stdout with no trailing newline.
All failures are written to stderr and exit with status 1. Saved local/global
configuration is used; environment variables are never exported.
+ npx @capgo/cli@latest build credentials export BUILD_CERTIFICATE_BASE64 --app-id com.example.app --platform ios --raw
+
+**Example:**
+
+```bash
+Example:
+```
**Options:**
@@ -1818,7 +1888,7 @@ npx @capgo/cli@latest build credentials migrate
Migrate legacy provisioning profile to the new multi-target format.
Converts BUILD_PROVISION_PROFILE_BASE64 to CAPGO_IOS_PROVISIONING_MAP.
Discovers the main bundle ID from your Xcode project automatically.
- npx @capgo/cli build credentials migrate --platform ios
+ npx @capgo/cli@latest build credentials migrate --platform ios
**Example:**
@@ -1889,6 +1959,186 @@ npx @capgo/cli@latest probe --platform ios
| **--platform** | string | Platform to probe: ios or android |
+## π **Observe**
+
+π Query Capgo Observe metrics so you can act on launch, crash, WebView, and navigation data.
+Start with summary and follow the findings. Capgo has no session id: use observe device DEVICE_ID for a device timeline.
+Navigation does not need Expo Router. Listen to history.pushState, history.replaceState, popstate, hashchange, and Capacitor App appUrlOpen, then send action=app_nav with metadata.route.
+
+### π **Summary**
+
+```bash
+npx @capgo/cli@latest observe summary
+```
+
+π Actionable Observe findings for an app.
+Start here. Each finding includes a next view to query.
+
+**Example:**
+
+```bash
+npx @capgo/cli@latest observe summary
+```
+
+**Options:**
+
+| Param | Type | Description |
+| -------------- | ------------- | -------------------- |
+| **-a** | string | API key to link to your account |
+| **--days** | string | Lookback window in days: 1, 3, 7, or 30 (default: 7) |
+| **--action** | string | Filter by stats action, for example app_launch_ready or app_nav |
+| **--sort** | string | Sort samples: slowest, fastest, newest, or oldest |
+| **--limit** | string | Max rows to return |
+| **--version-name** | string | Filter by bundle version name |
+| **--json** | boolean | Output as JSON |
+| **--supa-host** | string | Custom Supabase host URL (for self-hosting or Capgo development) |
+| **--supa-anon** | string | Custom Supabase anon key (for self-hosting) |
+
+### π **Metrics**
+
+```bash
+npx @capgo/cli@latest observe metrics
+```
+
+π Sample Observe timings, slowest first by default.
+Use --action app_launch_ready or app_nav, and --sort slowest to find outliers.
+
+**Example:**
+
+```bash
+npx @capgo/cli@latest observe metrics --action app_launch_ready --sort slowest --json
+```
+
+**Options:**
+
+| Param | Type | Description |
+| -------------- | ------------- | -------------------- |
+| **-a** | string | API key to link to your account |
+| **--days** | string | Lookback window in days: 1, 3, 7, or 30 (default: 7) |
+| **--action** | string | Filter by stats action, for example app_launch_ready or app_nav |
+| **--sort** | string | Sort samples: slowest, fastest, newest, or oldest |
+| **--limit** | string | Max rows to return |
+| **--version-name** | string | Filter by bundle version name |
+| **--json** | boolean | Output as JSON |
+| **--supa-host** | string | Custom Supabase host URL (for self-hosting or Capgo development) |
+| **--supa-anon** | string | Custom Supabase anon key (for self-hosting) |
+
+### π **Events**
+
+```bash
+npx @capgo/cli@latest observe events
+```
+
+π Observe action counts and latest devices.
+
+**Example:**
+
+```bash
+npx @capgo/cli@latest observe events --action app_crash_native
+```
+
+**Options:**
+
+| Param | Type | Description |
+| -------------- | ------------- | -------------------- |
+| **-a** | string | API key to link to your account |
+| **--days** | string | Lookback window in days: 1, 3, 7, or 30 (default: 7) |
+| **--action** | string | Filter by stats action, for example app_launch_ready or app_nav |
+| **--sort** | string | Sort samples: slowest, fastest, newest, or oldest |
+| **--limit** | string | Max rows to return |
+| **--version-name** | string | Filter by bundle version name |
+| **--json** | boolean | Output as JSON |
+| **--supa-host** | string | Custom Supabase host URL (for self-hosting or Capgo development) |
+| **--supa-anon** | string | Custom Supabase anon key (for self-hosting) |
+
+### π± **Device**
+
+```bash
+npx @capgo/cli@latest observe device
+```
+
+π± Device timeline (session substitute) for one device_id.
+Capgo has no session id. Read events in time order to see launch, WebView, crashes, and navigations.
+
+**Example:**
+
+```bash
+npx @capgo/cli@latest observe device DEVICE_ID --json
+```
+
+**Options:**
+
+| Param | Type | Description |
+| -------------- | ------------- | -------------------- |
+| **-d** | string | Device ID |
+| **-a** | string | API key to link to your account |
+| **--days** | string | Lookback window in days: 1, 3, 7, or 30 (default: 7) |
+| **--action** | string | Filter by stats action, for example app_launch_ready or app_nav |
+| **--sort** | string | Sort samples: slowest, fastest, newest, or oldest |
+| **--limit** | string | Max rows to return |
+| **--version-name** | string | Filter by bundle version name |
+| **--json** | boolean | Output as JSON |
+| **--supa-host** | string | Custom Supabase host URL (for self-hosting or Capgo development) |
+| **--supa-anon** | string | Custom Supabase anon key (for self-hosting) |
+
+### π **Versions**
+
+```bash
+npx @capgo/cli@latest observe versions
+```
+
+π¦ Observe breakdown by bundle version.
+
+**Example:**
+
+```bash
+npx @capgo/cli@latest observe versions
+```
+
+**Options:**
+
+| Param | Type | Description |
+| -------------- | ------------- | -------------------- |
+| **-a** | string | API key to link to your account |
+| **--days** | string | Lookback window in days: 1, 3, 7, or 30 (default: 7) |
+| **--action** | string | Filter by stats action, for example app_launch_ready or app_nav |
+| **--sort** | string | Sort samples: slowest, fastest, newest, or oldest |
+| **--limit** | string | Max rows to return |
+| **--version-name** | string | Filter by bundle version name |
+| **--json** | boolean | Output as JSON |
+| **--supa-host** | string | Custom Supabase host URL (for self-hosting or Capgo development) |
+| **--supa-anon** | string | Custom Supabase anon key (for self-hosting) |
+
+### π **Routes**
+
+```bash
+npx @capgo/cli@latest observe routes
+```
+
+π§ Per-screen Observe timings from metadata.route or action=app_nav.
+No Expo Router required. The app should listen to history/popstate/hashchange/appUrlOpen and send metadata.route.
+
+**Example:**
+
+```bash
+npx @capgo/cli@latest observe routes --json
+```
+
+**Options:**
+
+| Param | Type | Description |
+| -------------- | ------------- | -------------------- |
+| **-a** | string | API key to link to your account |
+| **--days** | string | Lookback window in days: 1, 3, 7, or 30 (default: 7) |
+| **--action** | string | Filter by stats action, for example app_launch_ready or app_nav |
+| **--sort** | string | Sort samples: slowest, fastest, newest, or oldest |
+| **--limit** | string | Max rows to return |
+| **--version-name** | string | Filter by bundle version name |
+| **--json** | boolean | Output as JSON |
+| **--supa-host** | string | Custom Supabase host URL (for self-hosting or Capgo development) |
+| **--supa-anon** | string | Custom Supabase anon key (for self-hosting) |
+
+
## πΉ **Mcp**
```bash
@@ -1906,7 +2156,7 @@ Selected tools exposed via MCP:
- capgo_list_organizations, capgo_add_organization
- capgo_star_repository
- capgo_star_all_repositories
- - capgo_get_account_id, capgo_doctor, capgo_get_stats
+ - capgo_get_account_id, capgo_doctor, capgo_get_stats, capgo_observe
- capgo_request_build, capgo_generate_encryption_keys
Example usage with Claude Desktop:
Add to claude_desktop_config.json:
diff --git a/cli/build.mjs b/cli/build.mjs
index a90cec6ce9..3f20f11b9f 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'],
+ entrypoints: ['src/index.ts', 'src/onboarding-worker.ts', 'src/notify-app-ready-worker.ts', 'src/updater-installed-worker.ts'],
target: 'node',
outdir: 'dist',
- external: EXTERNAL_PACKAGES,
+ external: [...EXTERNAL_PACKAGES, 'typescript'],
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 227eea81c8..fb505a6ffa 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -1,7 +1,7 @@
{
"name": "@capgo/cli",
"type": "module",
- "version": "8.46.1",
+ "version": "8.55.0",
"description": "A CLI to upload to capgo servers",
"author": "Martin martin@capgo.app",
"license": "Apache 2.0",
@@ -64,6 +64,7 @@
"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/",
@@ -83,6 +84,8 @@
"test:build-zip-filter": "bun test/test-build-zip-filter.mjs",
"test:checksum": "bun test/test-checksum-algorithm.mjs",
"test:build-needed": "bun test/test-build-needed.mjs",
+ "test:cli-help": "bun test/test-cli-help.mjs",
+ "test:build-cache-payload": "bun test/test-build-cache-payload.mjs",
"test:build-cancellation": "bun test/test-build-cancellation.mjs",
"test:ci-prompts": "bun test/test-ci-prompts.mjs",
"test:ci-secrets": "bun test/test-ci-secrets.mjs",
@@ -112,6 +115,7 @@
"test:app-created-source": "bun test/test-app-created-source.mjs",
"test:app-add-exists": "bun test/test-app-add-exists.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",
@@ -124,11 +128,14 @@
"test:channel-add-exists": "bun test/test-channel-add-exists.mjs",
"test:wait-log": "bun test/test-wait-log.mjs",
"test:init-guardrails": "bun test/test-init-guardrails.mjs",
+ "test:init-upload-recovery": "bun test/test-init-upload-error-gate.mjs && bun test/test-init-upload-monorepo-recovery.mjs",
"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: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:init-monorepo-targeting": "bun run test:capacitor-config-target && 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",
@@ -176,7 +183,7 @@
"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: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: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: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-add-exists && bun run test:wait-log && bun run test:init-guardrails && 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-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: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: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: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: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:ai-log-capture": "bun test/test-ai-log-capture.mjs",
@@ -207,6 +214,7 @@
"test:mcp-live-update-onboarding": "bun test/test-mcp-live-update-onboarding.mjs",
"test:app-set-options": "bun test/test-app-set-options.mjs && bun test/test-app-list-options.mjs",
"test:android-reporting-api": "bun test/test-android-reporting-api.mjs",
+ "test:android-gcp": "bun test/test-android-gcp.mjs",
"test:android-app-verification": "bun test/test-android-app-verification.mjs",
"test:android-rename": "bun test/test-android-rename.mjs",
"test:appflow-auth": "bun test/test-appflow-auth.mjs",
@@ -224,7 +232,10 @@
"test:trial-warning": "bun test/test-trial-warning.mjs",
"test:plan-validation": "bun test/test-plan-validation.mjs",
"test:auto-bump-version": "bun test/test-auto-bump-version.mjs",
- "test:auto-bump-ai-diff": "bun test/test-auto-bump-ai-diff.mjs"
+ "test:auto-bump-ai-diff": "bun test/test-auto-bump-ai-diff.mjs",
+ "test:ios-provisioning-map": "bun test/test-ios-provisioning-map.mjs",
+ "test:ios-provisioning-command": "bun test/test-ios-provisioning-command.mjs",
+ "test:channel-list": "bun test/test-channel-list.mjs"
},
"dependencies": {
"@inkjs/ui": "^2.0.0",
@@ -275,6 +286,7 @@
"@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/_artifacts/domain_map.yaml b/cli/skills/_artifacts/domain_map.yaml
index 14105b2b01..4abfd235a8 100644
--- a/cli/skills/_artifacts/domain_map.yaml
+++ b/cli/skills/_artifacts/domain_map.yaml
@@ -11,6 +11,11 @@ domains:
- webdocs/star.mdx
- webdocs/star-all.mdx
- src/index.ts
+ - name: observe
+ summary: Query Observe findings, launch and WebView timings, device timelines, and per-screen routes from the CLI and MCP.
+ primary_sources:
+ - webdocs/observe.mdx
+ - src/index.ts
- name: ota-release-management
summary: OTA bundle uploads, channel operations, preview QR codes, compatibility checks, cleanup, and encryption-key workflows.
primary_sources:
diff --git a/cli/skills/_artifacts/skill_spec.md b/cli/skills/_artifacts/skill_spec.md
index 7c7b3ce6a5..773c4d624a 100644
--- a/cli/skills/_artifacts/skill_spec.md
+++ b/cli/skills/_artifacts/skill_spec.md
@@ -2,7 +2,7 @@
## Goal
-Provide a small Capgo CLI skill set that helps an agent choose and invoke the correct CLI commands for app setup, OTA release operations, organization administration, MCP setup, GitHub support commands, and native cloud builds without exceeding TanStack Intent size limits.
+Provide a small Capgo CLI skill set that helps an agent choose and invoke the correct CLI commands for app setup, OTA release operations, organization administration, MCP setup, GitHub support commands, Observe queries, and native cloud builds without exceeding TanStack Intent size limits.
## Sources
@@ -13,6 +13,7 @@ Provide a small Capgo CLI skill set that helps an agent choose and invoke the co
## Skill set
- `usage`: routing, setup, diagnostics, app commands, docs generation, MCP, and GitHub support commands.
+- `observe`: Observe query commands and MCP (`summary`, `metrics`, `events`, `device`, `versions`, `routes`).
- `release-management`: bundle, channel, compatibility, cleanup, and encryption-key workflows.
- `native-builds`: native cloud build requests and build credential storage/update flows.
- `organization-management`: account ID lookup, organization admin flows, and deprecated `organisation` aliases.
diff --git a/cli/skills/_artifacts/skill_tree.yaml b/cli/skills/_artifacts/skill_tree.yaml
index 91f6e8fedd..2b93cf69d1 100644
--- a/cli/skills/_artifacts/skill_tree.yaml
+++ b/cli/skills/_artifacts/skill_tree.yaml
@@ -3,6 +3,10 @@ skills:
path: skills/usage/SKILL.md
domain: cli-usage
focus: High-level routing and shared invocation rules.
+ - name: observe
+ path: skills/observe/SKILL.md
+ domain: observe
+ focus: Query Observe findings, metrics, device timelines, and navigation routes.
- name: release-management
path: skills/release-management/SKILL.md
domain: ota-release-management
diff --git a/cli/skills/native-builds/SKILL.md b/cli/skills/native-builds/SKILL.md
index 579885b769..63346bd96f 100644
--- a/cli/skills/native-builds/SKILL.md
+++ b/cli/skills/native-builds/SKILL.md
@@ -158,6 +158,10 @@ interface BuildLogger {
- `--output-retention `: `1h` to `7d`
- `--skip-build-number-bump`
- `--no-skip-build-number-bump`
+- `--no-cache`: disable Xcode compilation cache for this build (default: cache enabled). Example: `npx @capgo/cli@latest build request com.example.app --platform ios --no-cache`
+- `--cache-key `: custom compilation cache namespace for this build (e.g. `rc`, `prod`, `feature/my-branch`). Use to share cache within an environment or isolate cache between RC and production. Ignored when `--no-cache` is set. Example RC vs PROD:
+ - RC: `npx @capgo/cli@latest build request com.example.app --platform ios --cache-key rc`
+ - PROD: `npx @capgo/cli@latest build request com.example.app --platform ios --cache-key prod`
## Local credential management
@@ -170,7 +174,7 @@ Credentials are stored locally, either globally in `~/.capgo-credentials/credent
- Example iOS flow:
```bash
-npx @capgo/cli build credentials save --platform ios \
+npx @capgo/cli@latest build credentials save --platform ios \
--certificate ./cert.p12 --p12-password "password" \
--ios-provisioning-profile ./profile.mobileprovision \
--apple-key ./AuthKey.p8 --apple-key-id "KEY123" \
@@ -180,7 +184,7 @@ npx @capgo/cli build credentials save --platform ios \
- Example multi-target iOS flow:
```bash
-npx @capgo/cli build credentials save --platform ios \
+npx @capgo/cli@latest build credentials save --platform ios \
--ios-provisioning-profile ./App.mobileprovision \
--ios-provisioning-profile com.example.widget=./Widget.mobileprovision
```
@@ -188,7 +192,7 @@ npx @capgo/cli build credentials save --platform ios \
- Example Android flow:
```bash
-npx @capgo/cli build credentials save --platform android \
+npx @capgo/cli@latest build credentials save --platform android \
--keystore ./release.keystore --keystore-alias "my-key" \
--keystore-key-password "key-pass" \
--play-config ./service-account.json
@@ -227,8 +231,8 @@ npx @capgo/cli build credentials save --platform android \
### `build credentials list`
- Examples:
- - `npx @capgo/cli build credentials list`
- - `npx @capgo/cli build credentials list --appId com.example.app`
+ - `npx @capgo/cli@latest build credentials list`
+ - `npx @capgo/cli@latest build credentials list --appId com.example.app`
- Options:
- `--appId `
- `--local`
@@ -236,9 +240,9 @@ npx @capgo/cli build credentials save --platform android \
### `build credentials clear`
- Examples:
- - `npx @capgo/cli build credentials clear`
- - `npx @capgo/cli build credentials clear --local`
- - `npx @capgo/cli build credentials clear --appId com.example.app --platform ios`
+ - `npx @capgo/cli@latest build credentials clear`
+ - `npx @capgo/cli@latest build credentials clear --local`
+ - `npx @capgo/cli@latest build credentials clear --appId com.example.app --platform ios`
- Options:
- `--appId `
- `--platform `
@@ -249,8 +253,8 @@ npx @capgo/cli build credentials save --platform android \
- Use to update specific credential fields without re-entering all data.
- Platform is auto-detected from the supplied options.
- Examples:
- - `npx @capgo/cli build credentials update --ios-provisioning-profile ./new-profile.mobileprovision`
- - `npx @capgo/cli build credentials update --local --keystore ./new-keystore.jks`
+ - `npx @capgo/cli@latest build credentials update --ios-provisioning-profile ./new-profile.mobileprovision`
+ - `npx @capgo/cli@latest build credentials update --local --keystore ./new-keystore.jks`
- Core options:
- `--appId `
- `--platform `
@@ -261,6 +265,16 @@ npx @capgo/cli build credentials save --platform android \
- `--skip-build-number-bump`, `--no-skip-build-number-bump`
- Supports the same iOS and Android credential fields as `build credentials save`.
+### `build credentials ios-provisioning`
+
+- Repairs missing App Store provisioning-profile entries for every signable target in the current Capacitor iOS project.
+- Reuses a compatible wildcard profile only after confirmation; otherwise uses saved App Store Connect API-key credentials to create target-specific profiles.
+- Use `--local` or `--global` to select the credential store. If both stores contain credentials for the app and neither option is passed, the command exits and asks you to choose one.
+
+```bash
+npx @capgo/cli@latest build credentials ios-provisioning --local
+```
+
### `build credentials export `
- Exports one value from saved Builder credentials only; environment variables are never used.
@@ -282,7 +296,7 @@ npx @capgo/cli@latest build credentials export ANDROID_KEYSTORE_FILE \
### `build credentials migrate`
-- Example: `npx @capgo/cli build credentials migrate --platform ios`
+- Example: `npx @capgo/cli@latest build credentials migrate --platform ios`
- Notes:
- Converts `BUILD_PROVISION_PROFILE_BASE64` to `CAPGO_IOS_PROVISIONING_MAP`.
- Discovers the main bundle ID from the Xcode project automatically.
diff --git a/cli/skills/observe/SKILL.md b/cli/skills/observe/SKILL.md
new file mode 100644
index 0000000000..a96fdb4fa9
--- /dev/null
+++ b/cli/skills/observe/SKILL.md
@@ -0,0 +1,38 @@
+---
+name: observe
+description: Use when querying Capgo Observe metrics, launch timings, crashes, WebView loads, device timelines, or per-screen navigation from the CLI, SDK, or MCP.
+---
+
+# Capgo Observe
+
+Query existing Observe stats so agents and scripts can act on the data. Dashboards stay visual; this surface is for `findings` plus a next query.
+
+Capgo has no session id. Treat `device_id` + time window as the session. Start with `summary` and follow `findings[].next`.
+
+Navigation does not need Expo Router. Listen to `history.pushState`, `history.replaceState`, `popstate`, `hashchange`, and Capacitor App `appUrlOpen`, then send stats action `app_nav` with `metadata.route` (or `path`) and optional `duration_ms`.
+
+## CLI
+
+```bash
+npx @capgo/cli@latest observe summary
+npx @capgo/cli@latest observe metrics --action app_launch_ready --sort slowest --json
+npx @capgo/cli@latest observe events --action app_crash_native
+npx @capgo/cli@latest observe device DEVICE_ID --json
+npx @capgo/cli@latest observe versions
+npx @capgo/cli@latest observe routes --json
+```
+
+Shared flags: `-a, --apikey`, `--days` (`1`, `3`, `7`, `30`), `--action`, `--sort`, `--limit`, `--version-name`, `--json`.
+
+## MCP
+
+Call `capgo_observe` with `view=summary` first. Follow `findings[].next`. Use `view=device` and `deviceId` for the timeline.
+
+## Views
+
+- `summary`: findings, handoff prompt, overview
+- `metrics`: timed samples (launch, WebView, `app_nav`)
+- `events`: action counts and latest devices
+- `device`: ordered timeline for one `device_id`
+- `versions`: per-bundle launch/issue breakdown
+- `routes`: grouped by `metadata.route` / `path` / `url`
diff --git a/cli/skills/organization-management/SKILL.md b/cli/skills/organization-management/SKILL.md
index ebd0470895..ef4db20535 100644
--- a/cli/skills/organization-management/SKILL.md
+++ b/cli/skills/organization-management/SKILL.md
@@ -9,10 +9,11 @@ Use this skill for account and organization administration commands.
## Account command
-### `account id`
+### `account whoami`
-- Example: `npx @capgo/cli@latest account id`
-- Use to retrieve an account ID that is safe to share for collaboration or support.
+- Example: `npx @capgo/cli@latest account whoami`
+- Alias: `account id`.
+- Displays the account ID and email associated with the API key.
- Key option:
- `-a, --apikey `
diff --git a/cli/skills/release-management/SKILL.md b/cli/skills/release-management/SKILL.md
index 6fb8ebb7a7..33c9590e08 100644
--- a/cli/skills/release-management/SKILL.md
+++ b/cli/skills/release-management/SKILL.md
@@ -66,7 +66,8 @@ Use this skill for OTA update workflows in Capgo Cloud.
- `--min-update-version `
- `--auto-min-update-version`
- `--ignore-metadata-check`
- - `--fail-on-incompatible` (fail the upload instead of uploading when the bundle is incompatible with a target channel's current native packages; cannot be combined with `--ignore-metadata-check`)
+ - `--fail-on-incompatible` (fail the upload instead of uploading when the bundle is incompatible with a target channel's current native packages; cannot be combined with `--ignore-metadata-check` or `--accept-incompatible`)
+ - `--accept-incompatible` (mark native-package incompatibility as handled: still checks and warns, continues the upload, skips the crash-warning email; cannot be combined with `--fail-on-incompatible` or `--ignore-metadata-check`)
- `--ignore-checksum-check`
- `--force-crc32-checksum`
- `--timeout `
@@ -186,6 +187,8 @@ Use this skill for OTA update workflows in Capgo Cloud.
- Alias: `l`
- Example: `npx @capgo/cli@latest channel list com.example.app`
+- Requires a valid API key with `app.read` and `app.read_channels` permission for the app. Permission failures identify the required permission.
+- Displays Yes/No settings and marks channels without a bundle as Unlinked. Narrow terminals show each channel as a Setting/Value table.
### `channel delete [channelId] [appId]`
@@ -228,6 +231,7 @@ Use this skill for OTA update workflows in Capgo Cloud.
- `--device`, `--no-device`
- `--package-json `
- `--ignore-metadata-check`
+ - `--accept-incompatible` (mark native-package incompatibility as handled: still checks and warns, sets the channel instead of failing; cannot be combined with `--ignore-metadata-check`)
- `--qr-preview`
- `--send-update-notification`
diff --git a/cli/skills/usage/SKILL.md b/cli/skills/usage/SKILL.md
index cf21dac6a0..f7906f33fb 100644
--- a/cli/skills/usage/SKILL.md
+++ b/cli/skills/usage/SKILL.md
@@ -10,6 +10,7 @@ Use this skill as the entry point for the Capgo CLI skill set.
TanStack Intent skills should stay focused and under the validator line limit, so the Capgo CLI guidance is split into multiple skills:
- `usage`: high-level command routing, shared invocation rules, and quick command selection.
+- `observe`: Observe query commands and MCP (`summary`, `metrics`, `events`, `device`, `versions`, `routes`).
- `release-management`: OTA bundle, channel, and encryption-key workflows.
- `native-builds`: native cloud build request and build-credential workflows.
- `organization-management`: organization, account, and deprecated organisation-alias workflows.
@@ -18,6 +19,13 @@ 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.
@@ -25,25 +33,28 @@ TanStack Intent skills should stay focused and under the validator line limit, s
### Project setup and diagnostics
-- `init [apikey] [appId]`: guided first-time setup for Capgo in a Capacitor app. The interactive flow now runs as a real Ink-based fullscreen onboarding so it uses the same UI stack as `build init` (alias: `build onboarding`), with a persistent dashboard, phase roadmap, progress cards, shared log area, and resume support. At startup, onboarding still recommends a clean git worktree before file edits; dirty repos show `Check again` first and also offer a non-recommended `Continue anyway` override. When dependency auto-detection fails on macOS, the flow opens a native file picker for `package.json` before falling back to manual path entry. If the local bundle ID already exists in the selected Capgo account, onboarding offers to reuse that app, then offers to delete and recreate it, then falls back to alternate bundle ID suggestions. If the user reuses a pending app that was already created in the web onboarding flow, the CLI syncs that selected dashboard app ID back into `capacitor.config.*` before the remaining steps continue. Outside that reused pending-app path, the CLI keeps using the local Capacitor app ID. It writes the new `autoUpdate` policy modes into config: `"atBackground"` for the default flow and `"always"` for instant updates. It can also offer a final `npx skills add https://github.com/Cap-go/capgo-skills -g -y` install step before the GitHub support prompt; if accepted, the support menu includes `Cap-go/capgo-skills` alongside the updater-only and all-Capgo choices. If native platforms are missing, the onboarding can offer to run `cap add` for you. The updater step now verifies that `@capgo/capacitor-updater` is both declared in the selected `package.json` and resolvable from `node_modules`; if automatic install or later build/sync fails, onboarding prints the manual command, waits for the user to type `ready`, re-checks, and only then continues. If the user enables instant updates, onboarding requires `@capacitor/splash-screen` for `autoSplashscreen`, offers to install it with the same auto-install/retry flow, and waits until it is declared and resolvable before writing config. During the iOS run-on-device step, onboarding asks whether to use a physical iPhone/iPad or a simulator; for physical devices, it asks the user to connect and unlock the device, then offers a check-again loop before launching with the detected target. If iOS sync validation fails during onboarding, the CLI can offer to run a one-line native reset command, wait for you to type `ready` after a manual fix, surface `doctor`, and save a support bundle before you leave the flow.
+- `init [apikey] [appId]`: guided first-time setup for Capgo in a Capacitor app. The interactive flow now runs as a real Ink-based fullscreen onboarding so it uses the same UI stack as `build init` (alias: `build onboarding`), with a persistent dashboard, phase roadmap, progress cards, shared log area, and resume support. At startup, onboarding still recommends a clean git worktree before file edits; dirty repos show `Check again` first and also offer a non-recommended `Continue anyway` override. When dependency auto-detection fails on macOS, the flow opens a native file picker for `package.json` before falling back to manual path entry. If the onboarding bundle upload fails, recovery can retry immediately or ask for the monorepo/workspace root `package.json` and hoisted `node_modules` paths (not the app package folder) and then retry. If the local bundle ID already exists in the selected Capgo account, onboarding offers to reuse that app, then offers to delete and recreate it, then falls back to alternate bundle ID suggestions. If the user reuses a pending app that was already created in the web onboarding flow, the CLI syncs that selected dashboard app ID back into `capacitor.config.*` before the remaining steps continue. Outside that reused pending-app path, the CLI keeps using the local Capacitor app ID. It writes the new `autoUpdate` policy modes into config: `"atBackground"` for the default flow and `"always"` for instant updates. It can also offer a final `npx skills add https://github.com/Cap-go/capgo-skills -g -y` install step before the GitHub support prompt; if accepted, the support menu includes `Cap-go/capgo-skills` alongside the updater-only and all-Capgo choices. If native platforms are missing, the onboarding can offer to run `cap add` for you. The updater step now verifies that `@capgo/capacitor-updater` is both declared in the selected `package.json` and resolvable from `node_modules`; if automatic install or later build/sync fails, onboarding prints the manual command, waits for the user to type `ready`, re-checks, and only then continues. If the user enables instant updates, onboarding requires `@capacitor/splash-screen` for `autoSplashscreen`, offers to install it with the same auto-install/retry flow, and waits until it is declared and resolvable before writing config. During the iOS run-on-device step, onboarding asks whether to use a physical iPhone/iPad or a simulator; for physical devices, it asks the user to connect and unlock the device, then offers a check-again loop before launching with the detected target. If iOS sync validation fails during onboarding, the CLI can offer to run a one-line native reset command, wait for you to type `ready` after a manual fix, surface `doctor`, and save a support bundle before you leave the flow.
- `init --no-analytics`: disables init onboarding analytics and terminal replay for that run.
+- When `init` finds an existing app channel, it asks **Yes, use it** or **No, create a new one** before creating anything. It prefers the saved/production channel, then a default download channel, then another existing channel. Reusing continues onboarding with that channel; declining lets the user choose a new name.
- `run device [platform]`: run a Capacitor app on a connected device or simulator. In an interactive terminal, omitting `[platform]` asks whether to start on iOS or Android. The command lists available devices and simulators, includes a reload option, and resolves the `cap run` command. Use `npx @capgo/cli@latest run device ios --no-launch` to exercise iOS physical/simulator target selection and print the resolved command without launching the app.
- `login [apikey]`: store an API key locally.
- `doctor`: inspect installation health and gather troubleshooting details.
- `probe`: test whether the update endpoint would deliver an update.
+- `observe summary|metrics|events|device|versions|routes`: query Observe findings, timings, device timelines, and per-screen routes. Start with `observe summary`. Use `observe device DEVICE_ID` as the session timeline. Navigation uses `app_nav` + `metadata.route` (history/popstate/hashchange/appUrlOpen, no Expo Router). Pass `--json` for agents.
### App-level operations
- `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. Skipped tasks count toward completed progress. Live checks can refresh saved v3 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.
### Docs and agent integrations
-- `mcp`: start the Capgo MCP server for AI-agent integrations; pass `--capacitor-config ` when its config-writing tools should target an app-specific source.
+- `mcp`: start the Capgo MCP server for AI-agent integrations; pass `--capacitor-config ` when its config-writing tools should target an app-specific source. Observe queries use `capgo_observe` (start at `view=summary`).
### GitHub support commands
@@ -52,6 +63,13 @@ TanStack Intent skills should stay focused and under the validator line limit, s
## Related skills
+### `observe`
+
+Load `skills/observe/SKILL.md` when working with:
+
+- `observe summary`, `observe metrics`, `observe events`, `observe device`, `observe versions`, `observe routes`
+- MCP `capgo_observe`
+
### `release-management`
Load `skills/release-management/SKILL.md` when working with:
@@ -78,7 +96,7 @@ Load `skills/native-builds/SKILL.md` when working with:
Load `skills/organization-management/SKILL.md` when working with:
-- `account id`
+- `account whoami` (alias: `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 5e91c5d417..95e2dda521 100644
--- a/cli/src/analytics/track.ts
+++ b/cli/src/analytics/track.ts
@@ -81,7 +81,9 @@ 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
}
/**
@@ -122,8 +124,10 @@ 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 {
diff --git a/cli/src/api/app.ts b/cli/src/api/app.ts
index 4c388a1543..c1518e66b4 100644
--- a/cli/src/api/app.ts
+++ b/cli/src/api/app.ts
@@ -9,12 +9,13 @@ import {
throwTwoFactorComplianceRpcError,
warnAndContinueTwoFactorPreflightNetworkFailure,
} from '../shared/two-factor-compliance'
-import { appAddHintMessage, formatCapgoApiErrorBody, getCapgoCliHttpStatus, hasCliPermission, invokeCapgoCliApi, isCapgoManagedSupabaseHost, resolveCapgoPublicApiHost, show2FADeniedError } from '../utils'
+import { appAddHintMessage, formatCapgoApiErrorBody, formatCapgoCliInvokeError, getCapgoCliHttpStatus, hasCliPermission, invokeCapgoCliApi, isCapgoManagedSupabaseHost, resolveCapgoPublicApiHost, show2FADeniedError } from '../utils'
export async function checkAppExists(
apikey: string,
appid: string,
options?: { supaHost?: string, supaAnon?: string },
+ silent = true,
) {
const { data, error } = await invokeCapgoCliApi(`app/${encodeURIComponent(appid)}`, {
apikey,
@@ -24,9 +25,19 @@ export async function checkAppExists(
supaAnon: options?.supaAnon,
})
if (error) {
- if (getCapgoCliHttpStatus(error) === 404)
+ const status = getCapgoCliHttpStatus(error)
+ if (status === 404)
return false
- throw error
+ if (status === 401 || status === 403) {
+ const message = 'Cannot access app. Check that your API key is valid and has app.read permission for this app.'
+ if (!silent)
+ log.error(message)
+ throw new CliUserError(
+ message,
+ { appId: appid, requiredPermissionKey: 'app.read' },
+ )
+ }
+ throw new Error(`Cannot check app access: ${await formatCapgoCliInvokeError(error)}`, { cause: error })
}
return !!data
}
@@ -41,7 +52,6 @@ export type ExistingOrganizationApp = Pick<
'app_id' | 'name' | 'owner_org' | 'need_onboarding'
>
-
export async function listPendingOnboardingApps(
apikey: string,
orgId: string,
@@ -259,7 +269,7 @@ export async function checkAppExistsAndHasPermissionOrgErr(
await check2FAComplianceForApp(supabase, appid, silent)
// Keep local/self-host Capgo HTTP traffic on the same host as this supabase client.
- if (!isChannelScopedPermission && !(await checkAppExists(apikey, appid, hostOptionsFromSupabase(supabase)))) {
+ if (!isChannelScopedPermission && !(await checkAppExists(apikey, appid, hostOptionsFromSupabase(supabase), silent))) {
const msg = appAddHintMessage(appid)
if (!silent)
log.error(msg)
diff --git a/cli/src/api/channels.ts b/cli/src/api/channels.ts
index c2de5be10b..9c24cec846 100644
--- a/cli/src/api/channels.ts
+++ b/cli/src/api/channels.ts
@@ -1,8 +1,10 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Database } from '../types/supabase.types'
+import process from 'node:process'
import { confirm as confirmC, intro, log, outro, spinner } from '@clack/prompts'
-import { Table } from '@sauber/table'
-import { formatError, invokeCapgoCliApi } from '../utils'
+import { CliUserError } from '../shared/cli-user-error'
+import { formatTable, visibleWidth } from '../terminal-table'
+import { formatCapgoCliInvokeError, formatError, getCapgoCliHttpStatus, invokeCapgoCliApi, readCapgoCliApiErrorPayload } from '../utils'
interface CheckVersionOptions {
silent?: boolean
@@ -21,7 +23,7 @@ interface CapgoHttpOptions {
supaAnon?: string
}
-type HttpChannel = {
+interface HttpChannel {
id: number
name: string
public?: boolean
@@ -49,7 +51,6 @@ function normalizeHttpChannel(row: HttpChannel): Channel {
id: row.id,
name: row.name,
public: !!row.public,
- // TODO(cli-http): GET channel does not currently return ios/android; default false for display
ios: row.ios ?? false,
android: row.android ?? false,
disable_auto_update: String(row.disableAutoUpdate ?? row.disable_auto_update ?? ''),
@@ -216,7 +217,7 @@ export function findChannel(supabase: SupabaseClient, appId: string, n
.single()
}
-export type ChannelLinkedVersion = { id: number, name: string }
+export interface ChannelLinkedVersion { id: number, name: string }
export async function findVersionsLinkedToChannel(
supabase: SupabaseClient,
@@ -268,34 +269,64 @@ export async function isVersionLinkedToOtherChannel(
export type { Channel } from '../schemas/channel'
type Channel = import('../schemas/channel').Channel
+function wrapChannelValue(value: string, width: number): string[] {
+ const lines: string[] = []
+ let line = ''
+ for (const character of value) {
+ if (character === '\n') {
+ lines.push(line)
+ line = ''
+ continue
+ }
+ if (line && visibleWidth(line + character) > width) {
+ lines.push(line)
+ line = ''
+ }
+ line += character
+ }
+ lines.push(line)
+ return lines
+}
+
+export function formatChannels(data: Channel[], columns = (process.stdout.columns ?? 120) - 2): string {
+ if (!data.length)
+ return 'No channels found.'
+
+ const headers = ['Name', 'Version', 'Public', 'iOS', 'Android', 'Auto Update', 'Updates Under Native', 'Device Self Set', 'Emulator', 'Device', 'Dev', 'Prod']
+ const yesNo = (value: boolean) => value ? 'Yes' : 'No'
+ const rows = data.toReversed().map(row => [
+ row.name,
+ row.version?.name || 'Unlinked',
+ yesNo(row.public),
+ yesNo(row.ios),
+ yesNo(row.android),
+ row.disable_auto_update || 'none',
+ yesNo(!row.disable_auto_update_under_native),
+ yesNo(row.allow_device_self_set),
+ yesNo(row.allow_emulator),
+ yesNo(row.allow_device),
+ yesNo(row.allow_dev),
+ yesNo(row.allow_prod),
+ ])
+ const table = formatTable({ headers, rows })
+ if (visibleWidth(table.split('\n')[0] ?? '') <= columns)
+ return table
+
+ const valueWidth = Math.max(2, columns - Math.max(...headers.map(visibleWidth)) - 7)
+ return rows.map(row => formatTable({
+ headers: ['Setting', 'Value'],
+ rows: headers.flatMap((header, index) =>
+ wrapChannelValue(row[index] ?? '', valueWidth).map((line, lineIndex) => [lineIndex ? '' : header, line]),
+ ),
+ })).join('\n\n')
+}
+
export function displayChannels(data: Channel[], silent = false) {
if (silent)
return
- const t = new Table()
- t.theme = Table.roundTheme
- t.headers = ['Name', 'Version', 'Public', 'iOS', 'Android', 'Auto Update', 'Native Auto Update', 'Device Self Set', 'Emulator', 'Device', 'Dev', 'Prod']
- t.rows = []
-
- for (const row of data.toReversed()) {
- t.rows.push([
- row.name,
- row.version?.name,
- row.public ? 'β
' : 'β',
- row.ios ? 'β
' : 'β',
- row.android ? 'β
' : 'β',
- row.disable_auto_update,
- row.disable_auto_update_under_native ? 'β' : 'β
',
- row.allow_device_self_set ? 'β
' : 'β',
- row.allow_emulator ? 'β
' : 'β',
- row.allow_device ? 'β
' : 'β',
- row.allow_dev ? 'β
' : 'β',
- row.allow_prod ? 'β
' : 'β',
- ])
- }
-
log.success('Channels')
- log.success(t.toString())
+ log.message(formatChannels(data))
}
export async function getActiveChannels(
@@ -307,11 +338,20 @@ export async function getActiveChannels(
while (true) {
const { data, error: vError } = await fetchChannelsPage(appid, page, options)
if (vError) {
- if (!options.silent)
- log.error(`App ${appid} not found in database`)
- throw new Error(`App ${appid} not found in database: ${formatError(vError)}`)
+ const status = getCapgoCliHttpStatus(vError)
+ const payload = await readCapgoCliApiErrorPayload(vError)
+ if (status === 401 || status === 403 || payload?.error === 'cannot_access_app') {
+ const message = 'Cannot list channels. Check that your API key is valid and has app.read_channels permission for this app.'
+ if (!options.silent)
+ log.error(message)
+ throw new CliUserError(
+ message,
+ { appId: appid, requiredPermissionKey: 'app.read_channels' },
+ )
+ }
+ throw new Error(`Cannot list channels: ${await formatCapgoCliInvokeError(vError)}`, { cause: vError })
}
- const batch = Array.isArray(data) ? data : []
+ const batch = Array.isArray(data) ? data : data ? [data] : []
if (!batch.length)
break
all.push(...batch.map(normalizeHttpChannel))
diff --git a/cli/src/app/add.ts b/cli/src/app/add.ts
index b0bb9dabc1..f174958c9c 100644
--- a/cli/src/app/add.ts
+++ b/cli/src/app/add.ts
@@ -413,7 +413,7 @@ export async function addAppInternal(
const message = formatError(ownershipError)
if (!silent)
log.error(`Could not add app ${message}`)
- throw new Error(`Could not add app ${message}`)
+ throw new CliUserError(`Could not add app ${message}`)
}
if (duplicateOutcome === 'duplicate_owned') {
@@ -423,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 Error(`Could not add app: ${takenMessage}`)
+ throw new CliUserError(`Could not add app: ${takenMessage}`)
}
else {
const message = formatError(error)
if (!silent)
log.error(`Could not add app ${message}`)
- throw new Error(`Could not add app ${message}`)
+ throw new CliUserError(`Could not add app ${message}`)
}
}
diff --git a/cli/src/app/todo.ts b/cli/src/app/todo.ts
new file mode 100644
index 0000000000..be32233db0
--- /dev/null
+++ b/cli/src/app/todo.ts
@@ -0,0 +1,197 @@
+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 { 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[]
+}
+
+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 ids: readonly (keyof typeof STEP_TITLES)[] = version === 3
+ ? V3_STEP_IDS
+ : version === 1 ? ['add_app', ...V2_STEP_IDS.slice(1)] : V2_STEP_IDS
+ const reportedSteps = 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 ? 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)
+ 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 ? 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) {
+ 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
+ }
+ 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/credentials-export-command.ts b/cli/src/build/credentials-export-command.ts
index 5c00344a4f..6ff0e5f50e 100644
--- a/cli/src/build/credentials-export-command.ts
+++ b/cli/src/build/credentials-export-command.ts
@@ -1,4 +1,4 @@
-import type { SavedCredentials } from '../schemas/build'
+import type { CredentialsPlatform, CredentialsStoreName, CredentialsStores } from './credentials-store-selection'
import type { FileHandle } from 'node:fs/promises'
import { link, mkdtemp, open, rmdir, unlink } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
@@ -8,18 +8,15 @@ import { canPromptInteractively, formatError } from '../utils'
import { getGlobalCredentialsPath, getLocalCredentialsPath, loadSavedCredentials } from './credentials'
import { canDecodeCredentialBase64, decodeCredentialBase64 } from './credentials-base64'
import { quoteCredentialsExportTerminalValue, writeCredentialsExportStderr } from './credentials-export-terminal'
+import { credentialsPlatformFields, hasConfiguredCredentials, resolveCredentialsStore } from './credentials-store-selection'
-type Platform = 'ios' | 'android'
-type Store = 'local' | 'global'
type CredentialsExportOptions = { appId?: string, platform?: string, local?: boolean, global?: boolean, file?: string, raw?: boolean, decodeBase64?: boolean }
-type CredentialsExportStores = Record
-type ResolvedCredentialsExport = { value: string, source: Store, platforms: Platform[] }
+type ResolvedCredentialsExport = { value: string, source: CredentialsStoreName, platforms: CredentialsPlatform[] }
type FileValue = { data: string | Buffer, decoded: boolean, warnLiteral: boolean }
type FileHandleForExport = Pick
type FileWriterDependencies = { mkdtemp?: typeof mkdtemp, open?: typeof open, link?: typeof link, unlink?: typeof unlink, rmdir?: typeof rmdir }
-const platforms: Platform[] = ['ios', 'android']
-const stores: Store[] = ['local', 'global']
+const platforms: CredentialsPlatform[] = ['ios', 'android']
export function isCredentialsExportInvocation(argv: readonly string[]): boolean {
for (let commandIndex = 2; commandIndex < argv.length - 2; commandIndex++) {
@@ -47,30 +44,16 @@ export function isCredentialsExportInvocation(argv: readonly string[]): boolean
return false
}
-function record(value: unknown): Record | undefined {
- return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : undefined
-}
-
-function platformFields(saved: SavedCredentials | null, platform: Platform): Record | undefined {
- const app = record(saved)
- return app && Object.hasOwn(app, platform) ? record(app[platform]) : undefined
-}
-
-function configured(saved: SavedCredentials | null, platform?: Platform): boolean {
- const fields = platform === undefined ? platforms.map(item => platformFields(saved, item)) : [platformFields(saved, platform)]
- return fields.some(field => Object.values(field ?? {}).some(value => typeof value === 'string'))
-}
-
-function storedValue(saved: SavedCredentials, platform: Platform, variable: string): string | undefined {
- const fields = platformFields(saved, platform)
+function storedValue(saved: CredentialsStores[CredentialsStoreName], platform: CredentialsPlatform, variable: string): string | undefined {
+ const fields = credentialsPlatformFields(saved, platform)
const value = fields && Object.hasOwn(fields, variable) ? fields[variable] : undefined
return typeof value === 'string' ? value : undefined
}
const quoted = (value: string | undefined) => quoteCredentialsExportTerminalValue(value)
-function resolvedFor(saved: SavedCredentials, source: Store, platform: Platform, variable: string): ResolvedCredentialsExport {
- if (!configured(saved, platform))
+function resolvedFor(saved: NonNullable, source: CredentialsStoreName, platform: CredentialsPlatform, variable: string): ResolvedCredentialsExport {
+ if (!hasConfiguredCredentials(saved, platform))
throw new Error(`${platform} is not configured in the ${source} store`)
const value = storedValue(saved, platform, variable)
if (value === undefined)
@@ -78,26 +61,16 @@ function resolvedFor(saved: SavedCredentials, source: Store, platform: Platform,
return { value, source, platforms: [platform] }
}
-export function resolveCredentialsExport(variable: string, options: CredentialsExportOptions, savedStores: CredentialsExportStores): ResolvedCredentialsExport {
- if (options.local && options.global)
- throw new Error('Cannot use --local and --global together')
- if (options.platform !== undefined && !platforms.includes(options.platform as Platform))
+export function resolveCredentialsExport(variable: string, options: CredentialsExportOptions, savedStores: CredentialsStores): ResolvedCredentialsExport {
+ if (options.platform !== undefined && !platforms.includes(options.platform as CredentialsPlatform))
throw new Error('--platform must be ios or android')
- const available = stores.filter(source => configured(savedStores[source]))
- const source = options.local || options.global ? options.local ? 'local' : 'global' : available[0]
- if (source === undefined)
- throw new Error(`No saved Builder credentials for ${quoted(options.appId)}`)
- if (!configured(savedStores[source]))
- throw new Error(`No saved Builder credentials for ${quoted(options.appId)} in the ${source} store`)
- if (!options.local && !options.global && available.length > 1)
- throw new Error('Saved Builder credentials exist in both stores; pass --local or --global')
- const saved = savedStores[source]!
- const selected = options.platform as Platform | undefined
+ const { source, saved } = resolveCredentialsStore(options, savedStores)
+ const selected = options.platform as CredentialsPlatform | undefined
if (selected)
return resolvedFor(saved, source, selected, variable)
- const configuredPlatforms = platforms.filter(platform => configured(saved, platform))
+ const configuredPlatforms = platforms.filter(platform => hasConfiguredCredentials(saved, platform))
if (configuredPlatforms.length === 1)
return resolvedFor(saved, source, configuredPlatforms[0]!, variable)
const [first, second] = configuredPlatforms
diff --git a/cli/src/build/credentials-store-selection.ts b/cli/src/build/credentials-store-selection.ts
new file mode 100644
index 0000000000..2007f0c205
--- /dev/null
+++ b/cli/src/build/credentials-store-selection.ts
@@ -0,0 +1,60 @@
+import type { SavedCredentials } from '../schemas/build'
+import { quoteCredentialsExportTerminalValue } from './credentials-export-terminal'
+
+export type CredentialsStoreName = 'local' | 'global'
+export type CredentialsPlatform = 'ios' | 'android'
+
+export interface CredentialsStoreOptions {
+ appId?: string
+ local?: boolean
+ global?: boolean
+}
+
+export type CredentialsStores = Record
+
+export interface ResolvedCredentialsStore {
+ source: CredentialsStoreName
+ saved: SavedCredentials
+}
+
+const platforms: CredentialsPlatform[] = ['ios', 'android']
+const stores: CredentialsStoreName[] = ['local', 'global']
+
+function record(value: unknown): Record | undefined {
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
+ ? value as Record
+ : undefined
+}
+
+export function credentialsPlatformFields(saved: SavedCredentials | null, platform: CredentialsPlatform): Record | undefined {
+ const app = record(saved)
+ return app && Object.hasOwn(app, platform) ? record(app[platform]) : undefined
+}
+
+export function hasConfiguredCredentials(saved: SavedCredentials | null, platform?: CredentialsPlatform): boolean {
+ const fields = platform === undefined
+ ? platforms.map(item => credentialsPlatformFields(saved, item))
+ : [credentialsPlatformFields(saved, platform)]
+ return fields.some(field => Object.values(field ?? {}).some(value => typeof value === 'string'))
+}
+
+export function resolveCredentialsStore(options: CredentialsStoreOptions, savedStores: CredentialsStores): ResolvedCredentialsStore {
+ if (options.local && options.global)
+ throw new Error('Cannot use --local and --global together')
+
+ const available = stores.filter(source => hasConfiguredCredentials(savedStores[source]))
+ const source = options.local || options.global
+ ? options.local ? 'local' : 'global'
+ : available[0]
+ const appId = quoteCredentialsExportTerminalValue(options.appId)
+
+ if (source === undefined)
+ throw new Error(`No saved Builder credentials for ${appId}`)
+ const saved = savedStores[source]
+ if (!hasConfiguredCredentials(saved))
+ throw new Error(`No saved Builder credentials for ${appId} in the ${source} store`)
+ if (!options.local && !options.global && available.length > 1)
+ throw new Error('Saved Builder credentials exist in both stores; pass --local or --global')
+
+ return { source, saved: saved! }
+}
diff --git a/cli/src/build/ios-provisioning-command.ts b/cli/src/build/ios-provisioning-command.ts
new file mode 100644
index 0000000000..c5bf8099c3
--- /dev/null
+++ b/cli/src/build/ios-provisioning-command.ts
@@ -0,0 +1,321 @@
+import type { SavedCredentials } from '../schemas/build'
+import type { CredentialsStoreName, CredentialsStores } from './credentials-store-selection'
+import type { ProvisioningMap, ProvisioningTargetGroup } from './ios-provisioning-map'
+import type { PbxTarget } from './pbxproj-parser'
+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, getAppId, getConfig } from '../utils'
+import { loadSavedCredentials, updateSavedCredentials } from './credentials'
+import { decodeCredentialBase64 } from './credentials-base64'
+import { resolveCredentialsStore } from './credentials-store-selection'
+import { analyzeProvisioningCoverage, createProvisioningMapEntry, isConcreteBundleId, parseProvisioningMap } from './ios-provisioning-map'
+import { DuplicateProfileError, createProfile, deleteProfile, ensureBundleId, findCertBySha1, generateJwt, verifyApiKey } from './onboarding/apple-api'
+import { findSignableTargets, findXcodeProject } from './pbxproj-parser'
+import { getPlatformDirFromCapacitorConfig } from './platform-paths'
+import { openP12 } from './prescan/checks/ios-certs'
+
+export interface IosProvisioningOptions {
+ local?: boolean
+ global?: boolean
+}
+
+export interface IosProvisioningProject {
+ appId: string
+ targets: PbxTarget[]
+}
+
+export interface IosProvisioningCommandDeps {
+ loadProject: () => Promise
+ loadStores: (appId: string, options: IosProvisioningOptions) => Promise
+ persistMap: (appId: string, source: CredentialsStoreName, map: ProvisioningMap) => Promise
+ canPrompt: () => boolean
+ confirm: (message: string) => Promise
+ logInfo: (message: string) => void
+ generateJwt: typeof generateJwt
+ verifyApiKey: typeof verifyApiKey
+ openP12: typeof openP12
+ findCertBySha1: typeof findCertBySha1
+ ensureBundleId: typeof ensureBundleId
+ createProfile: typeof createProfile
+ deleteProfile: typeof deleteProfile
+}
+
+function formatTargets(targets: ProvisioningTargetGroup[]): string {
+ return targets.map(target => `${target.targetNames.join('/')} (${target.bundleId})`).join(', ')
+}
+
+function formatTargetList(targets: ProvisioningTargetGroup[]): string {
+ return targets
+ .map(target => ` β’ ${target.targetNames.join('/')}\n Bundle ID: ${target.bundleId}`)
+ .join('\n')
+}
+
+async function confirmRequired(deps: IosProvisioningCommandDeps, message: string): Promise {
+ if (!deps.canPrompt())
+ throw new Error('This change requires confirmation in an interactive terminal. Run the command locally and retry.')
+ return deps.confirm(message)
+}
+
+function requireP8Credentials(credentials: NonNullable): void {
+ const required = ['APPLE_KEY_ID', 'APPLE_ISSUER_ID', 'APPLE_KEY_CONTENT', 'BUILD_CERTIFICATE_BASE64'] as const
+ const missing = required.filter(key => !credentials[key])
+ if (missing.length > 0) {
+ throw new Error(`App-specific password credentials are not supported for provisioning profile generation. Save a complete App Store Connect .p8 key and signing certificate first. Missing: ${missing.join(', ')}`)
+ }
+}
+
+function safeCause(error: unknown, secrets: string[]): string {
+ let message = formatError(error)
+ for (const secret of secrets.filter(value => value.length >= 4))
+ message = message.replaceAll(secret, '[REDACTED]')
+ return message
+}
+
+async function prepareAppleCredentials(credentials: NonNullable, deps: IosProvisioningCommandDeps) {
+ requireP8Credentials(credentials)
+ const encodedKey = credentials.APPLE_KEY_CONTENT!
+ let p8Content: string
+ try {
+ p8Content = decodeCredentialBase64(encodedKey).toString('utf8')
+ }
+ catch {
+ throw new Error('The saved App Store Connect .p8 key is invalid')
+ }
+ const secrets = [encodedKey, p8Content, credentials.BUILD_CERTIFICATE_BASE64!, credentials.P12_PASSWORD ?? '']
+ const freshToken = () => {
+ try {
+ return deps.generateJwt(credentials.APPLE_KEY_ID!, credentials.APPLE_ISSUER_ID!, p8Content)
+ }
+ catch {
+ throw new Error('The saved App Store Connect .p8 key is invalid')
+ }
+ }
+
+ try {
+ await deps.verifyApiKey(freshToken())
+ }
+ catch (error) {
+ throw new Error(`The saved App Store Connect .p8 key is invalid or does not have access: ${safeCause(error, secrets)}`)
+ }
+
+ let certificateSha1: string
+ try {
+ certificateSha1 = deps.openP12(credentials.BUILD_CERTIFICATE_BASE64!, credentials.P12_PASSWORD ?? '').sha1
+ }
+ catch {
+ throw new Error('The saved iOS signing certificate or P12 password is invalid')
+ }
+
+ let certificate
+ try {
+ certificate = await deps.findCertBySha1(freshToken(), certificateSha1)
+ }
+ catch (error) {
+ throw new Error(`Cannot verify the saved signing certificate in App Store Connect: ${safeCause(error, secrets)}`)
+ }
+ if (!certificate)
+ throw new Error('The saved iOS signing certificate is not available to this App Store Connect .p8 key')
+ return { certificateId: certificate.id, freshToken, secrets }
+}
+
+async function createTargetProfile(
+ target: ProvisioningTargetGroup,
+ certificateId: string,
+ freshToken: () => string,
+ secrets: string[],
+ deps: IosProvisioningCommandDeps,
+) {
+ let bundleResource
+ try {
+ bundleResource = await deps.ensureBundleId(freshToken(), target.bundleId)
+ }
+ catch (error) {
+ throw new Error(`Could not prepare the Apple bundle id for ${formatTargets([target])}: ${safeCause(error, secrets)}`)
+ }
+
+ const create = () => deps.createProfile(freshToken(), bundleResource.bundleIdResourceId, certificateId, target.bundleId)
+ try {
+ return await create()
+ }
+ catch (error) {
+ if (!(error instanceof DuplicateProfileError))
+ throw new Error(`Could not create a provisioning profile for ${formatTargets([target])}: ${safeCause(error, secrets)}`)
+
+ const replace = await confirmRequired(
+ deps,
+ `Replace these existing Capgo provisioning profiles for ${formatTargets([target])}? ${error.profiles.map(profile => profile.name).join(', ')}`,
+ )
+ if (!replace)
+ throw new Error(`Provisioning profile replacement was declined for ${formatTargets([target])}`)
+
+ const deletedProfiles: typeof error.profiles = []
+ for (const profile of error.profiles) {
+ try {
+ await deps.deleteProfile(freshToken(), profile.id)
+ deletedProfiles.push(profile)
+ }
+ catch {
+ if (deletedProfiles.length > 0) {
+ const deletedList = deletedProfiles.map(deleted => ` β’ ${deleted.name}`).join('\n')
+ throw new Error(`Could not delete all existing Capgo provisioning profiles for ${formatTargets([target])}.\n\nApple already deleted:\n${deletedList}\n\nThe saved map was not changed for this target. Retry the command; the remaining duplicates will be detected again.`)
+ }
+ throw new Error(`Could not delete all existing Capgo provisioning profiles for ${formatTargets([target])}. The saved map was not changed for this target.`)
+ }
+ }
+ try {
+ return await create()
+ }
+ catch {
+ throw new Error(`Existing Capgo provisioning profiles were deleted, but the replacement for ${formatTargets([target])} could not be created. Retry the command.`)
+ }
+ }
+}
+
+export async function runIosProvisioningCommand(options: IosProvisioningOptions, deps: IosProvisioningCommandDeps): Promise {
+ if (options.local && options.global)
+ throw new Error('Cannot use --local and --global together')
+
+ const project = await deps.loadProject()
+ if (!project.appId)
+ throw new Error('The Capacitor project does not define an app id')
+ if (project.targets.length === 0)
+ throw new Error('The iOS Xcode project has no signable targets')
+
+ const unresolved = project.targets.filter(target => !isConcreteBundleId(target.bundleId))
+ if (unresolved.length > 0)
+ throw new Error(`Cannot resolve the bundle id for: ${unresolved.map(target => target.name).join(', ')}`)
+
+ const stores = await deps.loadStores(project.appId, options)
+ const { source, saved } = resolveCredentialsStore({ ...options, appId: project.appId }, stores)
+ const credentials = saved.ios
+ if (!credentials)
+ throw new Error(`No saved iOS Builder credentials for ${project.appId} in the ${source} store`)
+ if (credentials.CAPGO_IOS_DISTRIBUTION === 'ad_hoc')
+ throw new Error('Ad Hoc provisioning is not supported by this command')
+
+ let map = parseProvisioningMap(credentials.CAPGO_IOS_PROVISIONING_MAP)
+ let coverage = analyzeProvisioningCoverage(project.targets, map)
+ if (coverage.missing.length === 0) {
+ deps.logInfo('All iOS targets have provisioning profiles saved in Capgo.')
+ return
+ }
+ if (coverage.wildcardConflict.length > 0)
+ throw new Error('Sorry, multiple matching wildcard provisioning profiles are not supported')
+
+ let declinedWildcard = false
+ if (coverage.wildcardReuse) {
+ const accepted = await confirmRequired(
+ deps,
+ `Update the provisioning profile map so these targets reuse "${coverage.wildcardReuse.entry.name}"?\n\n${formatTargetList(coverage.wildcardReuse.targets)}`,
+ )
+ if (accepted) {
+ const repaired = { ...map }
+ for (const target of coverage.wildcardReuse.targets)
+ repaired[target.bundleId] = coverage.wildcardReuse.entry
+ await deps.persistMap(project.appId, source, repaired)
+ map = repaired
+ coverage = analyzeProvisioningCoverage(project.targets, map)
+ }
+ else {
+ declinedWildcard = true
+ }
+ }
+
+ const generationTargets = declinedWildcard ? coverage.missing : coverage.generation
+ if (generationTargets.length === 0) {
+ deps.logInfo('All iOS targets have provisioning profiles saved in Capgo.')
+ return
+ }
+
+ const apple = await prepareAppleCredentials(credentials, deps)
+ const generate = await confirmRequired(
+ deps,
+ `Generate App Store provisioning profiles for these targets?\n\n${formatTargetList(generationTargets)}`,
+ )
+ if (!generate)
+ throw new Error('Provisioning profile generation was declined; no Apple resources were changed')
+
+ for (const target of generationTargets) {
+ const profile = await createTargetProfile(target, apple.certificateId, apple.freshToken, apple.secrets, deps)
+ const updated = {
+ ...map,
+ [target.bundleId]: createProvisioningMapEntry(profile.profileContent, profile.profileName, target.bundleId),
+ }
+ await deps.persistMap(project.appId, source, updated)
+ map = updated
+ deps.logInfo(`Saved a provisioning profile for ${formatTargets([target])}.`)
+ }
+ deps.logInfo('All iOS targets have provisioning profiles saved in Capgo.')
+}
+
+async function loadDefaultProject(): Promise {
+ const { config } = await getConfig(true)
+ const appId = getAppId(undefined, config)
+ if (!appId)
+ throw new Error('The Capacitor project does not define an app id')
+
+ const projectDir = cwd()
+ const iosDir = resolve(projectDir, getPlatformDirFromCapacitorConfig(config, 'ios'))
+ if (!existsSync(iosDir))
+ throw new Error('iOS is not configured in this Capacitor project. Run `npx cap add ios` first.')
+ const pbxprojPath = findXcodeProject(iosDir)
+ if (!pbxprojPath)
+ throw new Error(`No Xcode project was found in ${iosDir}`)
+ let pbxproj: string
+ try {
+ pbxproj = readFileSync(pbxprojPath, 'utf8')
+ }
+ catch {
+ throw new Error(`Cannot read the Xcode project at ${pbxprojPath}`)
+ }
+ const targets = findSignableTargets(pbxproj)
+ if (targets.length === 0)
+ throw new Error('The iOS Xcode project has no signable targets')
+ return { appId, targets }
+}
+
+async function loadDefaultStores(appId: string, options: IosProvisioningOptions): Promise {
+ if (options.local)
+ return { local: await loadSavedCredentials(appId, true, true), global: null }
+ if (options.global)
+ return { local: null, global: await loadSavedCredentials(appId, false, true) }
+ return {
+ local: await loadSavedCredentials(appId, true, true),
+ global: await loadSavedCredentials(appId, false, true),
+ }
+}
+
+export function defaultIosProvisioningCommandDeps(): IosProvisioningCommandDeps {
+ return {
+ loadProject: loadDefaultProject,
+ loadStores: loadDefaultStores,
+ persistMap: (appId, source, map) => updateSavedCredentials(appId, 'ios', { CAPGO_IOS_PROVISIONING_MAP: JSON.stringify(map) }, source === 'local'),
+ canPrompt: canPromptInteractively,
+ confirm: async message => {
+ const answer = await confirm({ message })
+ return !isCancel(answer) && answer
+ },
+ logInfo: message => log.info(message),
+ generateJwt,
+ verifyApiKey,
+ openP12,
+ findCertBySha1,
+ ensureBundleId,
+ createProfile,
+ deleteProfile,
+ }
+}
+
+export async function iosProvisioningCommand(options: IosProvisioningOptions): Promise {
+ try {
+ await runIosProvisioningCommand(options, defaultIosProvisioningCommandDeps())
+ }
+ catch (error) {
+ log.error(formatError(error))
+ exit(1)
+ }
+}
+
+export { DuplicateProfileError }
diff --git a/cli/src/build/ios-provisioning-map.ts b/cli/src/build/ios-provisioning-map.ts
new file mode 100644
index 0000000000..de7393bf1c
--- /dev/null
+++ b/cli/src/build/ios-provisioning-map.ts
@@ -0,0 +1,184 @@
+import type { PbxTarget } from './pbxproj-parser'
+import { parseMobileprovisionDetailedFromBase64 } from './mobileprovision-parser'
+
+export type ProvisioningMapErrorCode = 'missing' | 'empty' | 'malformed' | 'invalid'
+
+export class ProvisioningMapError extends Error {
+ constructor(public readonly code: ProvisioningMapErrorCode, message: string) {
+ super(message)
+ this.name = 'ProvisioningMapError'
+ }
+}
+
+export interface ProvisioningMapEntry {
+ profile: string
+ name: string
+ readonly bundleId: string
+}
+
+export type ProvisioningMap = Record
+
+export interface ProvisioningTargetGroup {
+ bundleId: string
+ targetNames: string[]
+}
+
+export interface WildcardReuse {
+ entry: ProvisioningMapEntry
+ sourceKeys: string[]
+ targets: ProvisioningTargetGroup[]
+}
+
+export interface ProvisioningCoverage {
+ exact: ProvisioningTargetGroup[]
+ missing: ProvisioningTargetGroup[]
+ unresolved: PbxTarget[]
+ wildcardReuse: WildcardReuse | null
+ wildcardConflict: ProvisioningTargetGroup[]
+ generation: ProvisioningTargetGroup[]
+}
+
+function record(value: unknown): Record | undefined {
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
+ ? value as Record
+ : undefined
+}
+
+function invalidMap(message: string): never {
+ throw new ProvisioningMapError('invalid', message)
+}
+
+/** Cache the parsed bundle ID without changing the persisted { profile, name } contract. */
+export function createProvisioningMapEntry(profile: string, name: string, bundleId: string): ProvisioningMapEntry {
+ const entry = { profile, name } as ProvisioningMapEntry
+ Object.defineProperty(entry, 'bundleId', { value: bundleId })
+ return entry
+}
+
+export function parseProvisioningMap(raw: unknown): ProvisioningMap {
+ if (raw === undefined || raw === null)
+ throw new ProvisioningMapError('missing', 'No provisioning profile map is saved')
+ if (typeof raw !== 'string' || raw.trim() === '')
+ invalidMap('The saved provisioning profile map is empty or invalid')
+
+ let value: unknown
+ try {
+ value = JSON.parse(raw)
+ }
+ catch {
+ throw new ProvisioningMapError('malformed', 'The saved provisioning profile map is not valid JSON')
+ }
+
+ const entries = record(value)
+ if (!entries)
+ invalidMap('The saved provisioning profile map must be a JSON object')
+ if (Object.keys(entries).length === 0)
+ throw new ProvisioningMapError('empty', 'The saved provisioning profile map has no profiles')
+
+ const map = Object.create(null) as ProvisioningMap
+ for (const [bundleId, stored] of Object.entries(entries)) {
+ if (!bundleId.trim())
+ invalidMap('The saved provisioning profile map contains an empty bundle ID')
+ const storedRecord = record(stored)
+ const profile = typeof stored === 'string'
+ ? stored
+ : storedRecord && Object.hasOwn(storedRecord, 'profile') && typeof storedRecord.profile === 'string'
+ ? storedRecord.profile
+ : undefined
+ if (!profile)
+ invalidMap(`The saved provisioning profile for ${bundleId} is invalid`)
+
+ try {
+ const detail = parseMobileprovisionDetailedFromBase64(profile)
+ map[bundleId] = createProvisioningMapEntry(profile, detail.name, detail.bundleId)
+ }
+ catch {
+ invalidMap(`The saved provisioning profile for ${bundleId} is invalid`)
+ }
+ }
+ return map
+}
+
+export function isConcreteBundleId(bundleId: string): boolean {
+ const value = bundleId.trim()
+ return value.length > 0
+ && !value.includes('*')
+ && !value.includes('$(')
+ && !value.includes('${')
+}
+
+export function wildcardBundleMatches(wildcardBundleId: string, targetBundleId: string): boolean {
+ if (wildcardBundleId === '*')
+ return true
+ if (!wildcardBundleId.endsWith('.*'))
+ return false
+ const prefix = wildcardBundleId.slice(0, -2)
+ return prefix.length > 0 && targetBundleId.startsWith(`${prefix}.`) && targetBundleId.length > prefix.length + 1
+}
+
+function groupTargets(targets: PbxTarget[]): ProvisioningTargetGroup[] {
+ const groups = new Map()
+ for (const target of targets) {
+ const existing = groups.get(target.bundleId)
+ if (existing)
+ existing.targetNames.push(target.name)
+ else
+ groups.set(target.bundleId, { bundleId: target.bundleId, targetNames: [target.name] })
+ }
+ return [...groups.values()]
+}
+
+export function analyzeProvisioningCoverage(targets: PbxTarget[], map: ProvisioningMap): ProvisioningCoverage {
+ const unresolved = targets.filter(target => !isConcreteBundleId(target.bundleId))
+ const groups = groupTargets(targets.filter(target => isConcreteBundleId(target.bundleId)))
+ const exact = groups.filter(target => Object.hasOwn(map, target.bundleId))
+ const missing = groups.filter(target => !Object.hasOwn(map, target.bundleId))
+
+ const matchingWildcards = new Map
+ }>()
+
+ for (const [sourceKey, entry] of Object.entries(map)) {
+ const matchingTargets = missing.filter(target => wildcardBundleMatches(entry.bundleId, target.bundleId))
+ if (matchingTargets.length === 0)
+ continue
+ const existing = matchingWildcards.get(entry.profile)
+ if (existing) {
+ existing.sourceKeys.push(sourceKey)
+ for (const target of matchingTargets)
+ existing.targetBundleIds.add(target.bundleId)
+ }
+ else {
+ matchingWildcards.set(entry.profile, {
+ entry,
+ sourceKeys: [sourceKey],
+ targetBundleIds: new Set(matchingTargets.map(target => target.bundleId)),
+ })
+ }
+ }
+
+ const wildcardCandidates = [...matchingWildcards.values()]
+ const matchedBundleIds = new Set(wildcardCandidates.flatMap(candidate => [...candidate.targetBundleIds]))
+ const wildcardConflict = wildcardCandidates.length > 1
+ ? missing.filter(target => matchedBundleIds.has(target.bundleId))
+ : []
+ const candidate = wildcardCandidates.length === 1 ? wildcardCandidates[0]! : undefined
+ const wildcardReuse = candidate
+ ? {
+ entry: candidate.entry,
+ sourceKeys: candidate.sourceKeys,
+ targets: missing.filter(target => candidate.targetBundleIds.has(target.bundleId)),
+ }
+ : null
+
+ return {
+ exact,
+ missing,
+ unresolved,
+ wildcardReuse,
+ wildcardConflict,
+ generation: missing.filter(target => !matchedBundleIds.has(target.bundleId)),
+ }
+}
diff --git a/cli/src/build/onboarding/android/gcp-api.ts b/cli/src/build/onboarding/android/gcp-api.ts
index b072a2ef02..ef0c15697a 100644
--- a/cli/src/build/onboarding/android/gcp-api.ts
+++ b/cli/src/build/onboarding/android/gcp-api.ts
@@ -367,7 +367,8 @@ export async function pollOperation(
*
* - 4β30 characters
* - allowed chars: letters, digits, space, hyphen (`-`), apostrophe (`'`),
- * exclamation (`!`), period (`.`)
+ * exclamation (`!`) β periods (`.`) are **not** allowed (appIds like
+ * `com.example.app` must be normalized before submit)
* - must start and end with a letter or digit
*
* We strip any disallowed character (including em-dashes β which break the
@@ -377,7 +378,7 @@ export async function pollOperation(
*/
export function sanitizeGcpProjectDisplayName(input: string): string {
const fallback = 'Capgo Build'
- const allowed = input.replace(/[^A-Z0-9 \-'!.]/gi, ' ').replace(/\s+/g, ' ').trim()
+ const allowed = input.replace(/[^A-Z0-9 \-'!]/gi, ' ').replace(/\s+/g, ' ').trim()
// Must start and end with a letter or digit.
const trimmed = allowed.replace(/^[^A-Z0-9]+/i, '').replace(/[^A-Z0-9]+$/i, '')
let result = trimmed.slice(0, 30).replace(/[^A-Z0-9]+$/i, '')
diff --git a/cli/src/build/prescan/checks/ios-profiles.ts b/cli/src/build/prescan/checks/ios-profiles.ts
index a62fad458a..47477c0cb2 100644
--- a/cli/src/build/prescan/checks/ios-profiles.ts
+++ b/cli/src/build/prescan/checks/ios-profiles.ts
@@ -1,6 +1,7 @@
// src/build/prescan/checks/ios-profiles.ts
import type { MobileprovisionDetail } from '../../mobileprovision-parser'
import type { Finding, PrescanCheck, ScanContext } from '../types'
+import { analyzeProvisioningCoverage, parseProvisioningMap as parseStoredProvisioningMap, wildcardBundleMatches } from '../../ios-provisioning-map'
import { parseMobileprovisionDetailedFromBase64 } from '../../mobileprovision-parser'
import { openP12 } from './ios-certs'
@@ -29,10 +30,8 @@ export function parseProvisioningMap(ctx: ScanContext): MappedProfile[] {
return []
const entries: MappedProfile[] = []
for (const [bundleId, value] of Object.entries(obj)) {
- if (typeof value === 'string') {
- // tolerated legacy/raw shape: { bundleId: base64 }
+ if (typeof value === 'string')
entries.push({ bundleId, base64: value })
- }
else if (value && typeof value === 'object' && typeof (value as { profile?: unknown }).profile === 'string') {
const entry = value as { profile: string, name?: string }
entries.push({ bundleId, base64: entry.profile, name: entry.name })
@@ -90,11 +89,7 @@ export const profileExpiry: PrescanCheck = {
}
function bundleMatches(profileBundleId: string, appBundleId: string): boolean {
- if (profileBundleId === '*')
- return true
- if (profileBundleId.endsWith('.*'))
- return appBundleId.startsWith(profileBundleId.slice(0, -1))
- return profileBundleId === appBundleId
+ return profileBundleId === appBundleId || wildcardBundleMatches(profileBundleId, appBundleId)
}
export const profileBundleMatch: PrescanCheck = {
@@ -178,24 +173,82 @@ export const certProfilePairing: PrescanCheck = {
export const targetsCovered: PrescanCheck = {
id: 'ios/targets-covered',
platforms: ['ios'],
- appliesTo: hasMap,
+ appliesTo: ctx => ctx.credentials?.CAPGO_IOS_PROVISIONING_MAP !== undefined,
async run(ctx): Promise {
+ let map
+ try {
+ map = parseStoredProvisioningMap(ctx.credentials?.CAPGO_IOS_PROVISIONING_MAP)
+ }
+ catch (error) {
+ return [{
+ id: 'ios/targets-covered',
+ severity: 'error',
+ title: 'Saved iOS provisioning profile map cannot be used',
+ detail: error instanceof Error ? error.message : 'The saved provisioning profile map is invalid',
+ fix: 'Save or update the iOS provisioning profile map before building',
+ }]
+ }
const { findSignableTargets, readPbxproj } = await import('../../pbxproj-parser')
const pbx = readPbxproj(ctx.projectDir)
if (!pbx)
return []
const targets = findSignableTargets(pbx)
- const mapped = parseProvisioningMap(ctx)
- // targets without a resolvable bundle id cannot be matched β skip them rather than false-error
- const missing = targets.filter(t => t.bundleId && !mapped.some(p => bundleMatches(p.bundleId, t.bundleId)))
+ const coverage = analyzeProvisioningCoverage(targets, map)
+ const wildcardOwned = new Set([
+ ...(coverage.wildcardReuse?.targets ?? []),
+ ...coverage.wildcardConflict,
+ ].map(target => target.bundleId))
+ const missing = coverage.missing.filter(target => !wildcardOwned.has(target.bundleId))
if (missing.length === 0)
return []
+ const missingCount = missing.reduce((count, target) => count + target.targetNames.length, 0)
+ const hasMultipleTargets = targets.filter(target => target.bundleId).length > 1
return [{
id: 'ios/targets-covered',
severity: 'error',
- title: `${missing.length} signable target(s) have no provisioning profile mapped`,
- detail: `uncovered: ${missing.map(t => `${t.name} (${t.bundleId})`).join(', ')}`,
- fix: 'Add --ios-provisioning-profile "bundleId=/path/to/profile.mobileprovision" for each and re-save credentials',
+ title: `${missingCount} signable target(s) have no provisioning profile mapped`,
+ detail: `uncovered: ${missing.flatMap(target => target.targetNames.map(name => `${name} (${target.bundleId})`)).join(', ')}`,
+ fix: hasMultipleTargets
+ ? 'Run npx @capgo/cli@latest build credentials ios-provisioning to set up every target'
+ : 'Add --ios-provisioning-profile "bundleId=/path/to/profile.mobileprovision" and re-save credentials',
+ }]
+ },
+}
+
+export const wildcardProfileTargets: PrescanCheck = {
+ id: 'ios/wildcard-profile-targets',
+ platforms: ['ios'],
+ appliesTo: ctx => ctx.credentials?.CAPGO_IOS_PROVISIONING_MAP !== undefined,
+ async run(ctx): Promise {
+ let map
+ try {
+ map = parseStoredProvisioningMap(ctx.credentials?.CAPGO_IOS_PROVISIONING_MAP)
+ }
+ catch {
+ return [] // ios/targets-covered owns malformed and invalid saved maps
+ }
+ const { findSignableTargets, readPbxproj } = await import('../../pbxproj-parser')
+ const pbx = readPbxproj(ctx.projectDir)
+ if (!pbx)
+ return []
+ const coverage = analyzeProvisioningCoverage(findSignableTargets(pbx), map)
+ if (coverage.wildcardConflict.length > 0) {
+ return [{
+ id: 'ios/wildcard-profile-targets',
+ severity: 'error',
+ title: 'Sorry, multiple matching wildcard provisioning profiles are not supported',
+ detail: `targets: ${coverage.wildcardConflict.map(target => `${target.targetNames.join('/')} (${target.bundleId})`).join(', ')}`,
+ fix: 'Remove or replace the conflicting wildcard profiles in the saved map, then retry',
+ }]
+ }
+ if (!coverage.wildcardReuse)
+ return []
+ return [{
+ id: 'ios/wildcard-profile-targets',
+ severity: 'error',
+ title: `${coverage.wildcardReuse.targets.length} target bundle id(s) can reuse a saved wildcard provisioning profile`,
+ detail: `targets: ${coverage.wildcardReuse.targets.map(target => `${target.targetNames.join('/')} (${target.bundleId})`).join(', ')}`,
+ fix: 'Run npx @capgo/cli@latest build credentials ios-provisioning to confirm and update the map',
}]
},
}
diff --git a/cli/src/build/prescan/registry.ts b/cli/src/build/prescan/registry.ts
index 1653822710..8b914e48ca 100644
--- a/cli/src/build/prescan/registry.ts
+++ b/cli/src/build/prescan/registry.ts
@@ -37,7 +37,7 @@ import {
import { credentialsSaved } from './checks/credentials'
import { ascKeyValid, p12Expiry, p12LegacyEncryption, p12Opens } from './checks/ios-certs'
import { infoplistSanity } from './checks/ios-plist'
-import { certProfilePairing, profileBundleMatch, profileExpiry, profileTypeVsMode, targetsCovered } from './checks/ios-profiles'
+import { certProfilePairing, profileBundleMatch, profileExpiry, profileTypeVsMode, targetsCovered, wildcardProfileTargets } from './checks/ios-profiles'
import {
allowNavigationWildcard,
serverCleartext,
@@ -121,7 +121,7 @@ export const ALL_CHECKS: PrescanCheck[] = [
// ios certs / profiles / plist
p12Opens, { ...p12LegacyEncryption, enforceAfter: IOS_P12_LEGACY_ENFORCE_AFTER },
p12Expiry, profileExpiry, profileBundleMatch, profileTypeVsMode,
- certProfilePairing, targetsCovered, infoplistSanity, ascKeyValid,
+ certProfilePairing, targetsCovered, wildcardProfileTargets, infoplistSanity, ascKeyValid,
// android keystore / project
keystoreOpens, keystoreExpiry, cordovaVarsPresent, gradlePropsHeuristics,
playSaJson, flavorExists, agp8PackageAttr,
diff --git a/cli/src/build/request.ts b/cli/src/build/request.ts
index 09d22ab6cd..9720b7bb92 100644
--- a/cli/src/build/request.ts
+++ b/cli/src/build/request.ts
@@ -340,6 +340,52 @@ async function fetchWithRetry(
export type { BuildCredentials, BuildRequestOptions, BuildRequestResult } from '../schemas/build'
+export interface BuildJobCachePayloadInput {
+ cache?: boolean
+ cacheKey?: string
+}
+
+export interface BuildJobCachePayload {
+ cache_enabled?: false
+ cache_key?: string
+ cache_fingerprint_extra?: string
+}
+
+/** Builder job API cache fields: omit cache_enabled when enabled (default), send false when opted out. */
+export function buildJobCachePayload(input?: BuildJobCachePayloadInput): BuildJobCachePayload {
+ const payload: BuildJobCachePayload = {}
+ if (input?.cache === false)
+ payload.cache_enabled = false
+
+ const trimmedCacheKey = input?.cacheKey?.trim()
+ if (trimmedCacheKey) {
+ payload.cache_key = trimmedCacheKey
+ // Builder compatibility: accept cache_key (PR #190) and legacy cache_fingerprint_extra.
+ payload.cache_fingerprint_extra = trimmedCacheKey
+ }
+
+ return payload
+}
+
+export const FAILED_BUILD_CACHE_HINT
+ = 'Tip: if this looks cache-related (stale artifacts between RC/PROD or branches), retry with --cache-key to isolate compilation cache, or --no-cache to skip cache restore.'
+
+/**
+ * Cache isolation tip after a failed native build.
+ * Skip it in caller-handled (Ink onboarding) mode: the TUI streams log.info into
+ * FullscreenBuildOutput, the extra line overflows the golden viewport, and
+ * --cache-key / --no-cache are not how the wizard retries.
+ */
+export function shouldLogFailedBuildCacheHint(options: {
+ cache?: boolean
+ cacheKey?: string
+ aiAnalysisMode?: 'auto-prompt' | 'caller-handled' | 'skip'
+}): boolean {
+ return options.cache !== false
+ && !options.cacheKey?.trim()
+ && options.aiAnalysisMode !== 'caller-handled'
+}
+
/**
* Stream build logs from the server via WebSocket.
* Returns the final status if detected from the stream, or null if stream ended without status.
@@ -1837,6 +1883,14 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO
build_mode: options.buildMode || 'release',
build_options: buildOptionsPayload,
build_credentials: buildCredentialsPayload,
+ ...buildJobCachePayload({ cache: options.cache, cacheKey: options.cacheKey }),
+ }
+
+ if (options.cache === false) {
+ log.info(`βΉοΈ --no-cache specified, compilation cache disabled for this ${platform} build`)
+ }
+ else if (options.cacheKey?.trim()) {
+ log.info(`βΉοΈ --cache-key "${options.cacheKey.trim()}" specified for this ${platform} build`)
}
log.info('β Using credentials (merged from CLI args, env vars, and saved file)')
@@ -2198,7 +2252,10 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO
'Content-Type': 'application/json',
authorization: options.apikey,
}),
- body: JSON.stringify({ app_id: appId }),
+ body: JSON.stringify({
+ app_id: appId,
+ ...buildJobCachePayload({ cache: options.cache, cacheKey: options.cacheKey }),
+ }),
})
if (!startResponse.ok) {
@@ -2308,6 +2365,13 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO
}
else if (finalStatus === 'failed') {
log.error(`Build failed`)
+ if (shouldLogFailedBuildCacheHint({
+ cache: options.cache,
+ cacheKey: options.cacheKey,
+ aiAnalysisMode,
+ })) {
+ log.info(FAILED_BUILD_CACHE_HINT)
+ }
// Non-interactive (CI/CD) failure with neither --ai-analytics nor
// --send-logs: surface the discoverability tip here, INDEPENDENT of log
// capture. The in-handler AI/decideCiFailureActions block below is gated
diff --git a/cli/src/bundle/upload.ts b/cli/src/bundle/upload.ts
index 6db1c1b6d1..6187e10ea2 100644
--- a/cli/src/bundle/upload.ts
+++ b/cli/src/bundle/upload.ts
@@ -1594,8 +1594,9 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio
// onboarding if the app has no build credentials, otherwise a native build.
// Accepting skips this OTA upload (a native build supersedes it). Skipped
// entirely for the programmatic SDK path (silent), which must not prompt,
- // print, or emit CTA telemetry.
- if (incompatible && !silent) {
+ // print, or emit CTA telemetry. Also skipped when `--accept-incompatible`
+ // is set: the caller already marked the mismatch as handled.
+ if (incompatible && !silent && !options.acceptIncompatible) {
// CI / non-interactive with the flag: hard fail now, before the promotional
// Builder ad prints (there is no escape-hatch prompt to offer).
if (options.failOnIncompatible && !interactive)
@@ -1627,6 +1628,9 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio
if (shouldBlockIncompatibleUpload({ incompatible, failOnIncompatible: !!options.failOnIncompatible, interactive, builderAction }))
uploadFailIncompatible()
}
+ else if (incompatible && options.acceptIncompatible && !silent) {
+ log.warn('Proceeding because --accept-incompatible was set. The incompatible-bundle crash warning will not be emailed.')
+ }
if (options.verbose) {
log.info(`[Verbose] Compatibility check completed:`)
log.info(` - Native packages: ${nativePackages ? nativePackages.length : 0}`)
@@ -2063,6 +2067,7 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio
version_new_name: bundle,
...(compatibilityResult.compatibility.versionOldId ? { version_old_id: compatibilityResult.compatibility.versionOldId } : {}),
...(compatibilityResult.compatibility.versionOldName ? { version_old_name: compatibilityResult.compatibility.versionOldName } : {}),
+ ...(options.acceptIncompatible ? { incompatibility_accepted: true } : {}),
},
})
}
@@ -2116,7 +2121,8 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio
/**
* Validate mutually-exclusive and dependent upload options, failing fast (via
* `uploadFail`) before any network call. Exported so the option-conflict guards
- * (e.g. `--fail-on-incompatible` + `--ignore-metadata-check`) can be unit-tested
+ * (e.g. `--fail-on-incompatible` + `--ignore-metadata-check`, or
+ * `--accept-incompatible` + `--fail-on-incompatible`) can be unit-tested
* directly.
*/
export function checkValidOptions(options: OptionsUpload) {
@@ -2184,6 +2190,12 @@ export function checkValidOptions(options: OptionsUpload) {
if (options.failOnIncompatible && options.ignoreMetadataCheck) {
uploadFail('You cannot use --fail-on-incompatible together with --ignore-metadata-check β the metadata check is exactly what --fail-on-incompatible enforces. Remove one of them.')
}
+ if (options.acceptIncompatible && options.failOnIncompatible) {
+ uploadFail('You cannot use --accept-incompatible together with --fail-on-incompatible β one continues despite a mismatch, the other refuses it. Remove one of them.')
+ }
+ if (options.acceptIncompatible && options.ignoreMetadataCheck) {
+ uploadFail('You cannot use --accept-incompatible together with --ignore-metadata-check β accepting a mismatch requires running the compatibility check. Remove one of them.')
+ }
}
async function maybePromptStarCapgoRepo() {
diff --git a/cli/src/capacitor-cli.ts b/cli/src/capacitor-cli.ts
index e45c940aec..d2c52bbdc8 100644
--- a/cli/src/capacitor-cli.ts
+++ b/cli/src/capacitor-cli.ts
@@ -25,7 +25,7 @@ export interface CapacitorCliConfig {
}
}
-export const requireTS: (typescript: unknown, filePath: string) => Record = requireTSUntyped
+export const requireTS: (typescript: unknown, filePath: string) => Record | Promise> = requireTSUntyped
// Serializes a config object the way Capacitor formats its own `.ts` writes, so
// the `.js` config we emit reads identically to the `.ts` one Capacitor produces.
export const formatJSObject: (value: unknown) => string = formatJSObjectUntyped
diff --git a/cli/src/channel/list.ts b/cli/src/channel/list.ts
index dd1070f2bc..c061ea0e4a 100644
--- a/cli/src/channel/list.ts
+++ b/cli/src/channel/list.ts
@@ -14,9 +14,10 @@ export async function listChannelsInternal(appId: string, options: OptionsBase,
appId = getAppId(appId, extConfig?.config)
if (!options.apikey) {
+ const message = 'Missing API key. Provide an API key with --apikey or log in.'
if (!silent)
- log.error('Missing API key, you need to provide an API key to upload your bundle')
- throw new Error('Missing API key')
+ log.error(message)
+ throw new CliUserError(message)
}
if (!appId) {
diff --git a/cli/src/channel/set.ts b/cli/src/channel/set.ts
index 3e57781fbf..fd3d6fd35d 100644
--- a/cli/src/channel/set.ts
+++ b/cli/src/channel/set.ts
@@ -55,6 +55,36 @@ function assertOptionalConfidence(value: number | undefined) {
throw new Error('Auto-pause confidence must be a number greater than 0 and less than 1')
}
+/**
+ * Warn (and optionally throw) when a bundle's native packages don't match the
+ * channel. `--accept-incompatible` continues after the warning so callers can
+ * mark a handled mismatch (runtime plugin guards, etc.).
+ */
+export function rejectOrAcceptIncompatibleChannelBundle(params: {
+ silent: boolean
+ acceptIncompatible?: boolean
+ incompatible: boolean
+ finalCompatibility: Compatibility[]
+ heading: string
+ errorMessage: string
+}): void {
+ if (!params.incompatible)
+ return
+ if (!params.silent) {
+ log.warn(params.heading)
+ log.warn('')
+ displayCompatibilityTable(params.finalCompatibility)
+ log.warn('')
+ log.warn('An app store update may be required for these changes to take effect.')
+ }
+ if (params.acceptIncompatible) {
+ if (!params.silent)
+ log.warn('Proceeding because --accept-incompatible was set.')
+ return
+ }
+ throw new Error(params.errorMessage)
+}
+
export async function setChannelInternal(channel: string, appId: string, options: OptionsSetChannel, silent = false) {
if (!silent)
intro('Set channel')
@@ -81,6 +111,13 @@ export async function setChannelInternal(channel: string, appId: string, options
throw new Error('Missing channel id')
}
+ if (options.acceptIncompatible && options.ignoreMetadataCheck) {
+ const message = 'You cannot use --accept-incompatible together with --ignore-metadata-check β accepting a mismatch requires running the compatibility check. Remove one of them.'
+ if (!silent)
+ log.error(message)
+ throw new Error(message)
+ }
+
const supabase = await createSupabaseClient(options.apikey, options.supaHost, options.supaAnon)
await check2FAComplianceForApp(supabase, appId, silent)
const userId = await resolveUserIdFromApiKey(supabase, options.apikey)
@@ -268,18 +305,16 @@ export async function setChannelInternal(channel: string, appId: string, options
const incompatiblePackages = finalCompatibility.filter(item => !isCompatible(item))
- if (localDependencies.length > 0 && incompatiblePackages.length > 0) {
- if (!silent) {
- log.warn(`Bundle NOT compatible with ${channel} channel`)
- log.warn('')
- displayCompatibilityTable(finalCompatibility)
- log.warn('')
- log.warn('An app store update may be required for these changes to take effect.')
- }
- throw new Error(`Bundle is not compatible with ${channel} channel`)
- }
+ rejectOrAcceptIncompatibleChannelBundle({
+ silent,
+ acceptIncompatible: options.acceptIncompatible,
+ incompatible: localDependencies.length > 0 && incompatiblePackages.length > 0,
+ finalCompatibility,
+ heading: `Bundle NOT compatible with ${channel} channel`,
+ errorMessage: `Bundle is not compatible with ${channel} channel`,
+ })
- if (!silent) {
+ if (!silent && !(localDependencies.length > 0 && incompatiblePackages.length > 0)) {
if (localDependencies.length === 0 && finalCompatibility.length > 0)
log.info(`Ignoring check compatibility with ${channel} channel because the bundle does not contain any native packages`)
else
@@ -318,16 +353,14 @@ export async function setChannelInternal(channel: string, appId: string, options
const incompatiblePackages = finalCompatibility.filter(item => !isCompatible(item))
- if (incompatiblePackages.length > 0) {
- if (!silent) {
- log.warn(`Bundle NOT compatible with ${channel} channel`)
- log.warn('')
- displayCompatibilityTable(finalCompatibility)
- log.warn('')
- log.warn('An app store update may be required for these changes to take effect.')
- }
- throw new Error(`Latest remote bundle is not compatible with ${channel} channel`)
- }
+ rejectOrAcceptIncompatibleChannelBundle({
+ silent,
+ acceptIncompatible: options.acceptIncompatible,
+ incompatible: incompatiblePackages.length > 0,
+ finalCompatibility,
+ heading: `Bundle NOT compatible with ${channel} channel`,
+ errorMessage: `Latest remote bundle is not compatible with ${channel} channel`,
+ })
}
if (!silent)
@@ -350,18 +383,16 @@ export async function setChannelInternal(channel: string, appId: string, options
const incompatiblePackages = finalCompatibility.filter(item => !isCompatible(item))
- if (localDependencies.length > 0 && incompatiblePackages.length > 0) {
- if (!silent) {
- log.warn(`Rollout bundle NOT compatible with ${channel} channel`)
- log.warn('')
- displayCompatibilityTable(finalCompatibility)
- log.warn('')
- log.warn('An app store update may be required for these changes to take effect.')
- }
- throw new Error(`Rollout bundle is not compatible with ${channel} channel`)
- }
+ rejectOrAcceptIncompatibleChannelBundle({
+ silent,
+ acceptIncompatible: options.acceptIncompatible,
+ incompatible: localDependencies.length > 0 && incompatiblePackages.length > 0,
+ finalCompatibility,
+ heading: `Rollout bundle NOT compatible with ${channel} channel`,
+ errorMessage: `Rollout bundle is not compatible with ${channel} channel`,
+ })
- if (!silent) {
+ if (!silent && !(localDependencies.length > 0 && incompatiblePackages.length > 0)) {
if (localDependencies.length === 0 && finalCompatibility.length > 0)
log.info(`Ignoring check compatibility with ${channel} channel because the rollout bundle does not contain any native packages`)
else
@@ -444,16 +475,14 @@ export async function setChannelInternal(channel: string, appId: string, options
const incompatiblePackages = finalCompatibility.filter(item => !isCompatible(item))
- if (localDependencies.length > 0 && incompatiblePackages.length > 0) {
- if (!silent) {
- log.warn(`Rollout bundle NOT compatible with ${channel} channel`)
- log.warn('')
- displayCompatibilityTable(finalCompatibility)
- log.warn('')
- log.warn('An app store update may be required for these changes to take effect.')
- }
- throw new Error(`Rollout bundle is not compatible with ${channel} channel`)
- }
+ rejectOrAcceptIncompatibleChannelBundle({
+ silent,
+ acceptIncompatible: options.acceptIncompatible,
+ incompatible: localDependencies.length > 0 && incompatiblePackages.length > 0,
+ finalCompatibility,
+ heading: `Rollout bundle NOT compatible with ${channel} channel`,
+ errorMessage: `Rollout bundle is not compatible with ${channel} channel`,
+ })
}
channelPayload.version = rolloutVersion
diff --git a/cli/src/config/index.ts b/cli/src/config/index.ts
index 151291507e..30a0ca53de 100644
--- a/cli/src/config/index.ts
+++ b/cli/src/config/index.ts
@@ -4,11 +4,21 @@ import { readFile, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { basename, extname, isAbsolute, relative, resolve, sep } from 'node:path'
import { cwd } from 'node:process'
+import { pathToFileURL } from 'node:url'
import { log } from '@clack/prompts'
import type { CapacitorConfig, ExtConfigPairs } from '../schemas/config'
import { formatJSObject, loadConfig as loadConfigCap, requireTS, writeConfig as writeConfigCap } from '../capacitor-cli'
import { CliUserError } from '../shared/cli-user-error'
+/**
+ * A plain `import()` is downleveled to `require()` when this file is compiled
+ * to CommonJS, which skips Node/Bun native TypeScript loading. Building it
+ * from a string keeps the real ESM loader.
+ *
+ * @see https://github.com/ionic-team/capacitor/issues/8531
+ */
+const dynamicImport = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise>
+
export type { CapacitorConfig, ExtConfigPairs } from '../schemas/config'
let configWriteTarget: string | undefined
@@ -86,6 +96,62 @@ function isTypeScriptCompiler(value: unknown): value is typeof import('typescrip
&& typeof candidate.ModuleKind?.CommonJS === 'number'
}
+const defaultCapacitorConfigFiles = ['capacitor.config.ts', 'capacitor.config.js', 'capacitor.config.json'] as const
+
+function findDefaultCapacitorConfigFile(dir: string): string | undefined {
+ for (const name of defaultCapacitorConfigFiles) {
+ const candidate = resolve(dir, name)
+ if (existsSync(candidate) && statSync(candidate).isFile())
+ return candidate
+ }
+ return undefined
+}
+
+function resolveProjectTypeScript(dir: string): unknown {
+ const packageJson = resolve(dir, 'package.json')
+ try {
+ return createRequire(existsSync(packageJson) ? packageJson : resolve(dir, 'capacitor.config.ts'))('typescript')
+ }
+ catch {
+ return undefined
+ }
+}
+
+/**
+ * TypeScript 7 dropped the classic compiler API from its default export.
+ * Capacitor still resolves `typescript` from the project and reads
+ * `ModuleKind.CommonJS`, which throws and is reported as a missing config file.
+ *
+ * @see https://github.com/Cap-go/capgo.app/issues/3265
+ */
+function projectTypeScriptLacksClassicApi(dir: string): boolean {
+ return !isTypeScriptCompiler(resolveProjectTypeScript(dir))
+}
+
+let cliTypeScriptModule: unknown
+
+function loadCliTypeScript(): unknown {
+ if (cliTypeScriptModule === undefined) {
+ try {
+ cliTypeScriptModule = createRequire(import.meta.url)('typescript')
+ }
+ catch {
+ cliTypeScriptModule = null
+ }
+ }
+ return cliTypeScriptModule
+}
+
+// Node 20 (CLI minimum) loads `.ts` configs through requireTS + the CLI's own
+// runtime TypeScript dependency. Native `import()` of `.ts` is only used as a
+// recovery path on Bun and Node.js 22+, where the runtime can load TypeScript.
+function supportsNativeTypeScriptImport(): boolean {
+ if (process.versions.bun)
+ return true
+ const major = Number(process.versions.node.split('.')[0])
+ return Number.isFinite(major) && major >= 22
+}
+
export async function loadConfigTarget(filePath: string): Promise {
const extension = extname(filePath)
if (extension === '.json')
@@ -106,24 +172,28 @@ export async function loadConfigTarget(filePath: string): Promise
+ if (isTypeScriptCompiler(typescript)) {
try {
- cliTypeScript = createRequire(import.meta.url)('typescript')
+ configModule = await Promise.resolve(requireTS(typescript, filePath)) as Record
}
- catch {
- cliTypeScript = undefined
+ catch (requireTsError) {
+ if (!supportsNativeTypeScriptImport())
+ throw requireTsError
+ configModule = await dynamicImport(pathToFileURL(resolve(filePath)).href)
}
}
- const typescript = isTypeScriptCompiler(projectTypeScript)
- ? projectTypeScript
- : cliTypeScript
- if (!isTypeScriptCompiler(typescript))
+ else if (supportsNativeTypeScriptImport()) {
+ configModule = await dynamicImport(pathToFileURL(resolve(filePath)).href)
+ }
+ else {
throw new Error('Could not load a usable TypeScript compiler for the Capacitor config')
- const configModule = requireTS(typescript, filePath)
+ }
const exportedConfig = configModule.default ?? configModule
return (typeof exportedConfig === 'function' ? await exportedConfig() : await exportedConfig) as CapacitorConfig
}
@@ -147,6 +217,13 @@ module.exports = config
}
export async function loadConfig(): Promise {
+ const configPath = findDefaultCapacitorConfigFile(cwd())
+ if (configPath && extname(configPath) === '.ts' && projectTypeScriptLacksClassicApi(cwd())) {
+ return {
+ config: await loadConfigTarget(configPath),
+ path: getConfigWriteTarget() ?? configPath,
+ }
+ }
const config = await loadConfigCap()
return {
config: config.app.extConfig,
diff --git a/cli/src/docs.ts b/cli/src/docs.ts
index 953f1b1c46..f6c307f385 100644
--- a/cli/src/docs.ts
+++ b/cli/src/docs.ts
@@ -54,8 +54,12 @@ function getCommandEmoji(cmdName: string): string {
emoji = 'π'
else if (cmdName.includes('decrypt'))
emoji = 'π'
- else if (cmdName.includes('debug'))
+ else if (cmdName.includes('debug'))
emoji = 'π'
+ else if (cmdName === 'device')
+ emoji = 'π±'
+ else if (cmdName === 'observe' || cmdName === 'summary' || cmdName === 'metrics' || cmdName === 'events' || cmdName === 'routes' || cmdName === 'versions')
+ emoji = 'π'
else if (cmdName === 'run')
emoji = 'π±'
else if (cmdName.includes('doctor'))
diff --git a/cli/src/index.ts b/cli/src/index.ts
index 97dcc9e870..d69d126e0d 100644
--- a/cli/src/index.ts
+++ b/cli/src/index.ts
@@ -1,6 +1,6 @@
import { cwd, exit } from 'node:process'
import { log } from '@clack/prompts'
-import { InvalidArgumentError, Option, program } from 'commander'
+import { type Command, InvalidArgumentError, Option, program } from 'commander'
import pack from '../package.json'
import { categorizeCliError } from './analytics/error-category'
import { applyCommandAnalyticsOptOut, applyRawCommandAnalyticsOptOut } from './analytics/opt-out'
@@ -14,10 +14,13 @@ 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'
import { manageCredentialsCommand } from './build/credentials-manage'
+import type { IosProvisioningOptions } from './build/ios-provisioning-command'
+import { iosProvisioningCommand } from './build/ios-provisioning-command'
import { syncIosMarketingVersionCommand } from './build/ios-marketing-version'
import { lastOutputCommand } from './build/last-output-command'
import { checkBuildNeeded } from './build/needed'
@@ -51,6 +54,9 @@ 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'
import { getPreviewQr } from './preview/qr'
@@ -58,7 +64,7 @@ import { probe } from './probe'
import { testRunDeviceCommand } from './run/device'
import { CliUserError } from './shared/cli-user-error'
import { TwoFactorComplianceNetworkError } from './shared/two-factor-compliance'
-import { getUserId } from './user/account'
+import { whoami } from './user/whoami'
import { formatError } from './utils'
import { normalizeAutoBumpInput } from './versionHelpers'
@@ -72,6 +78,8 @@ 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)`,
+ 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.`,
}
/** Collector for repeatable CLI options (e.g. --ios-provisioning-profile used multiple times) */
@@ -90,12 +98,15 @@ 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')
deferCommandInvocation(currentCommandPath, commandContext)
@@ -266,7 +277,8 @@ Example: npx @capgo/cli@latest bundle upload com.example.app --path ./dist --cha
)
.option('--auto-min-update-version', `Set the min update version based on native packages`)
.option('--ignore-metadata-check', `Ignores the metadata (node_modules) check when uploading`)
- .option('--fail-on-incompatible', `Fail the upload (exit non-zero) instead of uploading when the bundle is incompatible with the channel's current native packages. In an interactive terminal you can still choose a native build; declining fails. Cannot be combined with --ignore-metadata-check.`)
+ .option('--fail-on-incompatible', `Fail the upload (exit non-zero) instead of uploading when the bundle is incompatible with the channel's current native packages. In an interactive terminal you can still choose a native build; declining fails. Cannot be combined with --ignore-metadata-check or --accept-incompatible.`)
+ .option('--accept-incompatible', `${optionDescriptions.acceptIncompatible} Cannot be combined with --fail-on-incompatible or --ignore-metadata-check.`)
.option('--ignore-checksum-check', `Ignores the checksum check when uploading`)
.option('--force-crc32-checksum', `Force CRC32 checksum for upload (override auto-detection)`)
.option('--timeout ', `Timeout for the upload process in seconds`)
@@ -469,6 +481,19 @@ 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)
@@ -641,6 +666,7 @@ Example: npx @capgo/cli@latest channel set production com.example.app --bundle 1
.option('--send-update-notification', `Send a native update-check notification to devices after updating the linked channel bundle`)
.option('--package-json ', optionDescriptions.packageJson)
.option('--ignore-metadata-check', `Ignore checking node_modules compatibility if present in the bundle`)
+ .option('--accept-incompatible', `${optionDescriptions.acceptIncompatibleChannel} Cannot be combined with --ignore-metadata-check.`)
.option('--supa-host ', optionDescriptions.supaHost)
.option('--supa-anon ', optionDescriptions.supaAnon)
@@ -686,11 +712,12 @@ const account = program
.command('account')
.description(`π€ Manage your Capgo account details and retrieve information for support or collaboration.`)
-account.command('id')
- .description(`πͺͺ Retrieve your account ID, safe to share for collaboration or support purposes in Discord or other platforms.
+account.command('whoami')
+ .alias('id')
+ .description(`πͺͺ Retrieve your account ID and email address.
-Example: npx @capgo/cli@latest account id`)
- .action(getUserId)
+Example: npx @capgo/cli@latest account whoami`)
+ .action(whoami)
.option('-a, --apikey ', optionDescriptions.apikey)
const organization = program
@@ -877,31 +904,23 @@ organisation
const build = program
.command('build')
- .description(`ποΈ Manage native iOS/Android builds through Capgo Cloud.
+ .description(`Build native iOS and Android apps with Capgo Cloud.
-β οΈ Native cloud build requests are currently in LIMITED BETA. Access is restricted.
+Native cloud builds are currently in limited beta.
- π SECURITY GUARANTEE:
- Build credentials are NEVER stored on Capgo servers.
- They are used only during the build and auto-deleted after.
- Build outputs may optionally be uploaded for time-limited download links.
+Quick start:
+ npx @capgo/cli@latest build init
+ npx @capgo/cli@latest build request --platform
-π BEFORE BUILDING:
- Save your credentials first:
- npx @capgo/cli build credentials save --appId --platform ios
- npx @capgo/cli build credentials save --appId --platform android
-
-π€ CAPTURE THE OUTPUT URL FROM CI:
- Pass --output-record to persist the download URL + QR code, then read it
- back with \`build last-output\`:
- npx @capgo/cli build request --platform android --output-upload --output-record /tmp/build.json
- URL=$(npx @capgo/cli build last-output --path /tmp/build.json --field outputUrl)`)
+Run npx @capgo/cli@latest build --help for command-specific options.`)
build
.command('needed [appId]')
- .description(`π§ Print "yes" and exit with code 1 if a native build is required; otherwise print "no" and exit with code 0. Command failures exit with code 2.
+ .summary('Check whether a native build is required')
+ .description(`Print "yes" and exit with code 1 if a native build is required. Otherwise print "no" and exit with code 0. Command failures exit with code 2.
-Example: npx @capgo/cli@latest build needed com.example.app --channel production --verbose`)
+Example:
+ npx @capgo/cli@latest build needed com.example.app --channel production --verbose`)
.action(checkBuildNeeded)
.option('-a, --apikey ', optionDescriptions.apikey)
.option('-c, --channel ', `Channel to compare against. Defaults to CapacitorUpdater.defaultChannel or the public default channel`)
@@ -914,6 +933,7 @@ Example: npx @capgo/cli@latest build needed com.example.app --channel production
build
.command('init')
.alias('onboarding')
+ .summary('Configure build credentials interactively')
.description('Set up build credentials interactively (iOS: certificates + profiles automated; Android: keystore + Google OAuth provisions GCP service account and Play Console invite)')
.option('-a, --apikey ', 'API key to link to your account')
.option('-p, --platform ', 'Platform to onboard (ios or android). If omitted, auto-detects when only one native folder exists; prompts otherwise.')
@@ -931,27 +951,45 @@ build
build
.command('request [appId]')
+ .summary('Request a native build from Capgo Cloud')
.description(`Request a native build from Capgo Cloud.
-This command zips your project and uploads it to Capgo for a remote native build.
-By default the finished artifact can go to the app store (when store credentials are saved)
-and/or to Capgo storage as a time-limited download link (--output-upload).
+The project is zipped and uploaded for a remote native build. The finished artifact can be sent to the app store, uploaded to Capgo for a time-limited download, or both.
- π SECURITY: Credentials are never stored on Capgo servers. They are auto-deleted
- after build completion. Build outputs may optionally be uploaded for time-limited download links.
+Credentials are uploaded only for the build. Their temporary server copies are deleted after it finishes.
-π PREREQUISITE: Save credentials first with:
- \`npx @capgo/cli@latest build credentials save --appId --platform \`
+Before your first build:
+ npx @capgo/cli@latest build init
-Example: npx @capgo/cli@latest build request com.example.app --platform ios --path .
-Android AAB only (no Play upload): npx @capgo/cli@latest build request com.example.app --platform android --no-playstore-upload --output-upload
-iOS IPA only (no TestFlight upload): npx @capgo/cli@latest build request com.example.app --platform ios --ios-distribution ad_hoc --output-upload`)
+Examples:
+ iOS build:
+ npx @capgo/cli@latest build request com.example.app \\
+ --platform ios --path .
+
+ Android artifact without Play upload:
+ npx @capgo/cli@latest build request com.example.app \\
+ --platform android --no-playstore-upload --output-upload
+
+ iOS artifact without TestFlight upload:
+ npx @capgo/cli@latest build request com.example.app \\
+ --platform ios --ios-distribution ad_hoc --output-upload
+
+ Disable the Xcode compilation cache:
+ npx @capgo/cli@latest build request com.example.app \\
+ --platform ios --no-cache
+
+ Use a separate compilation cache:
+ npx @capgo/cli@latest build request com.example.app \\
+ --platform ios --cache-key prod`)
.action(requestBuildCommand)
+ .optionsGroup('General options:')
+ .helpOption('-h, --help', 'Display help for command')
.option('--path ', `Path to the project directory to build (default: current directory)`)
.option('--node-modules ', optionDescriptions.nodeModules)
.option('--platform ', `Target platform: ios or android (required)`)
.option('--build-mode ', `Build mode: debug or release (default: release)`)
// iOS credential CLI options (can also be set via env vars or saved credentials)
+ .optionsGroup('iOS options:')
.option('--build-certificate-base64 ', 'iOS: Base64-encoded .p12 certificate')
.option('--p12-password ', 'iOS: Certificate password (optional if cert has no password)')
.option('--apple-id ', 'iOS: Apple ID email for app-specific password uploads (alternative to App Store Connect API key)')
@@ -965,7 +1003,10 @@ iOS IPA only (no TestFlight upload): npx @capgo/cli@latest build request com.exa
.option('--ios-target ', 'iOS: Xcode target for reading build settings (default: same as scheme)')
.addOption(new Option('--ios-distribution ', 'iOS: Distribution mode. app_store (default) uploads to TestFlight/App Store; ad_hoc skips store upload and builds an Ad Hoc IPA for device install. Use ad_hoc with --output-upload when the App Store app does not exist yet or you only need an IPA download.').choices(['app_store', 'ad_hoc']).default('app_store'))
.option('--ios-provisioning-profile ', 'iOS: Provisioning profile path or bundleId=path mapping (repeatable)', collect, [])
+ .option('--no-cache', 'Disable Xcode compilation cache for this build (default: cache enabled)')
+ .option('--cache-key ', 'Custom compilation cache key for this build (e.g. rc, prod, feature-branch). Use to share or isolate cache between environments. Precedence over the default appId-only key; ignored when --no-cache is set.')
// Android credential CLI options (can also be set via env vars or saved credentials)
+ .optionsGroup('Android options:')
.option('--android-keystore-file ', 'Android: Base64-encoded keystore file')
.option('--keystore-key-alias ', 'Android: Keystore key alias')
.option('--keystore-key-password ', 'Android: Keystore key password')
@@ -976,6 +1017,7 @@ iOS IPA only (no TestFlight upload): npx @capgo/cli@latest build request com.exa
.addOption(new Option('--android-release-status ', 'Android: Google Play release status (draft, completed, inProgress, halted). Default without --submit-to-store-review: draft. With --submit-to-store-review and no status set: completed. Precedence: CLI > env > saved credentials').choices(['draft', 'completed', 'inProgress', 'halted']))
.option('--in-app-update-priority ', 'Android: Google Play in-app update priority for this release (integer 0β5; higher = more urgent). See https://developer.android.com/guide/playcore/in-app-updates. Precedence: CLI > env > saved credentials')
.option('--no-playstore-upload', 'Android: do not upload the AAB/APK to Google Play for this build (ignores saved Play credentials). Use when the Play app does not exist yet, or you only want a Capgo download link. Requires --output-upload.')
+ .optionsGroup('Store options:')
.option('--submit-to-store-review', 'After upload, submit the store release for review instead of leaving it as a draft/inactive build. On Android this defaults to the production track with release_status completed (override with --android-track / PLAY_STORE_TRACK and --android-release-status / PLAY_STORE_RELEASE_STATUS). On iOS this submits the processed TestFlight build to App Store review.')
.option('--store-release-name ', 'Store release name/version label. Android sends this as the Google Play version_name; iOS uses it as the App Store version when creating or reusing the editable version.')
.option('--store-release-notes ', 'Default store release notes. Android uses this as the Play changelog; iOS uses it as the fallback App Store What\'s New text.')
@@ -983,6 +1025,7 @@ iOS IPA only (no TestFlight upload): npx @capgo/cli@latest build request com.exa
.option('--ios-testflight-groups ', 'iOS: optional comma-separated TestFlight external group names or IDs for external beta distribution.')
.option('--ios-automatic-release', 'iOS: automatically release the App Store version after Apple approval. Default is manual release.')
.option('--no-ios-automatic-release', 'iOS: keep the App Store version waiting for manual release after Apple approval.')
+ .optionsGroup('Output and version options:')
.option('--output-upload', 'Upload the finished IPA/APK/AAB to Capgo storage and print a time-limited download link (and QR). Use with --no-playstore-upload (Android) or --ios-distribution ad_hoc (iOS) when you only need the artifact and are not publishing to the store yet. Precedence: CLI > env > saved credentials')
.option('--no-output-upload', 'Do not upload the finished IPA/APK/AAB to Capgo storage (no download link). Store upload still happens when Play/TestFlight credentials are configured. Precedence: CLI > env > saved credentials')
.option('--output-retention ', 'Override output link TTL for this build only (1h to 7d). Examples: 1h, 6h, 2d. Precedence: CLI > env > saved credentials')
@@ -993,6 +1036,7 @@ iOS IPA only (no TestFlight upload): npx @capgo/cli@latest build request com.exa
.option('--no-skip-marketing-version-bump', 'Override saved credentials to re-enable automatic marketing version bump for this build only.')
.option('--sync-ios-version', 'iOS: sync the app version from package.json before uploading the project. Updates MARKETING_VERSION or CFBundleShortVersionString based on the Xcode Info.plist configuration.')
.option('--sync-android-version', 'Android: sync versionName in android/app/build.gradle from package.json before uploading the project. Fails unless versionName is a standalone quoted string literal.')
+ .optionsGroup('Prescan and diagnostics options:')
.option('--ai-analytics', 'On build failure, send logs to Capgo AI for diagnosis. In interactive terminals this skips the upfront confirmation; in CI this auto-uploads and prints the analysis to stderr.')
.option('--no-prescan', 'Skip the automatic pre-build scan')
.option('--prescan-ignore-fatal', 'Run the pre-build scan but never block the build (report only)')
@@ -1001,27 +1045,35 @@ iOS IPA only (no TestFlight upload): npx @capgo/cli@latest build request com.exa
.option('--fail-on-warnings', 'Treat prescan warnings as fatal')
.option('--send-logs-to-support', 'On a CI/CD build failure, automatically upload the build logs to Capgo support (no email required). Capgo support is notified and will follow up by email. Additive to --ai-analytics.')
.addOption(new Option('--send-logs', 'Deprecated alias for --send-logs-to-support').hideHelp())
+ .option('--verbose', optionDescriptions.verbose)
+ .optionsGroup('Capgo options:')
.option('-a, --apikey ', optionDescriptions.apikey)
.option('--supa-host ', optionDescriptions.supaHost)
.option('--supa-anon ', optionDescriptions.supaAnon)
- .option('--verbose', optionDescriptions.verbose)
build
.command('sync-ios-version')
+ .summary('Sync the local iOS marketing version')
.description(`Sync the local iOS app version from package.json.
Updates MARKETING_VERSION or CFBundleShortVersionString based on the Xcode Info.plist configuration.
-Example: npx @capgo/cli@latest build sync-ios-version --path .`)
+Example:
+ npx @capgo/cli@latest build sync-ios-version --path .`)
.option('--path ', 'Path to the project directory (default: current directory)')
.option('--check', 'Check only; exit non-zero when MARKETING_VERSION or CFBundleShortVersionString is out of sync')
.action(syncIosMarketingVersionCommand)
build
.command('prescan [appId]')
+ .summary('Check for build problems before uploading')
.description(`Scan your project and saved credentials for problems that would fail a cloud build β before uploading anything.
-Checks credentials (expiry, passwords, profile pairing), project state (cap sync, node_modules layout), and platform config. Runs automatically inside \`build request\`; this command runs it standalone (e.g. in CI).`)
+Checks credentials, project state, and platform configuration. This scan runs
+automatically inside \`build request\`; use this command to run it separately.
+
+Example:
+ npx @capgo/cli@latest build prescan com.example.app --platform ios`)
.option('--platform ', 'Target platform: ios or android (required)')
.option('--path ', 'Path to the project directory (default: current directory)')
.option('-a, --apikey ', optionDescriptions.apikey)
@@ -1039,6 +1091,7 @@ Checks credentials (expiry, passwords, profile pairing), project state (cap sync
build
.command('last-output')
+ .summary('Read a saved build output record')
.description(`Read the build output record written by a previous \`build request --output-record\`.
Prints the full JSON by default, a single field with --field, or the ASCII QR
@@ -1046,9 +1099,9 @@ code with --qr. Useful in CI to grab the download URL or QR for posting back
to a PR or issue.
Examples:
- npx @capgo/cli build last-output --path /tmp/build.json
- npx @capgo/cli build last-output --path /tmp/build.json --field outputUrl
- npx @capgo/cli build last-output --path /tmp/build.json --qr`)
+ npx @capgo/cli@latest build last-output --path /tmp/build.json
+ npx @capgo/cli@latest build last-output --path /tmp/build.json --field outputUrl
+ npx @capgo/cli@latest build last-output --path /tmp/build.json --qr`)
.action(lastOutputCommand)
.option('--path ', 'Path to the JSON record written by --output-record (required)')
.option('--field ', 'Print a single field (one of: jobId, appId, platform, buildMode, status, outputUrl, qrCodeAscii, qrCodePngPath, finishedAt, schemaVersion)')
@@ -1056,21 +1109,23 @@ Examples:
const buildCredentials = build
.command('credentials')
- .description(`Manage build credentials stored locally on your machine.
+ .summary('Manage locally saved build credentials')
+ .description(`Manage locally saved build credentials.
+
+Credentials are stored in ~/.capgo-credentials/credentials.json globally, or
+in .capgo-credentials.json for one project. They are uploaded only for a build;
+their temporary server copies are deleted after it finishes.
-π SECURITY:
- - Credentials saved to ~/.capgo-credentials/credentials.json (global) or .capgo-credentials.json (local)
- - When building, sent to Capgo but NEVER stored permanently
- - Deleted from Capgo immediately after build
- - Build outputs may optionally be uploaded for time-limited download links
+Setup guides:
+ iOS: https://capgo.app/docs/cli/cloud-build/ios/
+ Android: https://capgo.app/docs/cli/cloud-build/android/
-π DOCUMENTATION:
- iOS setup: https://capgo.app/docs/cli/cloud-build/ios/
- Android setup: https://capgo.app/docs/cli/cloud-build/android/`)
+Run npx @capgo/cli@latest build credentials --help for command-specific options.`)
buildCredentials
.command('apple-key')
.alias('asc-key')
+ .summary('Create an App Store Connect API key on macOS')
.description(`Create an App Store Connect team API key with a guided macOS helper (macOS only).
Opens a native window that walks you through Apple's App Store Connect UI in an
@@ -1079,55 +1134,63 @@ embedded browser, auto-captures the Issuer ID + Key ID, intercepts the one-time
Progress statistics are forwarded to Capgo analytics (disable with CAPGO_DISABLE_TELEMETRY).
Example:
- npx @capgo/cli build credentials apple-key --appId com.example.app`)
+ npx @capgo/cli@latest build credentials apple-key --appId com.example.app`)
.action((options: CreateAppleKeyOptions) => createAppleKeyCommand(options))
.option('-a, --apikey ', optionDescriptions.apikey)
.option('--appId ', 'Save the captured key into this app iOS build credentials')
.option('--local', 'Save into the per-project .capgo-credentials.json instead of the global file')
.option('--json', 'Print the captured Key ID / Issuer ID / .p8 path as JSON')
+buildCredentials
+ .command('ios-provisioning')
+ .summary('Set up profiles for every signable iOS target')
+ .description(`Set up provisioning profiles for every signable iOS target.
+
+Reuses an eligible saved wildcard profile after confirmation, or generates
+missing App Store profiles with the saved App Store Connect .p8 key.
+
+Example:
+ npx @capgo/cli@latest build credentials ios-provisioning`)
+ .option('--local', 'Use credentials from the current project')
+ .option('--global', 'Use credentials from the global store')
+ .action((options: IosProvisioningOptions) => iosProvisioningCommand(options))
+
buildCredentials
.command('save')
- .description(`Save build credentials locally for iOS or Android.
-
-Credentials are stored in:
- - ~/.capgo-credentials/credentials.json (default, global)
- - .capgo-credentials.json in project root (with --local flag)
-
-β οΈ REQUIRED BEFORE BUILDING: You must save credentials before requesting a build.
-
-π These credentials are NEVER stored on Capgo servers permanently.
- They are deleted immediately after the build completes.
-
-π Setup guides:
- iOS: https://capgo.app/docs/cli/cloud-build/ios/
- Android: https://capgo.app/docs/cli/cloud-build/android/
-
-iOS Example:
- npx @capgo/cli build credentials save --platform ios \\
- --certificate ./cert.p12 --p12-password "password" \\
- --ios-provisioning-profile ./profile.mobileprovision \\
- --apple-key ./AuthKey.p8 --apple-key-id "KEY123" \\
- --apple-issuer-id "issuer-uuid" --apple-team-id "team-id"
-
-Multi-target Example (app + widget extension):
- npx @capgo/cli build credentials save --platform ios \\
- --ios-provisioning-profile ./App.mobileprovision \\
- --ios-provisioning-profile com.example.widget=./Widget.mobileprovision \\
- ...
-
-Android Example:
- npx @capgo/cli build credentials save --platform android \\
- --keystore ./release.keystore --keystore-alias "my-key" \\
- --keystore-key-password "key-pass" \\
- --play-config ./service-account.json
-
-Local storage (per-project):
- npx @capgo/cli build credentials save --local --platform ios ...`)
+ .summary('Save iOS or Android build credentials')
+ .description(`Save iOS or Android build credentials on this machine.
+
+Credentials are stored globally by default. Use --local to store them in the
+current project's .capgo-credentials.json file instead.
+
+Setup guides:
+ iOS: https://capgo.app/docs/cli/cloud-build/ios/
+ Android: https://capgo.app/docs/cli/cloud-build/android/
+
+Examples:
+ iOS app with a widget extension:
+ npx @capgo/cli@latest build credentials save \\
+ --appId com.example.app --platform ios \\
+ --certificate ./cert.p12 --p12-password "password" \\
+ --ios-provisioning-profile ./profile.mobileprovision \\
+ --ios-provisioning-profile com.example.widget=./Widget.mobileprovision \\
+ --apple-key ./AuthKey.p8 --apple-key-id "KEY123" \\
+ --apple-issuer-id "issuer-uuid" --apple-team-id "team-id"
+
+ Android:
+ npx @capgo/cli@latest build credentials save \\
+ --appId com.example.app --platform android \\
+ --keystore ./release.keystore --keystore-alias "my-key" \\
+ --keystore-key-password "key-pass" \\
+ --play-config ./service-account.json`)
.action(saveCredentialsCommand)
+ .optionsGroup('General options:')
+ .helpOption('-h, --help', 'Display help for command')
.option('--appId ', 'App ID (e.g., com.example.app) (required)')
.option('--platform ', 'Platform: ios or android (required)')
+ .option('--local', 'Save to .capgo-credentials.json in project root instead of global ~/.capgo-credentials/')
// iOS options
+ .optionsGroup('iOS options:')
.option('--certificate ', 'iOS: Path to .p12 certificate file')
.option('--ios-provisioning-profile ', 'iOS: Provisioning profile path or bundleId=path (repeatable)', collect, [])
.option('--p12-password ', 'iOS: Certificate password (optional if cert has no password)')
@@ -1140,6 +1203,7 @@ Local storage (per-project):
.option('--apple-app-specific-password ', 'iOS: App-specific password (xxxx-xxxx-xxxx-xxxx) for TestFlight uploads')
.option('--apple-app-id ', 'iOS: Numeric App Store Connect app id (required together with --apple-id and --apple-app-specific-password)')
// Android options
+ .optionsGroup('Android options:')
.option('--keystore ', 'Android: Path to keystore file (.keystore or .jks)')
.option('--keystore-alias ', 'Android: Keystore key alias')
.option('--keystore-key-password ', 'Android: Keystore key password')
@@ -1147,8 +1211,7 @@ Local storage (per-project):
.option('--play-config ', 'Android: Path to Play Store service account JSON')
.option('--android-flavor ', 'Android: Product flavor to build (e.g. production). Required if your project has multiple flavors.')
.option('--in-app-update-priority ', 'Android: Google Play in-app update priority for future releases (integer 0β5; higher = more urgent). Omit to leave Playβs existing value untouched.')
- // Storage option
- .option('--local', 'Save to .capgo-credentials.json in project root instead of global ~/.capgo-credentials/')
+ .optionsGroup('Build defaults:')
.option('--output-upload', 'Upload build outputs (IPA/APK/AAB) to Capgo storage and print download links')
.option('--no-output-upload', 'Do not upload build outputs (IPA/APK/AAB) to Capgo storage')
.option('--output-retention ', 'Output link TTL: 1h to 7d (default: 1h). Examples: 1h, 6h, 2d')
@@ -1159,28 +1222,31 @@ Local storage (per-project):
buildCredentials
.command('list')
+ .summary('List saved credentials with secrets masked')
.description(`List saved build credentials (passwords masked).
Shows what credentials are currently saved (both global and local).
Examples:
- npx @capgo/cli build credentials list # List all apps
- npx @capgo/cli build credentials list --appId com.example.app # List specific app`)
+ npx @capgo/cli@latest build credentials list
+ npx @capgo/cli@latest build credentials list --appId com.example.app
+ npx @capgo/cli@latest build credentials list --local`)
.action(listCredentialsCommand)
.option('--appId ', 'App ID to list (optional, lists all if omitted)')
.option('--local', 'List credentials from local .capgo-credentials.json only')
buildCredentials
.command('clear')
+ .summary('Remove saved build credentials')
.description(`Clear saved build credentials.
Remove credentials from storage.
Use --appId and --platform to target specific credentials.
Examples:
- npx @capgo/cli build credentials clear # Clear all apps (global)
- npx @capgo/cli build credentials clear --local # Clear local credentials
- npx @capgo/cli build credentials clear --appId com.example.app --platform ios`)
+ npx @capgo/cli@latest build credentials clear
+ npx @capgo/cli@latest build credentials clear --local
+ npx @capgo/cli@latest build credentials clear --appId com.example.app --platform ios`)
.action(clearCredentialsCommand)
.option('--appId ', 'App ID to clear (optional, clears all apps if omitted)')
.option('--platform ', 'Platform to clear: ios or android (optional, clears all platforms if omitted)')
@@ -1188,19 +1254,23 @@ Examples:
buildCredentials
.command('update')
+ .summary('Update selected saved credential fields')
.description(`Update specific credentials without providing all of them again.
Update existing credentials by providing only the fields you want to change.
Platform is auto-detected from the options you provide.
Examples:
- npx @capgo/cli build credentials update --ios-provisioning-profile ./new-profile.mobileprovision
- npx @capgo/cli build credentials update --local --keystore ./new-keystore.jks`)
+ npx @capgo/cli@latest build credentials update --ios-provisioning-profile ./new-profile.mobileprovision
+ npx @capgo/cli@latest build credentials update --local --keystore ./new-keystore.jks`)
.action(updateCredentialsCommand)
+ .optionsGroup('General options:')
+ .helpOption('-h, --help', 'Display help for command')
.option('--appId ', 'App ID (auto-detected from capacitor.config if omitted)')
.option('--platform ', 'Platform: ios or android (auto-detected from options)')
.option('--local', 'Update local .capgo-credentials.json instead of global')
// iOS options
+ .optionsGroup('iOS options:')
.option('--certificate ', 'Path to P12 certificate file')
.option('--ios-provisioning-profile ', 'Provisioning profile path or bundleId=path (repeatable, additive by default)', collect, [])
.option('--overwrite-ios-provisioning-map', 'Replace the entire provisioning map instead of merging (default: merge)')
@@ -1214,6 +1284,7 @@ Examples:
.option('--apple-app-id ', 'iOS: Numeric App Store Connect app id (required together with --apple-id and --apple-app-specific-password)')
.addOption(new Option('--ios-distribution ', 'iOS: Distribution mode').choices(['app_store', 'ad_hoc']).default('app_store'))
// Android options
+ .optionsGroup('Android options:')
.option('--keystore ', 'Path to keystore file (.keystore or .jks)')
.option('--keystore-alias ', 'Keystore key alias')
.option('--keystore-key-password ', 'Keystore key password')
@@ -1221,6 +1292,7 @@ Examples:
.option('--play-config ', 'Path to Google Play service account JSON')
.option('--android-flavor ', 'Android: Product flavor to build (e.g. production). Required if your project has multiple flavors.')
.option('--in-app-update-priority ', 'Android: Google Play in-app update priority for future releases (integer 0β5; higher = more urgent).')
+ .optionsGroup('Build defaults:')
.option('--output-upload', 'Upload build outputs (IPA/APK/AAB) to Capgo storage and print download links')
.option('--no-output-upload', 'Do not upload build outputs (IPA/APK/AAB) to Capgo storage')
.option('--output-retention ', 'Output link TTL: 1h to 7d. Examples: 1h, 6h, 2d')
@@ -1231,16 +1303,17 @@ Examples:
buildCredentials
.command('manage')
+ .summary('Manage saved credentials interactively')
.description(`Interactively manage saved build credentials.
Browse stored credentials, view what's configured, export a CI/CD-ready .env file,
or delete a platform's credentials. Reuses the same TUI as \`capgo init\`.
Examples:
- npx @capgo/cli build credentials manage
- npx @capgo/cli build credentials manage --appId com.example.app
- npx @capgo/cli build credentials manage --appId com.example.app --platform ios
- npx @capgo/cli build credentials manage --local`)
+ npx @capgo/cli@latest build credentials manage
+ npx @capgo/cli@latest build credentials manage --appId com.example.app
+ npx @capgo/cli@latest build credentials manage --appId com.example.app --platform ios
+ npx @capgo/cli@latest build credentials manage --local`)
.action(manageCredentialsCommand)
.option('--appId ', 'App ID to manage (optional, prompts to pick if omitted)')
.option('--platform ', 'Platform to manage: ios or android (optional, prompts to pick if omitted)')
@@ -1248,11 +1321,15 @@ Examples:
buildCredentials
.command('export ')
+ .summary('Export one saved credential value')
.description(`Export one saved Builder credential or configuration value.
Raw mode prints only the exact stored value to stdout with no trailing newline.
All failures are written to stderr and exit with status 1. Saved local/global
-configuration is used; environment variables are never exported.`)
+configuration is used; environment variables are never exported.
+
+Example:
+ npx @capgo/cli@latest build credentials export BUILD_CERTIFICATE_BASE64 --app-id com.example.app --platform ios --raw`)
.action(exportCredentialsCommand)
.option('--app-id ', 'App ID whose saved Builder value will be exported (required)')
.addOption(new Option('--appId ', 'Compatibility alias for --app-id').hideHelp())
@@ -1265,13 +1342,14 @@ configuration is used; environment variables are never exported.`)
buildCredentials
.command('migrate')
+ .summary('Migrate a legacy iOS provisioning profile')
.description(`Migrate legacy provisioning profile to the new multi-target format.
Converts BUILD_PROVISION_PROFILE_BASE64 to CAPGO_IOS_PROVISIONING_MAP.
Discovers the main bundle ID from your Xcode project automatically.
Example:
- npx @capgo/cli build credentials migrate --platform ios`)
+ npx @capgo/cli@latest build credentials migrate --platform ios`)
.action(migrateCredentialsCommand)
.option('--appId ', 'App ID (auto-detected from capacitor.config if omitted)')
.option('--platform ', 'Platform (only ios is supported)')
@@ -1305,6 +1383,103 @@ Example: npx @capgo/cli@latest probe --platform ios`)
.option('--platform ', 'Platform to probe: ios or android')
.action(probe)
+function addObserveQueryOptions(command: Command) {
+ return command
+ .option('-a, --apikey ', optionDescriptions.apikey)
+ .option('--days ', 'Lookback window in days: 1, 3, 7, or 30 (default: 7)')
+ .option('--action ', 'Filter by stats action, for example app_launch_ready or app_nav')
+ .option('--sort ', 'Sort samples: slowest, fastest, newest, or oldest')
+ .option('--limit ', 'Max rows to return')
+ .option('--version-name ', 'Filter by bundle version name')
+ .option('--json', 'Output as JSON')
+ .option('--supa-host ', optionDescriptions.supaHost)
+ .option('--supa-anon ', optionDescriptions.supaAnon)
+}
+
+const observe = program
+ .command('observe')
+ .description(`π Query Capgo Observe metrics so you can act on launch, crash, WebView, and navigation data.
+
+Start with summary and follow the findings. Capgo has no session id: use observe device DEVICE_ID for a device timeline.
+Navigation does not need Expo Router. Listen to history.pushState, history.replaceState, popstate, hashchange, and Capacitor App appUrlOpen, then send action=app_nav with metadata.route.
+
+Example: npx @capgo/cli@latest observe summary`)
+
+addObserveQueryOptions(
+ observe
+ .command('summary [appId]')
+ .description(`π Actionable Observe findings for an app.
+
+Start here. Each finding includes a next view to query.
+
+Example: npx @capgo/cli@latest observe summary`)
+ .action(async (appId: string | undefined, options: ObserveCliOptions) => {
+ await observeCommand('summary', appId, options)
+ }),
+)
+
+addObserveQueryOptions(
+ observe
+ .command('metrics [appId]')
+ .description(`π Sample Observe timings, slowest first by default.
+
+Use --action app_launch_ready or app_nav, and --sort slowest to find outliers.
+
+Example: npx @capgo/cli@latest observe metrics --action app_launch_ready --sort slowest --json`)
+ .action(async (appId: string | undefined, options: ObserveCliOptions) => {
+ await observeCommand('metrics', appId, options)
+ }),
+)
+
+addObserveQueryOptions(
+ observe
+ .command('events [appId]')
+ .description(`π Observe action counts and latest devices.
+
+Example: npx @capgo/cli@latest observe events --action app_crash_native`)
+ .action(async (appId: string | undefined, options: ObserveCliOptions) => {
+ await observeCommand('events', appId, options)
+ }),
+)
+
+addObserveQueryOptions(
+ observe
+ .command('device [deviceId] [appId]')
+ .description(`π± Device timeline (session substitute) for one device_id.
+
+Capgo has no session id. Read events in time order to see launch, WebView, crashes, and navigations.
+
+Example: npx @capgo/cli@latest observe device DEVICE_ID --json`)
+ .option('-d, --device ', 'Device ID')
+ .action(async (deviceId: string | undefined, appId: string | undefined, options: ObserveCliOptions) => {
+ await observeCommand('device', appId, options, deviceId)
+ }),
+)
+
+addObserveQueryOptions(
+ observe
+ .command('versions [appId]')
+ .description(`π¦ Observe breakdown by bundle version.
+
+Example: npx @capgo/cli@latest observe versions`)
+ .action(async (appId: string | undefined, options: ObserveCliOptions) => {
+ await observeCommand('versions', appId, options)
+ }),
+)
+
+addObserveQueryOptions(
+ observe
+ .command('routes [appId]')
+ .description(`π§ Per-screen Observe timings from metadata.route or action=app_nav.
+
+No Expo Router required. The app should listen to history/popstate/hashchange/appUrlOpen and send metadata.route.
+
+Example: npx @capgo/cli@latest observe routes --json`)
+ .action(async (appId: string | undefined, options: ObserveCliOptions) => {
+ await observeCommand('routes', appId, options)
+ }),
+)
+
program
.command('generate-docs [filePath]')
.description('Generate Markdown documentation for CLI commands - either for README or individual files')
@@ -1328,7 +1503,7 @@ Selected tools exposed via MCP:
- capgo_list_organizations, capgo_add_organization
- capgo_star_repository
- capgo_star_all_repositories
- - capgo_get_account_id, capgo_doctor, capgo_get_stats
+ - capgo_get_account_id, capgo_doctor, capgo_get_stats, capgo_observe
- capgo_request_build, capgo_generate_encryption_keys
Example usage with Claude Desktop:
@@ -1360,6 +1535,8 @@ 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/channel-selection.ts b/cli/src/init/channel-selection.ts
new file mode 100644
index 0000000000..8d51312823
--- /dev/null
+++ b/cli/src/init/channel-selection.ts
@@ -0,0 +1,62 @@
+import type { SupabaseClient } from '@supabase/supabase-js'
+import type { Database } from '../types/supabase.types'
+import { formatError } from '../utils'
+import { isChannelAlreadyExistsError } from './channel-conflict'
+
+interface OnboardingChannel {
+ name: string
+ public: boolean
+}
+
+interface ChannelSelectionPrompts {
+ reuseChannel: (name: string) => Promise
+ chooseName: (existingNames: string[]) => Promise
+ createChannel: (name: string) => Promise
+}
+
+export async function selectOnboardingChannel(
+ supabase: SupabaseClient,
+ appId: string,
+ preferredName: string,
+ prompts: ChannelSelectionPrompts,
+): Promise {
+ const { data, error } = await supabase
+ .from('channels')
+ .select('name, public')
+ .eq('app_id', appId)
+ .order('name')
+
+ if (error)
+ throw new Error(`Cannot check existing channels: ${formatError(error)}`)
+
+ const channels: OnboardingChannel[] = data ?? []
+ const existingChannel = channels.find(channel => channel.name === preferredName)
+ ?? channels.find(channel => channel.public)
+ ?? channels[0]
+
+ if (existingChannel && await prompts.reuseChannel(existingChannel.name))
+ return existingChannel.name
+
+ const existingNames = channels.map(channel => channel.name)
+ while (true) {
+ const name = await prompts.chooseName(existingNames)
+ if (existingNames.includes(name)) {
+ if (await prompts.reuseChannel(name))
+ return name
+ continue
+ }
+
+ try {
+ await prompts.createChannel(name)
+ return name
+ }
+ catch (error) {
+ if (!isChannelAlreadyExistsError(error))
+ throw error
+
+ existingNames.push(name)
+ if (await prompts.reuseChannel(name))
+ return name
+ }
+ }
+}
diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts
index cba23dc8bf..7d995cf7b4 100644
--- a/cli/src/init/command.ts
+++ b/cli/src/init/command.ts
@@ -40,7 +40,7 @@ import { uploadSupportLogs } from '../support/support-upload'
import { canPromptInteractively, consoleWebUrl, createSupabaseClient, defaultApiHost, findBuildCommandForProjectType, findMainFile, findMainFileForProjectType, findProjectType, findRoot, findSavedKeySilent, formatError, getAllPackagesDependencies, getAppId, getBundleVersion, getConfig, getConfigForWrite, getLocalConfig, getNativeProjectResetAdvice, getOrganizationListWithPermission, getPackageScripts, getPMAndCommand, hasCliPermission, PACKNAME, projectIsMonorepo, resolveUserIdFromApiKey, setPMAndCommand, updateConfigbyKey, updateConfigUpdater, validateIosUpdaterSync } from '../utils'
import { buildAppIdConflictSuggestions, isAppAlreadyExistsError } from './app-conflict'
import { loginInitInBrowser, shouldStartInitBrowserLogin } from './browser-login'
-import { isChannelAlreadyExistsError } from './channel-conflict'
+import { selectOnboardingChannel } from './channel-selection'
import { createMissingExecutableError, getAvailablePackageManagers, getMissingPackageManagerExecutable, getPackageManagerInfo, preparePackageManagerCommandEnvironment, probeExecutable, probePackageManagerCommand, resolveExecutableProbeError, waitForCommandResult } from './command-execution'
import { reportInitOnboardingStep } from './onboarding-report'
import { cancel as pCancel, confirm as pConfirm, intro as pIntro, isCancel as pIsCancel, log as pLog, outro as pOutro, select as pSelect, spinner as pSpinner, text as pText } from './prompts'
@@ -48,6 +48,7 @@ import { finishActiveCliReplay, getActiveCliReplaySessionId, isCliTelemetryDisab
import { appendInitStreamingLine, clearInitStreamingOutput, INIT_CANCEL, pushInitLog, setInitCodeDiff, setInitEncryptionSummary, setInitVersionWarning, startInitStreamingOutput, stopInitInkSession, updateInitStreamingStatus, waitForInitLogSkip, waitForInitStreamingContinue } from './runtime'
import { createInitTelemetry, mergeInitProgressTelemetry, parseInitProgressTelemetry } from './telemetry'
import { formatInitResumeMessage, initOnboardingSteps, renderInitOnboardingComplete, renderInitOnboardingFrame, renderInitOnboardingWelcome } from './ui'
+import { formatBundleUploadRunnerCommand, getBundleUploadFailureRecoveryOptions, mergeMonorepoRootUploadPaths, MONOREPO_ROOT_PATHS_NOTE } from './upload-recovery'
import { CAPACITOR_SPLASH_SCREEN_PACKAGE, CAPGO_UPDATER_PACKAGE, getSplashScreenInstallState, getUpdaterInstallState } from './updater'
interface SuperOptions extends Options {
@@ -155,6 +156,7 @@ let globalMainFilePath: string | undefined
let tmpObject: tmp.FileResult['name'] | undefined
let globalPathToPackageJson: string | undefined
+let globalUploadPackageJsonPath: string | undefined
let globalNodeModulesPath: string | undefined
let globalChannelName = defaultChannel
let globalPlatform: 'ios' | 'android' = 'ios'
@@ -372,10 +374,11 @@ export function getGitRepoStatus(startDir = cwd()): GitRepoStatus {
}
}
-export function getInitUpdaterPluginConfig(appId: string, directInstall: boolean) {
+export function getInitUpdaterPluginConfig(appId: string, directInstall: boolean, channelName?: string) {
return {
version: initNativeBundleVersion,
appId,
+ ...(channelName ? { defaultChannel: channelName } : {}),
autoUpdate: directInstall ? 'always' : 'atBackground',
...(directInstall
? {
@@ -1322,6 +1325,7 @@ function markStepDone(step: number, pathToPackageJson?: string, channelName?: st
encryptionSummary: globalEncryptionSummary,
autoTestChange: globalAutoTestChange,
nodeModulesPath: globalNodeModulesPath,
+ uploadPackageJsonPath: globalUploadPackageJsonPath,
}
writeFileSync(getTmpObjectPath(), JSON.stringify(mergeInitProgressTelemetry(progress, activeInitTelemetry?.getProgressMetadata())))
if (pathToPackageJson) {
@@ -1428,6 +1432,7 @@ async function tryResumeOnboarding(
configLoadDir,
mainFilePath,
nodeModulesPath,
+ uploadPackageJsonPath,
channelName,
platform,
delta,
@@ -1514,6 +1519,9 @@ async function tryResumeOnboarding(
if (typeof nodeModulesPath === 'string' && nodeModulesPath.length > 0) {
globalNodeModulesPath = nodeModulesPath
}
+ if (typeof uploadPackageJsonPath === 'string' && uploadPackageJsonPath.length > 0) {
+ globalUploadPackageJsonPath = uploadPackageJsonPath
+ }
if (channelName) {
globalChannelName = channelName
}
@@ -1607,6 +1615,7 @@ async function tryResumeOnboarding(
setInitEncryptionSummary(undefined)
globalAutoTestChange = undefined
globalNodeModulesPath = undefined
+ globalUploadPackageJsonPath = undefined
return undefined
}
catch (err) {
@@ -1619,6 +1628,7 @@ async function tryResumeOnboarding(
setInitEncryptionSummary(undefined)
globalAutoTestChange = undefined
globalNodeModulesPath = undefined
+ globalUploadPackageJsonPath = undefined
return undefined
}
}
@@ -1626,6 +1636,7 @@ async function tryResumeOnboarding(
function cleanupStepsDone() {
globalAutoTestChange = undefined
globalNodeModulesPath = undefined
+ globalUploadPackageJsonPath = undefined
if (!tmpObject) {
return
}
@@ -1730,6 +1741,48 @@ async function askForExistingDirectoryPath(orgId: string, apikey: string, messag
return (selectedPath as string).trim()
}
+async function askForExistingPackageJsonPath(orgId: string, apikey: string, message: string, placeholder?: string): Promise {
+ const selectedPath = await pText({
+ message,
+ placeholder,
+ validate: validatePackageJsonPath,
+ })
+
+ if (pIsCancel(selectedPath)) {
+ await cancelCommand(selectedPath, orgId, apikey)
+ }
+
+ return (selectedPath as string).trim()
+}
+
+async function promptForMonorepoRootUploadPaths(
+ orgId: string,
+ apikey: string,
+ currentPackageJson?: string,
+ currentNodeModules?: string,
+): Promise<{ packageJson?: string, nodeModules?: string }> {
+ pLog.info(MONOREPO_ROOT_PATHS_NOTE)
+ const rootDir = findRoot(cwd())
+ const packageJson = await askForExistingPackageJsonPath(
+ orgId,
+ apikey,
+ 'Monorepo root package.json path:',
+ join(rootDir, PACKNAME),
+ )
+ const nodeModules = await askForExistingDirectoryPath(
+ orgId,
+ apikey,
+ 'Monorepo root node_modules path:',
+ join(rootDir, 'node_modules'),
+ )
+ const promptCwd = cwd()
+ return mergeMonorepoRootUploadPaths(
+ { packageJson, nodeModules },
+ { packageJson: currentPackageJson, nodeModules: currentNodeModules },
+ promptCwd,
+ )
+}
+
/**
* Find the nearest Capacitor config file by walking up the directory tree.
*/
@@ -2548,7 +2601,7 @@ async function addAppStep(organization: Organization, apikey: string, appId: str
}
}
-async function addChannelStep(orgId: string, apikey: string, appId: string) {
+async function addChannelStep(orgId: string, apikey: string, appId: string, supabase: Awaited>, options: SuperOptions) {
const pm = getPMAndCommand()
pLog.success(`β
App ${appId} added β accessible to all members of your organization`)
pLog.info(`π‘ Keep in mind: Capgo cannot deliver updates to app versions that donβt include Capacitor Updater.`)
@@ -2556,69 +2609,64 @@ async function addChannelStep(orgId: string, apikey: string, appId: string) {
pLog.info(`A channel is a release track that controls which users get which updates.`)
pLog.info(`Most apps only need one: "production". You can add more later.`)
pLog.info(`Learn more: https://capgo.app/docs/live-updates/channels/`)
- while (true) {
- let channelName = globalChannelName
- const channelChoice = await pSelect({
- message: 'Which channel name do you want to use?',
- options: [
- { value: 'default', label: `β
Use "${defaultChannel}"` },
- { value: 'custom', label: 'βοΈ Choose a custom name' },
- ],
- })
- await cancelCommand(channelChoice, orgId, apikey)
-
- if (channelChoice === 'default') {
- channelName = defaultChannel
- }
- else {
+ const channelName = await selectOnboardingChannel(supabase, appId, globalChannelName, {
+ reuseChannel: async (name) => {
+ const choice = await pSelect({
+ message: `A channel named "${name}" already exists, do you want to use it or do you want to create a new channel?`,
+ options: [
+ { value: 'use-existing', label: 'Yes, use it' },
+ { value: 'create-new', label: 'No, create a new one' },
+ ],
+ })
+ await cancelCommand(choice, orgId, apikey)
+ if (choice === 'use-existing') {
+ pLog.success(`Using existing channel "${name}" β
`)
+ return true
+ }
+ return false
+ },
+ chooseName: async (existingNames) => {
+ if (!existingNames.includes(defaultChannel)) {
+ const channelChoice = await pSelect({
+ message: 'Which channel name do you want to use?',
+ options: [
+ { value: 'default', label: `β
Use "${defaultChannel}"` },
+ { value: 'custom', label: 'βοΈ Choose a custom name' },
+ ],
+ })
+ await cancelCommand(channelChoice, orgId, apikey)
+ if (channelChoice === 'default')
+ return defaultChannel
+ }
const selectedChannelName = await pText({
message: 'Enter the channel name to use for onboarding:',
placeholder: 'e.g. staging, beta, dev',
validate: validateChannelName,
})
await cancelCommand(selectedChannelName, orgId, apikey)
- channelName = (selectedChannelName as string).trim()
- }
-
- globalChannelName = channelName
- const s = pSpinner()
- s.start(`Running: ${pm.runner} @capgo/cli@latest channel add ${channelName} ${appId} --default`)
- try {
- const addChannelRes = await addChannelInternal(channelName, appId, {
- default: true,
- apikey,
- }, true)
- if (!addChannelRes)
- s.stop(`Channel already added β
`)
- else
+ return (selectedChannelName as string).trim()
+ },
+ createChannel: async (name) => {
+ const s = pSpinner()
+ s.start(`Running: ${pm.runner} @capgo/cli@latest channel add ${name} ${appId} --default`)
+ try {
+ await addChannelInternal(name, appId, {
+ default: true,
+ apikey,
+ supaHost: options.supaHost,
+ supaAnon: options.supaAnon,
+ }, true)
s.stop(`Channel add done β
`)
- await markStep(orgId, apikey, 'add-channel', appId)
- return channelName
- }
- catch (error) {
- if (!isChannelAlreadyExistsError(error)) {
+ }
+ catch (error) {
s.stop(`Channel creation failed β`)
throw error
}
-
- s.stop(`Channel already exists`)
-
- const existingChannelChoice = await pSelect({
- message: `The channel "${channelName}" already exists. What would you like to do?`,
- options: [
- { value: 'use-existing', label: 'β
Use the existing channel' },
- { value: 'change', label: 'βοΈ Choose a different channel name' },
- ],
- })
- await cancelCommand(existingChannelChoice, orgId, apikey)
-
- if (existingChannelChoice === 'use-existing') {
- pLog.success(`Using existing channel "${channelName}" β
`)
- await markStep(orgId, apikey, 'add-channel', appId)
- return channelName
- }
- }
- }
+ },
+ })
+ globalChannelName = channelName
+ await markStep(orgId, apikey, 'add-channel', appId)
+ return channelName
}
function rememberPackageJsonPath(packageJsonPath: string): void {
@@ -2638,6 +2686,8 @@ function validatePackageJsonPath(value: string | undefined): string | undefined
return 'Path is required.'
if (!existsSync(trimmedValue))
return `Path ${trimmedValue} does not exist`
+ if (!statSync(trimmedValue).isFile())
+ return 'Selected path is not a file'
if (path.basename(trimmedValue) !== PACKNAME)
return 'Selected a file that is not a package.json file'
}
@@ -3034,7 +3084,7 @@ async function addUpdaterStep(orgId: string, apikey: string, appId: string) {
if (doDirectInstall) {
await updateConfigbyKey('SplashScreen', { launchAutoHide: false })
}
- return updateConfigUpdater(getInitUpdaterPluginConfig(appId, delta))
+ return updateConfigUpdater(getInitUpdaterPluginConfig(appId, delta, globalChannelName))
})
s.stop(`Updated ${formatInitFilePath(updatedConfig.path)} β
`)
break
@@ -4927,14 +4977,13 @@ async function maybeOfferAutoTestCleanup(orgId: string, apikey: string, appId: s
const pm = getPMAndCommand()
const cleanupVersion = getSuggestedCleanupBundleVersion(currentVersion)
- const cleanupUploadCommand = [
- `${pm.runner} @capgo/cli@latest bundle upload ${appId}`,
- `--bundle ${cleanupVersion}`,
- `--channel ${globalChannelName}`,
- delta ? '--delta-only' : '',
- globalPathToPackageJson ? `--package-json ${globalPathToPackageJson}` : '',
- globalNodeModulesPath ? `--node-modules ${globalNodeModulesPath}` : '',
- ].filter(Boolean).join(' ')
+ const cleanupUploadCommand = formatBundleUploadRunnerCommand(pm.runner, appId, {
+ bundle: cleanupVersion,
+ channel: globalChannelName,
+ deltaOnly: delta,
+ packageJson: globalUploadPackageJsonPath ?? globalPathToPackageJson,
+ nodeModules: globalNodeModulesPath,
+ })
pLog.info(reverted
? 'Build and upload one more cleanup bundle so the onboarding test change disappears from the installed app.'
@@ -4951,25 +5000,60 @@ async function uploadStep(orgId: string, apikey: string, appId: string, newVersi
const doBundle = await pConfirm({ message: `Upload the updated ${appId} bundle (v${newVersion}) to Capgo?` })
await cancelCommand(doBundle, orgId, apikey)
if (doBundle) {
- let nodeModulesPath: string | undefined
+ let nodeModulesPath: string | undefined = globalNodeModulesPath
+ let uploadPackageJsonPath = globalUploadPackageJsonPath ?? selectedPackageJsonPath
const isMonorepo = projectIsMonorepo(cwd())
+ let warnedMonorepo = false
+
+ const recoverFromUploadFailure = async (failureText: string): Promise<'cancel' | 'retry'> => {
+ const continueResult = await waitForInitStreamingContinue('Press Enter to continue, or Ctrl+C to cancel.')
+ clearInitStreamingOutput()
+ if (pIsCancel(continueResult)) {
+ await cancelCommand(continueResult, orgId, apikey)
+ return 'cancel'
+ }
+ const choice = await selectRecoveryOption(
+ orgId,
+ apikey,
+ `Upload failed: ${failureText}\nWhat do you want to do?`,
+ getBundleUploadFailureRecoveryOptions(),
+ failureText,
+ supportPlatform,
+ )
+ if (choice === 'retry-with-monorepo-paths') {
+ const paths = await promptForMonorepoRootUploadPaths(orgId, apikey, uploadPackageJsonPath, nodeModulesPath)
+ uploadPackageJsonPath = paths.packageJson
+ nodeModulesPath = paths.nodeModules
+ globalUploadPackageJsonPath = uploadPackageJsonPath
+ globalNodeModulesPath = nodeModulesPath
+ // Persist the selected paths on the existing step-9 checkpoint so
+ // resume can restore them if this retry fails and the user exits.
+ // Do not mark step 10 complete until upload succeeds.
+ markStepDone(9)
+ }
+ return 'retry'
+ }
+
while (true) {
const s = pSpinner()
s.start(`Running: ${pm.runner} @capgo/cli@latest bundle upload ${delta ? '--delta-only' : ''}`)
- if (globalPathToPackageJson && isMonorepo) {
- pLog.warn(`You are most likely using a monorepo, please provide the path to your package.json file AND node_modules path folder when uploading your bundle`)
- pLog.warn(`Example: ${pm.runner} @capgo/cli@latest bundle upload --package-json ./packages/my-app/package.json --node-modules ./packages/my-app/node_modules ${delta ? '--delta-only' : ''}`)
+ if (isMonorepo && !warnedMonorepo) {
+ warnedMonorepo = true
+ pLog.warn('This project looks like a monorepo. Bundle upload needs the monorepo root package.json and the hoisted root node_modules folder.')
+ pLog.info(MONOREPO_ROOT_PATHS_NOTE)
+ pLog.warn(`Example: ${pm.runner} @capgo/cli@latest bundle upload --package-json ./package.json --node-modules ./node_modules ${delta ? '--delta-only' : ''}`)
nodeModulesPath ||= join(findRoot(cwd()), 'node_modules')
- pLog.warn(`Using node modules path: ${nodeModulesPath}`)
- if (!existsSync(nodeModulesPath)) {
+ pLog.warn(`Using monorepo root node_modules path: ${nodeModulesPath}`)
+ const firstNodeModulesPath = nodeModulesPath.split(',')[0]?.trim()
+ if (firstNodeModulesPath && !existsSync(firstNodeModulesPath)) {
s.stop('Upload blocked β')
- pLog.error(`Node modules path does not exist`)
- nodeModulesPath = await askForExistingDirectoryPath(orgId, apikey, 'Enter the path to the correct node_modules directory:', nodeModulesPath)
+ pLog.error('Monorepo root node_modules path does not exist')
+ nodeModulesPath = await askForExistingDirectoryPath(orgId, apikey, 'Monorepo root node_modules path:', nodeModulesPath)
continue
}
}
- globalNodeModulesPath = isMonorepo ? nodeModulesPath : undefined
+ globalNodeModulesPath = nodeModulesPath
let uploadRes: Awaited> | undefined
const appendUploadOutput = (message: string, prefix = '') => {
@@ -5010,8 +5094,8 @@ async function uploadStep(orgId: string, apikey: string, appId: string, newVersi
uploadRes = await uploadBundleInternal(appId, {
channel: globalChannelName,
apikey,
- packageJson: isMonorepo ? selectedPackageJsonPath : undefined,
- nodeModules: isMonorepo ? nodeModulesPath : undefined,
+ packageJson: uploadPackageJsonPath,
+ nodeModules: nodeModulesPath,
deltaOnly: delta,
bundle: newVersion,
ignoreChecksumCheck: true,
@@ -5034,27 +5118,15 @@ async function uploadStep(orgId: string, apikey: string, appId: string, newVersi
catch (error) {
const failureText = formatError(error)
updateInitStreamingStatus('error', failureText)
- const continueResult = await waitForInitStreamingContinue('Press Enter to continue, or Ctrl+C to cancel.')
- clearInitStreamingOutput()
- if (pIsCancel(continueResult)) {
- await cancelCommand(continueResult, orgId, apikey)
+ const recovery = await recoverFromUploadFailure(failureText)
+ if (recovery === 'cancel')
return
- }
- await selectRecoveryOption(orgId, apikey, `Upload failed: ${failureText}\nWhat do you want to do?`, [
- { value: 'retry', label: 'Retry bundle upload' },
- ], failureText, supportPlatform)
continue
}
if (!uploadRes?.success) {
- const continueResult = await waitForInitStreamingContinue('Press Enter to continue, or Ctrl+C to cancel.')
- clearInitStreamingOutput()
- if (pIsCancel(continueResult)) {
- await cancelCommand(continueResult, orgId, apikey)
+ const recovery = await recoverFromUploadFailure('Bundle upload did not complete successfully.')
+ if (recovery === 'cancel')
return
- }
- await selectRecoveryOption(orgId, apikey, 'Bundle upload failed. What do you want to do?', [
- { value: 'retry', label: 'Retry bundle upload' },
- ], 'Bundle upload did not complete successfully.', supportPlatform)
continue
}
@@ -5084,15 +5156,17 @@ async function uploadStep(orgId: string, apikey: string, appId: string, newVersi
}
}
else {
- const manualUploadCommandParts = [
- `${pm.runner} @capgo/cli@latest bundle upload ${appId}`,
- `--bundle ${newVersion}`,
- `--channel ${globalChannelName}`,
- delta ? '--delta-only' : '',
- globalPathToPackageJson ? `--package-json ${globalPathToPackageJson}` : '',
- ]
- const manualUploadCommand = manualUploadCommandParts.filter(Boolean).join(' ')
+ const manualUploadCommand = formatBundleUploadRunnerCommand(pm.runner, appId, {
+ bundle: newVersion,
+ channel: globalChannelName,
+ deltaOnly: delta,
+ packageJson: globalUploadPackageJsonPath ?? globalPathToPackageJson,
+ nodeModules: globalNodeModulesPath,
+ })
pLog.info(`Upload yourself from ${selectedProjectDir} with command: ${manualUploadCommand}`)
+ if (projectIsMonorepo(cwd())) {
+ pLog.info(MONOREPO_ROOT_PATHS_NOTE)
+ }
}
await markStep(orgId, apikey, 'upload', appId)
}
@@ -5522,6 +5596,7 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup
stepToSkip = 0
resumed = undefined
globalNodeModulesPath = undefined
+ globalUploadPackageJsonPath = undefined
globalChannelName = defaultChannel
globalPlatform = 'ios'
globalDelta = false
@@ -5668,7 +5743,7 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup
if (stepToSkip < 2) {
renderCurrentStep(2)
- channelName = await addChannelStep(orgId, options.apikey, appId)
+ channelName = await addChannelStep(orgId, options.apikey, appId, supabase, options)
globalChannelName = channelName
markStepDone(2, undefined, channelName)
}
diff --git a/cli/src/init/mcp/engine.ts b/cli/src/init/mcp/engine.ts
index e991ed7782..7a45a60b9b 100644
--- a/cli/src/init/mcp/engine.ts
+++ b/cli/src/init/mcp/engine.ts
@@ -4,6 +4,7 @@ import type { ChoiceOption, LiveUpdatePhase, NextStepResult, Platform } from './
import type { LiveUpdateProgress } from './progress.js'
import { LIVE_UPDATE_ROADMAP, LIVE_UPDATE_RULES, NEXT_STEP_TOOL } from './contract.js'
import { explainForState } from './explanations.js'
+import { withMonorepoUploadRetryHint } from '../upload-recovery.js'
import { initOnboardingSteps } from '../ui.js'
import { clearSession, getSession, mergeSession } from './session-state.js'
@@ -475,7 +476,7 @@ async function runAutoEffect(deps: EngineDeps, facts: LiveUpdateFacts, state: st
encrypt: progress?.encryptionEnabled,
})
if (!res.ok)
- return { ok: false, error: res.error ?? 'Upload failed' }
+ return { ok: false, error: withMonorepoUploadRetryHint(res.error ?? 'Upload failed') }
mergeProgress(deps, progress, { step_done: 10, appId })
return { ok: true }
}
diff --git a/cli/src/init/mcp/explanations.ts b/cli/src/init/mcp/explanations.ts
index f632c52b02..03a36af052 100644
--- a/cli/src/init/mcp/explanations.ts
+++ b/cli/src/init/mcp/explanations.ts
@@ -19,7 +19,7 @@ const EXPLANATIONS: Record = {
'run-on-device': 'WHAT: Launch the app on a real device or simulator.\nWHY: OTA updates apply inside a running native shell β we need the baseline app installed first.\nWHAT TO DO: Run the command shown, confirm the app opens, then continue.',
'make-test-change': 'WHAT: Apply a visible test change and bump the OTA version.\nWHY: We need a new bundle version to upload and verify the update path.\nWHAT TO DO: Wait β this step runs automatically after git is clean (or you chose to continue with a dirty repo).',
'dirty-git': 'WHAT: Your git working tree has uncommitted changes.\nWHY: The test change step edits project files; a dirty repo makes rollback harder.\nOPTIONS: "check-again" after you commit or stash; "continue-dirty" proceeds anyway (not recommended).\nWHAT TO DO: Commit or stash changes, then check again.',
- 'upload-bundle': 'WHAT: Upload the new web bundle to Capgo on your channel.\nWHY: Devices poll Capgo for bundles linked to their channel.\nWHAT TO DO: Wait β this step runs automatically.',
+ 'upload-bundle': 'WHAT: Upload the new web bundle to Capgo on your channel.\nWHY: Devices poll Capgo for bundles linked to their channel.\nWHAT TO DO: Wait β this step runs automatically. If upload fails in a monorepo, retry with the monorepo root package.json and the monorepo root node_modules paths (not the app package folder).',
'test-update': 'WHAT: Confirm the device received and applied the OTA update.\nWHY: This validates the full live-update path end to end.\nWHAT TO DO: Relaunch or background/foreground the app, look for the test banner or change, then confirm.',
'completion': 'WHAT: OTA onboarding is complete.\nWHY: Your app is registered, the updater is wired, and a test update succeeded.\nWHAT TO DO: Use `npx @capgo/cli@latest bundle upload` for future releases on your channel.',
}
diff --git a/cli/src/init/upload-recovery.ts b/cli/src/init/upload-recovery.ts
new file mode 100644
index 0000000000..ea917c3b06
--- /dev/null
+++ b/cli/src/init/upload-recovery.ts
@@ -0,0 +1,96 @@
+import { resolve } from 'node:path'
+import { shellQuotePath } from '../app/info'
+import { formatRunnerCommand } from '../runner-command'
+
+export const MONOREPO_ROOT_PATHS_NOTE = 'These must be the monorepo/workspace root paths β the workspace package.json and the hoisted node_modules folder β not the app package under apps/ or packages/.'
+
+export const MONOREPO_UPLOAD_RETRY_HINT = 'If this app lives in a monorepo, retry the upload with the monorepo root package.json and the monorepo root node_modules paths (not the app package folder).'
+
+export type BundleUploadRecoveryChoice = 'retry' | 'retry-with-monorepo-paths'
+
+export function getBundleUploadFailureRecoveryOptions(): { value: BundleUploadRecoveryChoice, label: string, hint?: string }[] {
+ return [
+ { value: 'retry', label: 'Retry bundle upload' },
+ {
+ value: 'retry-with-monorepo-paths',
+ label: 'Provide monorepo root package.json and node_modules paths, then retry',
+ hint: 'Workspace root, not the app package folder',
+ },
+ ]
+}
+
+export function joinUniqueUploadPaths(...paths: Array): string | undefined {
+ const seen = new Set()
+ const result: string[] = []
+ for (const value of paths) {
+ if (!value)
+ continue
+ for (const part of value.split(',')) {
+ const trimmed = part.trim()
+ if (!trimmed || seen.has(trimmed))
+ continue
+ seen.add(trimmed)
+ result.push(trimmed)
+ }
+ }
+ return result.length ? result.join(',') : undefined
+}
+
+export function resolveUploadPaths(paths: string | undefined, baseDir: string): string | undefined {
+ if (!paths)
+ return undefined
+ return joinUniqueUploadPaths(
+ ...paths.split(',').map(part => part.trim()).filter(Boolean).map(part => resolve(baseDir, part)),
+ )
+}
+
+
+export function mergeMonorepoRootUploadPaths(
+ prompted: { packageJson?: string, nodeModules?: string },
+ current: { packageJson?: string, nodeModules?: string },
+ promptCwd: string,
+): { packageJson?: string, nodeModules?: string } {
+ return {
+ packageJson: joinUniqueUploadPaths(
+ resolveUploadPaths(prompted.packageJson, promptCwd),
+ resolveUploadPaths(current.packageJson, promptCwd),
+ ),
+ nodeModules: joinUniqueUploadPaths(
+ resolveUploadPaths(prompted.nodeModules, promptCwd),
+ resolveUploadPaths(current.nodeModules, promptCwd),
+ ),
+ }
+}
+
+export function formatBundleUploadRunnerCommand(
+ runner: string,
+ appId: string,
+ options: {
+ bundle?: string
+ channel?: string
+ deltaOnly?: boolean
+ packageJson?: string
+ nodeModules?: string
+ },
+): string {
+ const args: string[] = ['@capgo/cli@latest', 'bundle', 'upload', appId]
+ if (options.bundle)
+ args.push('--bundle', options.bundle)
+ if (options.channel)
+ args.push('--channel', options.channel)
+ if (options.deltaOnly)
+ args.push('--delta-only')
+ if (options.packageJson)
+ args.push('--package-json', shellQuotePath(options.packageJson))
+ if (options.nodeModules)
+ args.push('--node-modules', shellQuotePath(options.nodeModules))
+ return formatRunnerCommand(runner, args)
+}
+
+export function withMonorepoUploadRetryHint(error: string): string {
+ if (!error)
+ return MONOREPO_UPLOAD_RETRY_HINT
+ if (error.includes(MONOREPO_UPLOAD_RETRY_HINT))
+ return error
+ return `${error}\n${MONOREPO_UPLOAD_RETRY_HINT}`
+}
diff --git a/cli/src/mcp/instructions.ts b/cli/src/mcp/instructions.ts
index df427cd9b8..3bd063086b 100644
--- a/cli/src/mcp/instructions.ts
+++ b/cli/src/mcp/instructions.ts
@@ -4,8 +4,8 @@
*/
export function buildServerInstructions(opts: { onboardingEnabled: boolean, liveUpdateEnabled: boolean }): string {
const base
- = 'Capgo Cloud MCP server: manage Capgo apps and their live updates β list apps, '
- + 'upload and clean up bundles, set or override channels, read update and usage stats, '
+ = 'Capgo Cloud MCP server: manage Capgo apps and live updates β list apps, '
+ + 'upload and clean up bundles, set channels, query Observe (capgo_observe, start at summary), '
+ 'and request native cloud builds. Tools use the saved Capgo API key; not signed in? Call capgo_login.'
const parts = [base]
diff --git a/cli/src/mcp/server.ts b/cli/src/mcp/server.ts
index c29f7b6619..582b0a185b 100644
--- a/cli/src/mcp/server.ts
+++ b/cli/src/mcp/server.ts
@@ -21,6 +21,7 @@ import {
mcpGetStatsInputSchema,
mcpListBundlesInputSchema,
mcpListChannelsInputSchema,
+ mcpObserveInputSchema,
mcpProbeInputSchema,
mcpRequestBuildInputSchema,
mcpUpdateAppInputSchema,
@@ -212,6 +213,7 @@ async function startMcpServerInternal(restoreConfigWriteTarget: () => void): Pro
autoSetBundle,
autoBump,
encrypt,
+ acceptIncompatible,
capacitorConfig,
}) => {
const result = await sdk.uploadBundle({
@@ -228,6 +230,7 @@ async function startMcpServerInternal(restoreConfigWriteTarget: () => void): Pro
autoSetBundle,
autoBump,
encrypt,
+ acceptIncompatible,
capacitorConfig,
})
if (!result.success) {
@@ -434,7 +437,7 @@ async function startMcpServerInternal(restoreConfigWriteTarget: () => void): Pro
description: 'Update channel settings including linked bundle and targeting options',
inputSchema: mcpUpdateChannelInputSchema,
},
- async ({ appId, channelId, bundle, state, downgrade, ios, android, selfAssign, disableAutoUpdate, dev, emulator, device, prod, rolloutBundle, rolloutPercentage, rolloutPercentageBps, rolloutEnable, rolloutDisable, rolloutPause, rolloutResume, rolloutRollback, rolloutPromote, rolloutCacheTtlSeconds, autoPauseEnabled, autoPauseDisabled, autoPauseWindowMinutes, autoPauseFailureRateBps, autoPauseConfidence, autoPauseMinAttempts, autoPauseMinFailures, autoPauseAction, autoPauseCooldownMinutes }) => {
+ async ({ appId, channelId, bundle, state, downgrade, ios, android, selfAssign, disableAutoUpdate, dev, emulator, device, prod, rolloutBundle, rolloutPercentage, rolloutPercentageBps, rolloutEnable, rolloutDisable, rolloutPause, rolloutResume, rolloutRollback, rolloutPromote, rolloutCacheTtlSeconds, autoPauseEnabled, autoPauseDisabled, autoPauseWindowMinutes, autoPauseFailureRateBps, autoPauseConfidence, autoPauseMinAttempts, autoPauseMinFailures, autoPauseAction, autoPauseCooldownMinutes, acceptIncompatible }) => {
const payload = parseSchema(updateChannelOptionsSchema, {
appId,
channelId,
@@ -468,6 +471,7 @@ async function startMcpServerInternal(restoreConfigWriteTarget: () => void): Pro
autoPauseMinFailures,
autoPauseAction,
autoPauseCooldownMinutes,
+ acceptIncompatible,
})
const result = await sdk.updateChannel(payload)
if (!result.success) {
@@ -631,6 +635,35 @@ async function startMcpServerInternal(restoreConfigWriteTarget: () => void): Pro
},
)
+ server.registerTool(
+ 'capgo_observe',
+ {
+ description: 'Query Capgo Observe launch, crash, WebView, and navigation metrics. Start with view=summary and follow findings.next. Use view=device as the session timeline. Per-screen data needs metadata.route or action=app_nav from history/popstate/hashchange/appUrlOpen (no Expo Router).',
+ inputSchema: mcpObserveInputSchema,
+ },
+ async ({ appId, view, days, action, deviceId, versionName, sort, limit }) => {
+ const result = await sdk.observe({
+ appId,
+ view,
+ days,
+ action,
+ deviceId,
+ versionName,
+ sort,
+ limit,
+ })
+ if (!result.success) {
+ return formatMcpError(result)
+ }
+ return {
+ content: [{
+ type: 'text' as const,
+ text: JSON.stringify(result.data, null, 2),
+ }],
+ }
+ },
+ )
+
// ============================================================================
// Build Management Tools
// ============================================================================
@@ -641,12 +674,14 @@ async function startMcpServerInternal(restoreConfigWriteTarget: () => void): Pro
description: 'Request a native iOS/Android build from Capgo Cloud',
inputSchema: mcpRequestBuildInputSchema,
},
- async ({ appId, platform, path, nodeModules }) => {
+ async ({ appId, platform, path, nodeModules, cache, cacheKey }) => {
const result = await sdk.requestBuild({
appId,
platform,
path,
nodeModules,
+ cache,
+ cacheKey,
// Credentials should be pre-saved using the CLI
})
if (!result.success) {
diff --git a/cli/src/mcp/tool-schemas.ts b/cli/src/mcp/tool-schemas.ts
index 4111897ca0..1b41375e12 100644
--- a/cli/src/mcp/tool-schemas.ts
+++ b/cli/src/mcp/tool-schemas.ts
@@ -1,5 +1,6 @@
import { z } from 'zod'
-import { capacitorConfigOptionSchema } from '../schemas/sdk'
+import { buildCacheKeyOptionSchema, buildCacheOptionSchema } from '../schemas/build'
+import { capacitorConfigOptionSchema, observeOptionsObjectSchema, refineObserveDeviceId } from '../schemas/sdk'
export const mcpAddAppInputSchema = z.object({
appId: z.string(),
@@ -32,6 +33,7 @@ export const mcpUploadBundleInputSchema = z.object({
autoSetBundle: z.boolean().optional(),
autoBump: z.enum(['major', 'minor', 'patch', 'metadata', 'ai']).optional().describe('Semver part to bump from latest remote version (default minor when set via CLI without value); ai classifies via Capgo Workers AI'),
encrypt: z.boolean().optional(),
+ acceptIncompatible: z.boolean().optional().describe('Accept native-package incompatibility as handled (still checks and warns, continues, skips the crash-warning email)'),
capacitorConfig: capacitorConfigOptionSchema.optional(),
})
@@ -102,6 +104,7 @@ export const mcpUpdateChannelInputSchema = z.object({
autoPauseMinFailures: z.number().int().min(0).nullable().optional(),
autoPauseAction: z.enum(['pause', 'rollback', 'notify']).optional(),
autoPauseCooldownMinutes: z.number().int().min(0).max(10080).optional(),
+ acceptIncompatible: z.boolean().optional().describe('Accept native-package incompatibility as handled (still checks and warns, sets the channel instead of failing)'),
})
export const mcpDeleteChannelInputSchema = z.object({
@@ -132,11 +135,19 @@ export const mcpGetStatsInputSchema = z.object({
rangeEnd: z.string().optional(),
})
+export const mcpObserveInputSchema = observeOptionsObjectSchema.omit({
+ apikey: true,
+ supaHost: true,
+ supaAnon: true,
+}).superRefine(refineObserveDeviceId)
+
export const mcpRequestBuildInputSchema = z.object({
appId: z.string(),
platform: z.enum(['ios', 'android']),
path: z.string().optional(),
nodeModules: z.string().optional(),
+ cache: buildCacheOptionSchema.describe('When false, disables compilation cache for this build. Omit or true to use the default (cache enabled).'),
+ cacheKey: buildCacheKeyOptionSchema.describe('Custom compilation cache key (e.g. rc, prod) to share or isolate cache between environments.'),
})
export const mcpGenerateEncryptionKeysInputSchema = z.object({
diff --git a/cli/src/notify-app-ready-worker.ts b/cli/src/notify-app-ready-worker.ts
new file mode 100644
index 0000000000..1875b63843
--- /dev/null
+++ b/cli/src/notify-app-ready-worker.ts
@@ -0,0 +1,12 @@
+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/observe/api.ts b/cli/src/observe/api.ts
new file mode 100644
index 0000000000..2d48e552c4
--- /dev/null
+++ b/cli/src/observe/api.ts
@@ -0,0 +1,26 @@
+import type { ObserveOptions } from '../schemas/sdk'
+import { CliUserError } from '../shared/cli-user-error'
+import { findSavedKey, formatCapgoCliInvokeError, invokeCapgoCliApi } from '../utils'
+
+export async function fetchObserve(options: ObserveOptions): Promise> {
+ const apikey = options.apikey || findSavedKey(true)
+ const { data, error } = await invokeCapgoCliApi>('private/observe', {
+ apikey,
+ method: 'POST',
+ body: {
+ appId: options.appId,
+ view: options.view ?? 'summary',
+ days: options.days,
+ action: options.action,
+ deviceId: options.deviceId,
+ versionName: options.versionName,
+ sort: options.sort,
+ limit: options.limit,
+ },
+ supaHost: options.supaHost,
+ supaAnon: options.supaAnon,
+ })
+ if (error)
+ throw new CliUserError(await formatCapgoCliInvokeError(error))
+ return data ?? {}
+}
diff --git a/cli/src/observe/command.ts b/cli/src/observe/command.ts
new file mode 100644
index 0000000000..fef461832f
--- /dev/null
+++ b/cli/src/observe/command.ts
@@ -0,0 +1,214 @@
+import type { ObserveOptions, ObserveView } from '../schemas/sdk'
+import { stderr, stdout } from 'node:process'
+import { intro, log, outro } from '@clack/prompts'
+import { Table } from '@sauber/table'
+import { checkAlerts } from '../api/update'
+import { observeDaysSchema, observeOptionsObjectSchema } from '../schemas/sdk'
+import { CliUserError } from '../shared/cli-user-error'
+import { formatError, getAppId, getConfig } from '../utils'
+import { fetchObserve } from './api'
+
+export interface ObserveCliOptions {
+ apikey?: string
+ days?: string
+ action?: string
+ device?: string
+ versionName?: string
+ sort?: string
+ limit?: string
+ json?: boolean
+ supaHost?: string
+ supaAnon?: string
+}
+
+interface ObserveFinding {
+ severity?: string
+ title?: string
+ detail?: string
+ next?: { view?: string, action?: string, sort?: string, deviceId?: string }
+}
+
+function parsePositiveInt(value: string | undefined, label: string) {
+ if (value == null || value === '')
+ return undefined
+ const parsed = Number(value)
+ if (!Number.isInteger(parsed) || parsed <= 0)
+ throw new CliUserError(`${label} must be a positive integer`)
+ return parsed
+}
+
+function parseObserveDays(value: string | undefined): ObserveOptions['days'] {
+ const parsed = parsePositiveInt(value, '--days')
+ if (parsed == null)
+ return undefined
+ const result = observeDaysSchema.safeParse(parsed)
+ if (!result.success)
+ throw new CliUserError('--days must be 1, 3, 7, or 30')
+ return result.data
+}
+
+function parseObserveLimit(value: string | undefined) {
+ const parsed = parsePositiveInt(value, '--limit')
+ if (parsed == null)
+ return undefined
+ const result = observeOptionsObjectSchema.shape.limit.safeParse(parsed)
+ if (!result.success)
+ throw new CliUserError('--limit must be between 1 and 100')
+ return result.data
+}
+
+function parseObserveSort(value: string | undefined): ObserveOptions['sort'] | undefined {
+ if (!value)
+ return undefined
+ if (!['slowest', 'fastest', 'newest', 'oldest'].includes(value))
+ throw new CliUserError('--sort must be slowest, fastest, newest, or oldest')
+ return value as ObserveOptions['sort']
+}
+
+function writeJson(payload: unknown) {
+ stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
+}
+
+function printFindings(findings: ObserveFinding[] | undefined) {
+ if (!findings?.length)
+ return
+ const table = new Table()
+ table.headers = ['Severity', 'Finding', 'Next']
+ table.rows = findings.map((finding) => {
+ const next = finding.next
+ ? `${finding.next.view ?? ''}${finding.next.action ? ` ${finding.next.action}` : ''}${finding.next.sort ? ` ${finding.next.sort}` : ''}`
+ : ''
+ return [finding.severity ?? '', finding.title ?? '', next.trim()]
+ })
+ log.info('Findings')
+ log.info(table.toString())
+ for (const finding of findings) {
+ if (finding.detail)
+ log.message(`${finding.title}: ${finding.detail}`)
+ }
+}
+
+export async function observeCommand(
+ view: ObserveView,
+ appIdArg: string | undefined,
+ options: ObserveCliOptions,
+ deviceIdArg?: string,
+) {
+ if (!options.json)
+ intro('Capgo Observe')
+ try {
+ await checkAlerts(options.json ? { warn: message => stderr.write(`${message}\n`) } : undefined)
+ const extConfig = await getConfig()
+ const appId = getAppId(appIdArg, extConfig?.config)
+ if (!appId)
+ throw new CliUserError('Missing app ID. Pass it as an argument or set it in capacitor.config.')
+
+ const deviceId = deviceIdArg || options.device
+ if (view === 'device' && !deviceId)
+ throw new CliUserError('Missing device ID. Example: npx @capgo/cli@latest observe device DEVICE_ID')
+
+ const payload = await fetchObserve({
+ appId,
+ view,
+ days: parseObserveDays(options.days),
+ action: options.action,
+ deviceId,
+ versionName: options.versionName,
+ sort: parseObserveSort(options.sort),
+ limit: parseObserveLimit(options.limit),
+ apikey: options.apikey,
+ supaHost: options.supaHost,
+ supaAnon: options.supaAnon,
+ })
+
+ if (options.json) {
+ writeJson(payload)
+ return
+ }
+
+ const findings = payload.findings as ObserveFinding[] | undefined
+ printFindings(findings)
+
+ if (typeof payload.handoff_prompt === 'string')
+ log.info(payload.handoff_prompt)
+
+ if (view === 'metrics' || view === 'device') {
+ const rows = (payload.samples ?? payload.events) as Array> | undefined
+ if (rows?.length) {
+ const table = new Table()
+ table.headers = ['Time', 'Action', 'Device', 'Version', 'Duration', 'Route']
+ table.rows = rows.map(row => [
+ String(row.created_at ?? ''),
+ String(row.action ?? ''),
+ String(row.device_id ?? ''),
+ String(row.version_name ?? ''),
+ row.duration_ms == null ? '' : String(row.duration_ms),
+ String(row.route ?? ''),
+ ])
+ log.info(table.toString())
+ }
+ else {
+ log.warn('No samples in this window.')
+ }
+ }
+ else if (view === 'routes') {
+ const routes = payload.routes as Array> | undefined
+ if (routes?.length) {
+ const table = new Table()
+ table.headers = ['Route', 'Events', 'Devices', 'P50', 'P90']
+ table.rows = routes.map(row => [
+ String(row.route ?? ''),
+ String(row.events ?? ''),
+ String(row.devices ?? ''),
+ row.p50_ms == null ? '' : String(row.p50_ms),
+ row.p90_ms == null ? '' : String(row.p90_ms),
+ ])
+ log.info(table.toString())
+ }
+ else {
+ log.warn('No route metadata yet. Listen to history/popstate/hashchange and send action=app_nav with metadata.route.')
+ }
+ }
+ else if (view === 'events') {
+ const actions = payload.actions as Array> | undefined
+ if (actions?.length) {
+ const table = new Table()
+ table.headers = ['Action', 'Events', 'Devices', 'Last seen']
+ table.rows = actions.map(row => [
+ String(row.action ?? ''),
+ String(row.total ?? ''),
+ String(row.device_count ?? ''),
+ String(row.last_seen ?? ''),
+ ])
+ log.info(table.toString())
+ }
+ else {
+ log.warn('No observe events in this window.')
+ }
+ }
+ else if (view === 'versions' || view === 'summary') {
+ const versions = payload.versions as Array> | undefined
+ if (versions?.length) {
+ const table = new Table()
+ table.headers = ['Version', 'Devices', 'Issue-free', 'Launch P90']
+ table.rows = versions.map(row => [
+ String(row.version_name ?? ''),
+ String(row.devices ?? ''),
+ row.issue_free_rate == null ? '' : String(row.issue_free_rate),
+ row.launch_p90_ms == null ? '' : String(row.launch_p90_ms),
+ ])
+ log.info(table.toString())
+ }
+ }
+
+ outro('Done')
+ }
+ catch (error) {
+ const message = formatError(error)
+ if (options.json)
+ stderr.write(`${message}\n`)
+ else
+ log.error(message)
+ throw error
+ }
+}
diff --git a/cli/src/onboarding-worker.ts b/cli/src/onboarding-worker.ts
new file mode 100644
index 0000000000..44c86fb51a
--- /dev/null
+++ b/cli/src/onboarding-worker.ts
@@ -0,0 +1,36 @@
+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
new file mode 100644
index 0000000000..07584989ab
--- /dev/null
+++ b/cli/src/onboarding/background-api.ts
@@ -0,0 +1,39 @@
+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
new file mode 100644
index 0000000000..2b6bc09d0c
--- /dev/null
+++ b/cli/src/onboarding/background-check.ts
@@ -0,0 +1,77 @@
+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
new file mode 100644
index 0000000000..88eeb7fdad
--- /dev/null
+++ b/cli/src/onboarding/background-preparation.ts
@@ -0,0 +1,38 @@
+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'
+
+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
+ 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
new file mode 100644
index 0000000000..f6cd4701d0
--- /dev/null
+++ b/cli/src/onboarding/background-shutdown.ts
@@ -0,0 +1,73 @@
+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
new file mode 100644
index 0000000000..0397ecab7a
--- /dev/null
+++ b/cli/src/onboarding/background-workers.ts
@@ -0,0 +1,23 @@
+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
new file mode 100644
index 0000000000..a3ca10aa05
--- /dev/null
+++ b/cli/src/onboarding/background.ts
@@ -0,0 +1,60 @@
+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
new file mode 100644
index 0000000000..969c49f672
--- /dev/null
+++ b/cli/src/onboarding/notify-app-ready-project.ts
@@ -0,0 +1,138 @@
+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
new file mode 100644
index 0000000000..748ebfc511
--- /dev/null
+++ b/cli/src/onboarding/notify-app-ready-source.ts
@@ -0,0 +1,189 @@
+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
new file mode 100644
index 0000000000..44148e4e1f
--- /dev/null
+++ b/cli/src/onboarding/updater-installed.ts
@@ -0,0 +1,8 @@
+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/schemas/build.ts b/cli/src/schemas/build.ts
index 95af47a374..2885a3cb24 100644
--- a/cli/src/schemas/build.ts
+++ b/cli/src/schemas/build.ts
@@ -53,6 +53,17 @@ export type BuildCredentials = z.infer
// Build Request Options Schema
// ============================================================================
+export const buildCacheOptionSchema = z.boolean().optional()
+
+export const buildCacheKeyOptionSchema = z.preprocess((value) => {
+ if (value === undefined)
+ return undefined
+ if (typeof value !== 'string')
+ return value
+ const trimmed = value.trim()
+ return trimmed.length > 0 ? trimmed : undefined
+}, z.string().min(1).optional())
+
export const buildRequestOptionsSchema = optionsBaseSchema.extend({
path: z.string().optional(),
nodeModules: z.string().optional(),
@@ -108,6 +119,8 @@ export const buildRequestOptionsSchema = optionsBaseSchema.extend({
prescanWarn: z.array(z.string()).optional(),
failOnWarnings: z.boolean().optional(),
builderJourneyId: z.string().optional(),
+ cache: buildCacheOptionSchema,
+ cacheKey: buildCacheKeyOptionSchema,
})
export type BuildRequestOptions = z.infer
diff --git a/cli/src/schemas/bundle.ts b/cli/src/schemas/bundle.ts
index 200db6436b..237f6bc17a 100644
--- a/cli/src/schemas/bundle.ts
+++ b/cli/src/schemas/bundle.ts
@@ -36,6 +36,7 @@ export const optionsUploadSchema = optionsBaseSchema.extend({
autoBump: z.enum(['major', 'minor', 'patch', 'metadata', 'ai']).optional(),
ignoreMetadataCheck: z.boolean().optional(),
failOnIncompatible: z.boolean().optional(),
+ acceptIncompatible: z.boolean().optional(),
ignoreChecksumCheck: z.boolean().optional(),
forceCrc32Checksum: z.boolean().optional(),
timeout: z.number().optional(),
diff --git a/cli/src/schemas/channel.ts b/cli/src/schemas/channel.ts
index df9dadb2ce..d10a702129 100644
--- a/cli/src/schemas/channel.ts
+++ b/cli/src/schemas/channel.ts
@@ -68,6 +68,7 @@ export const optionsSetChannelSchema = optionsBaseSchema.extend({
prod: z.boolean().optional(),
packageJson: z.string().optional(),
ignoreMetadataCheck: z.boolean().optional(),
+ acceptIncompatible: z.boolean().optional(),
qrPreview: z.boolean().optional(),
sendUpdateNotification: z.boolean().optional(),
rolloutBundle: z.string().optional(),
@@ -93,6 +94,7 @@ export const optionsSetChannelSchema = optionsBaseSchema.extend({
rejectConflictingBooleanGroup(value, ctx, ['rolloutEnable', 'rolloutDisable'])
rejectConflictingBooleanGroup(value, ctx, ['rolloutPause', 'rolloutResume', 'rolloutRollback', 'rolloutPromote'])
rejectConflictingBooleanGroup(value, ctx, ['autoPauseEnabled', 'autoPauseDisabled'])
+ rejectConflictingBooleanGroup(value, ctx, ['acceptIncompatible', 'ignoreMetadataCheck'])
})
export type OptionsSetChannel = z.infer
diff --git a/cli/src/schemas/index.ts b/cli/src/schemas/index.ts
index 440e73ab67..e610b4f709 100644
--- a/cli/src/schemas/index.ts
+++ b/cli/src/schemas/index.ts
@@ -92,6 +92,11 @@ export {
getStatsOptionsSchema,
listOrganizationsOptionsSchema,
loginOptionsSchema,
+ observeOptionsObjectSchema,
+ observeOptionsSchema,
+ observeDaysSchema,
+ observeSortSchema,
+ observeViewSchema,
organizationInfoSchema,
saveKeyOptionsSchema,
bundleCompatibilityOptionsSchema as sdkBundleCompatibilityOptionsSchema,
@@ -124,6 +129,8 @@ export type {
GetStatsOptions,
ListOrganizationsOptions,
LoginOptions,
+ ObserveOptions,
+ ObserveView,
OrganizationInfo,
SaveKeyOptions,
BundleCompatibilityOptions as SdkBundleCompatibilityOptions,
diff --git a/cli/src/schemas/sdk.ts b/cli/src/schemas/sdk.ts
index 50c4fae520..642fec4ba4 100644
--- a/cli/src/schemas/sdk.ts
+++ b/cli/src/schemas/sdk.ts
@@ -1,5 +1,5 @@
import { z } from 'zod'
-import { buildCredentialsSchema } from './build'
+import { buildCacheKeyOptionSchema, buildCacheOptionSchema, buildCredentialsSchema } from './build'
import { localizedReleaseNotesSchema, rejectConflictingBooleanGroup } from './common'
export const capacitorConfigOptionSchema = z.string().min(1).describe('Capacitor config source to update')
@@ -101,9 +101,12 @@ export const uploadOptionsSchema = z.object({
selfAssign: z.boolean().optional(),
packageJsonPaths: z.string().optional(),
ignoreCompatibilityCheck: z.boolean().optional(),
+ acceptIncompatible: z.boolean().optional(),
disableCodeCheck: z.boolean().optional(),
useZip: z.boolean().optional(),
capacitorConfig: capacitorConfigOptionSchema.optional(),
+}).superRefine((value, ctx) => {
+ rejectConflictingBooleanGroup(value, ctx, ['acceptIncompatible', 'ignoreCompatibilityCheck'])
})
export type UploadOptions = z.infer
@@ -228,6 +231,7 @@ export const updateChannelOptionsBaseSchema = z.object({
autoPauseMinFailures: z.number().int().min(0).nullable().optional(),
autoPauseAction: z.enum(['pause', 'rollback', 'notify']).optional(),
autoPauseCooldownMinutes: z.number().int().min(0).max(10080).optional(),
+ acceptIncompatible: z.boolean().optional(),
apikey: z.string().optional(),
supaHost: z.string().optional(),
supaAnon: z.string().optional(),
@@ -390,6 +394,8 @@ export const requestBuildOptionsSchema = z.object({
prescanIgnoreFatal: z.boolean().optional(),
prescanSkip: z.array(z.string()).optional(),
prescanWarn: z.array(z.string()).optional(),
+ cache: buildCacheOptionSchema,
+ cacheKey: buildCacheKeyOptionSchema,
})
export type RequestBuildOptions = z.infer
@@ -449,6 +455,42 @@ export const deviceStatsSchema = z.object({
export type DeviceStats = z.infer
+export const observeViewSchema = z.enum(['summary', 'metrics', 'events', 'device', 'versions', 'routes'])
+export const observeSortSchema = z.enum(['slowest', 'fastest', 'newest', 'oldest'])
+export const observeDaysSchema = z.union([z.literal(1), z.literal(3), z.literal(7), z.literal(30)])
+
+export const observeOptionsObjectSchema = z.object({
+ appId: z.string().describe('App ID'),
+ view: observeViewSchema.optional().describe('Start with summary, then follow findings.next. device is the session timeline.'),
+ days: observeDaysSchema.optional().describe('Lookback window: 1, 3, 7, or 30 (default 7)'),
+ action: z.string().optional().describe('Filter by stats action, e.g. app_launch_ready or app_nav'),
+ deviceId: z.string().optional().describe('Required for view=device. Device timeline substitutes for a session id.'),
+ versionName: z.string().optional().describe('Filter by bundle version name'),
+ sort: observeSortSchema.optional().describe('Sort samples: slowest, fastest, newest, oldest'),
+ limit: z.number().int().min(1).max(100).optional().describe('Max rows to return (default 20, max 100)'),
+ apikey: z.string().optional(),
+ supaHost: z.string().optional(),
+ supaAnon: z.string().optional(),
+})
+
+export function refineObserveDeviceId(
+ value: { view?: z.infer, deviceId?: string },
+ ctx: z.RefinementCtx,
+) {
+ if (value.view === 'device' && !value.deviceId?.trim()) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['deviceId'],
+ message: 'deviceId is required for view=device',
+ })
+ }
+}
+
+export const observeOptionsSchema = observeOptionsObjectSchema.superRefine(refineObserveDeviceId)
+
+export type ObserveOptions = z.infer
+export type ObserveView = z.infer
+
// ============================================================================
// SDK Probe Schemas
// ============================================================================
diff --git a/cli/src/sdk.ts b/cli/src/sdk.ts
index f41d92f59b..6b92e6189e 100644
--- a/cli/src/sdk.ts
+++ b/cli/src/sdk.ts
@@ -28,6 +28,7 @@ import type {
GetStatsOptions,
ListOrganizationsOptions,
LoginOptions,
+ ObserveOptions,
OrganizationInfo,
ProbeOptions,
RequestBuildOptions,
@@ -70,10 +71,12 @@ import { resolveCapacitorConfigTargetPath, withConfigWriteTarget } from './confi
import { starAllRepositories as starAllRepositoriesInternal, starRepository } from './github'
import { createKeyInternal, deleteOldPrivateKeyInternal, saveKeyInternal } from './key'
import { loginInternal } from './login'
+import { fetchObserve } from './observe/api'
import { addOrganizationInternal } from './organization/add'
import { deleteOrganizationInternal } from './organization/delete'
import { listOrganizationsInternal } from './organization/list'
import { setOrganizationInternal } from './organization/set'
+import { requestBuildOptionsSchema, updateChannelOptionsSchema, uploadOptionsSchema } from './schemas/sdk'
import { CliUserError } from './shared/cli-user-error'
import { getUserIdInternal } from './user/account'
import { createSupabaseClient, findSavedKey, getConfig, getLocalConfig } from './utils'
@@ -540,37 +543,39 @@ export class CapgoSDK {
*/
async uploadBundle(options: UploadOptions): Promise {
try {
- return await withCapacitorConfigTarget(options.capacitorConfig, async () => {
+ const parsed = uploadOptionsSchema.parse(options)
+ return await withCapacitorConfigTarget(parsed.capacitorConfig, async () => {
// Convert SDK options to internal format
const internalOptions: OptionsUpload = {
- apikey: options.apikey || this.apikey || findSavedKey(true),
- supaHost: options.supaHost || this.supaHost,
- supaAnon: options.supaAnon || this.supaAnon,
- path: options.path,
- bundle: options.bundle,
- channel: options.channel,
- rollout: options.rollout,
- rolloutPercentageBps: options.rolloutPercentageBps,
- rolloutCacheTtlSeconds: options.rolloutCacheTtlSeconds,
- external: options.external,
- key: options.encrypt !== false, // default true unless explicitly false
- keyV2: options.encryptionKey,
- timeout: options.timeout,
- tus: options.useTus,
- comment: options.comment,
- minUpdateVersion: options.minUpdateVersion,
- autoMinUpdateVersion: options.autoMinUpdateVersion,
- autoSetBundle: options.autoSetBundle,
- autoBump: normalizeAutoBumpInput(options.autoBump),
- selfAssign: options.selfAssign,
- packageJson: options.packageJsonPaths,
- ignoreMetadataCheck: options.ignoreCompatibilityCheck,
- codeCheck: !options.disableCodeCheck, // disable if requested, otherwise check
- zip: options.useZip, // use legacy zip upload if requested
+ apikey: parsed.apikey || this.apikey || findSavedKey(true),
+ supaHost: parsed.supaHost || this.supaHost,
+ supaAnon: parsed.supaAnon || this.supaAnon,
+ path: parsed.path,
+ bundle: parsed.bundle,
+ channel: parsed.channel,
+ rollout: parsed.rollout,
+ rolloutPercentageBps: parsed.rolloutPercentageBps,
+ rolloutCacheTtlSeconds: parsed.rolloutCacheTtlSeconds,
+ external: parsed.external,
+ key: parsed.encrypt !== false, // default true unless explicitly false
+ keyV2: parsed.encryptionKey,
+ timeout: parsed.timeout,
+ tus: parsed.useTus,
+ comment: parsed.comment,
+ minUpdateVersion: parsed.minUpdateVersion,
+ autoMinUpdateVersion: parsed.autoMinUpdateVersion,
+ autoSetBundle: parsed.autoSetBundle,
+ autoBump: normalizeAutoBumpInput(parsed.autoBump),
+ selfAssign: parsed.selfAssign,
+ packageJson: parsed.packageJsonPaths,
+ ignoreMetadataCheck: parsed.ignoreCompatibilityCheck,
+ acceptIncompatible: parsed.acceptIncompatible,
+ codeCheck: !parsed.disableCodeCheck, // disable if requested, otherwise check
+ zip: parsed.useZip, // use legacy zip upload if requested
}
// Call internal upload function but suppress CLI behaviors
- const uploadResponse = await uploadBundleInternal(options.appId, internalOptions, true)
+ const uploadResponse = await uploadBundleInternal(parsed.appId, internalOptions, true)
return {
success: uploadResponse.success,
@@ -729,16 +734,18 @@ export class CapgoSDK {
*/
async requestBuild(options: RequestBuildOptions): Promise> {
try {
+ const parsed = requestBuildOptionsSchema.parse(options)
+
// Convert BuildCredentials object to flattened CLI-compatible format
- const creds = options.credentials
+ const creds = parsed.credentials
const internalOptions: InternalBuildRequestOptions = {
- apikey: options.apikey || this.apikey || findSavedKey(true),
- supaHost: options.supaHost || this.supaHost,
- supaAnon: options.supaAnon || this.supaAnon,
- path: options.path,
- nodeModules: options.nodeModules,
- platform: options.platform,
- userId: options.userId,
+ apikey: parsed.apikey || this.apikey || findSavedKey(true),
+ supaHost: parsed.supaHost || this.supaHost,
+ supaAnon: parsed.supaAnon || this.supaAnon,
+ path: parsed.path,
+ nodeModules: parsed.nodeModules,
+ platform: parsed.platform,
+ userId: parsed.userId,
// Flatten BuildCredentials to individual fields
buildCertificateBase64: creds?.BUILD_CERTIFICATE_BASE64,
p12Password: creds?.P12_PASSWORD,
@@ -758,23 +765,25 @@ export class CapgoSDK {
keystoreKeyPassword: creds?.KEYSTORE_KEY_PASSWORD,
keystoreStorePassword: creds?.KEYSTORE_STORE_PASSWORD,
playConfigJson: creds?.PLAY_CONFIG_JSON,
- androidTrack: options.androidTrack ?? (creds?.PLAY_STORE_TRACK as 'internal' | 'alpha' | 'beta' | 'production' | undefined),
- androidReleaseStatus: options.androidReleaseStatus ?? (creds?.PLAY_STORE_RELEASE_STATUS as 'draft' | 'completed' | 'inProgress' | 'halted' | undefined),
- submitToStoreReview: options.submitToStoreReview ?? (creds?.CAPGO_STORE_SUBMIT_REVIEW === undefined ? undefined : creds.CAPGO_STORE_SUBMIT_REVIEW === 'true'),
- storeReleaseName: options.storeReleaseName ?? creds?.CAPGO_STORE_RELEASE_NAME,
- storeReleaseNotes: options.storeReleaseNotes ?? creds?.CAPGO_STORE_RELEASE_NOTES,
- storeReleaseNotesLocalized: options.storeReleaseNotesLocalized ?? parseStoreReleaseNotesLocalizedJson(creds?.CAPGO_STORE_RELEASE_NOTES_LOCALIZED),
- iosTestflightGroups: options.iosTestflightGroups ?? creds?.CAPGO_IOS_TESTFLIGHT_GROUPS,
- iosAutomaticRelease: options.iosAutomaticRelease ?? (creds?.CAPGO_IOS_AUTOMATIC_RELEASE === undefined ? undefined : creds.CAPGO_IOS_AUTOMATIC_RELEASE === 'true'),
+ androidTrack: parsed.androidTrack ?? (creds?.PLAY_STORE_TRACK as 'internal' | 'alpha' | 'beta' | 'production' | undefined),
+ androidReleaseStatus: parsed.androidReleaseStatus ?? (creds?.PLAY_STORE_RELEASE_STATUS as 'draft' | 'completed' | 'inProgress' | 'halted' | undefined),
+ submitToStoreReview: parsed.submitToStoreReview ?? (creds?.CAPGO_STORE_SUBMIT_REVIEW === undefined ? undefined : creds.CAPGO_STORE_SUBMIT_REVIEW === 'true'),
+ storeReleaseName: parsed.storeReleaseName ?? creds?.CAPGO_STORE_RELEASE_NAME,
+ storeReleaseNotes: parsed.storeReleaseNotes ?? creds?.CAPGO_STORE_RELEASE_NOTES,
+ storeReleaseNotesLocalized: parsed.storeReleaseNotesLocalized ?? parseStoreReleaseNotesLocalizedJson(creds?.CAPGO_STORE_RELEASE_NOTES_LOCALIZED),
+ iosTestflightGroups: parsed.iosTestflightGroups ?? creds?.CAPGO_IOS_TESTFLIGHT_GROUPS,
+ iosAutomaticRelease: parsed.iosAutomaticRelease ?? (creds?.CAPGO_IOS_AUTOMATIC_RELEASE === undefined ? undefined : creds.CAPGO_IOS_AUTOMATIC_RELEASE === 'true'),
// Prescan escape hatch: SDK callers own their output channel and cannot
// pass CLI flags, so expose the gate controls directly.
- prescan: options.prescan,
- prescanIgnoreFatal: options.prescanIgnoreFatal,
- prescanSkip: options.prescanSkip,
- prescanWarn: options.prescanWarn,
+ prescan: parsed.prescan,
+ prescanIgnoreFatal: parsed.prescanIgnoreFatal,
+ prescanSkip: parsed.prescanSkip,
+ prescanWarn: parsed.prescanWarn,
+ cache: parsed.cache,
+ cacheKey: parsed.cacheKey,
}
- const result = await requestBuildInternal(options.appId, internalOptions, true)
+ const result = await requestBuildInternal(parsed.appId, internalOptions, true)
if (result.success && result.jobId) {
return {
@@ -867,48 +876,50 @@ export class CapgoSDK {
*/
async updateChannel(options: UpdateChannelOptions): Promise {
try {
+ const parsed = updateChannelOptionsSchema.parse(options)
const internalOptions: OptionsSetChannel = {
- apikey: options.apikey || this.apikey || findSavedKey(true),
- supaHost: options.supaHost || this.supaHost,
- supaAnon: options.supaAnon || this.supaAnon,
- bundle: options.bundle ?? undefined,
- state: options.state,
- downgrade: options.downgrade,
- ios: options.ios,
- android: options.android,
- selfAssign: options.selfAssign,
- disableAutoUpdate: options.disableAutoUpdate ?? undefined,
- updatePackage: options.updatePackage,
- dev: options.dev,
- emulator: options.emulator,
- device: options.device,
- prod: options.prod,
- rolloutBundle: options.rolloutBundle,
- rolloutPercentage: options.rolloutPercentage,
- rolloutPercentageBps: options.rolloutPercentageBps,
- rolloutEnable: options.rolloutEnable,
- rolloutDisable: options.rolloutDisable,
- rolloutPause: options.rolloutPause,
- rolloutResume: options.rolloutResume,
- rolloutRollback: options.rolloutRollback,
- rolloutPromote: options.rolloutPromote,
- rolloutCacheTtlSeconds: options.rolloutCacheTtlSeconds,
- autoPauseEnabled: options.autoPauseEnabled,
- autoPauseDisabled: options.autoPauseDisabled,
- autoPauseWindowMinutes: options.autoPauseWindowMinutes,
- autoPauseFailureRateBps: options.autoPauseFailureRateBps,
- autoPauseConfidence: options.autoPauseConfidence,
- autoPauseMinAttempts: options.autoPauseMinAttempts,
- autoPauseMinFailures: options.autoPauseMinFailures,
- autoPauseAction: options.autoPauseAction,
- autoPauseCooldownMinutes: options.autoPauseCooldownMinutes,
+ apikey: parsed.apikey || this.apikey || findSavedKey(true),
+ supaHost: parsed.supaHost || this.supaHost,
+ supaAnon: parsed.supaAnon || this.supaAnon,
+ bundle: parsed.bundle ?? undefined,
+ state: parsed.state,
+ downgrade: parsed.downgrade,
+ ios: parsed.ios,
+ android: parsed.android,
+ selfAssign: parsed.selfAssign,
+ disableAutoUpdate: parsed.disableAutoUpdate ?? undefined,
+ updatePackage: parsed.updatePackage,
+ dev: parsed.dev,
+ emulator: parsed.emulator,
+ device: parsed.device,
+ prod: parsed.prod,
+ rolloutBundle: parsed.rolloutBundle,
+ rolloutPercentage: parsed.rolloutPercentage,
+ rolloutPercentageBps: parsed.rolloutPercentageBps,
+ rolloutEnable: parsed.rolloutEnable,
+ rolloutDisable: parsed.rolloutDisable,
+ rolloutPause: parsed.rolloutPause,
+ rolloutResume: parsed.rolloutResume,
+ rolloutRollback: parsed.rolloutRollback,
+ rolloutPromote: parsed.rolloutPromote,
+ rolloutCacheTtlSeconds: parsed.rolloutCacheTtlSeconds,
+ autoPauseEnabled: parsed.autoPauseEnabled,
+ autoPauseDisabled: parsed.autoPauseDisabled,
+ autoPauseWindowMinutes: parsed.autoPauseWindowMinutes,
+ autoPauseFailureRateBps: parsed.autoPauseFailureRateBps,
+ autoPauseConfidence: parsed.autoPauseConfidence,
+ autoPauseMinAttempts: parsed.autoPauseMinAttempts,
+ autoPauseMinFailures: parsed.autoPauseMinFailures,
+ autoPauseAction: parsed.autoPauseAction,
+ autoPauseCooldownMinutes: parsed.autoPauseCooldownMinutes,
latest: false,
latestRemote: false,
packageJson: undefined,
ignoreMetadataCheck: false,
+ acceptIncompatible: parsed.acceptIncompatible,
}
- await setChannelInternal(options.channelId, options.appId, internalOptions, true)
+ await setChannelInternal(parsed.channelId, parsed.appId, internalOptions, true)
return { success: true }
}
@@ -1251,6 +1262,25 @@ export class CapgoSDK {
}
}
+ /**
+ * Query Capgo Observe (launch, issues, routes, device timelines).
+ * Start with view=summary; follow findings.next. Use --json / MCP for agents.
+ */
+ async observe(options: ObserveOptions): Promise>> {
+ try {
+ const data = await fetchObserve({
+ ...options,
+ apikey: options.apikey || this.apikey,
+ supaHost: options.supaHost || this.supaHost,
+ supaAnon: options.supaAnon || this.supaAnon,
+ })
+ return { success: true, data }
+ }
+ catch (error) {
+ return createErrorResult(error)
+ }
+ }
+
// ==========================================================================
// Miscellaneous Helpers
// ==========================================================================
@@ -1577,6 +1607,19 @@ export async function getStats(options: GetStatsOptions): Promise>> {
+ const sdk = new CapgoSDK({
+ apikey: options.apikey,
+ supaHost: options.supaHost,
+ supaAnon: options.supaAnon,
+ })
+ return sdk.observe(options)
+}
+
export async function probeUpdates(options: ProbeOptions): Promise> {
const sdk = new CapgoSDK()
return sdk.probe(options)
@@ -1633,6 +1676,7 @@ export type {
GetStatsOptions,
ListOrganizationsOptions,
LoginOptions,
+ ObserveOptions,
OrganizationInfo,
ProbeOptions,
RequestBuildOptions,
diff --git a/cli/src/types/supabase.types.ts b/cli/src/types/supabase.types.ts
index cd51a02a25..b958946e65 100644
--- a/cli/src/types/supabase.types.ts
+++ b/cli/src/types/supabase.types.ts
@@ -4555,6 +4555,21 @@ export type Database = {
version_build: string
}[]
}
+ read_native_active_devices_summary: {
+ Args: { p_app_id: string; p_period_end: string; p_period_start: string }
+ Returns: {
+ devices: number
+ platform: string
+ }[]
+ }
+ read_native_daily_platform_active: {
+ Args: { p_app_id: string; p_period_end: string; p_period_start: string }
+ Returns: {
+ date: string
+ devices: number
+ platform: string
+ }[]
+ }
read_storage_usage: {
Args: { p_app_id: string; p_period_end: string; p_period_start: string }
Returns: {
@@ -4652,6 +4667,7 @@ export type Database = {
Returns: boolean
}
remove_old_jobs: { Args: never; Returns: undefined }
+ request_actor_email_adress: { Args: never; Returns: string | null }
request_actor_user_id: { Args: never; Returns: string }
request_app_chart_refresh: {
Args: { app_id: string }
@@ -4899,6 +4915,7 @@ export type Database = {
| "webview_content_process_terminated"
| "os_version_changed"
| "native_app_version_changed"
+ | "app_nav"
stripe_status:
| "created"
| "succeeded"
@@ -5168,6 +5185,7 @@ export const Constants = {
"webview_content_process_terminated",
"os_version_changed",
"native_app_version_changed",
+ "app_nav",
],
stripe_status: [
"created",
diff --git a/cli/src/updater-installed-worker.ts b/cli/src/updater-installed-worker.ts
new file mode 100644
index 0000000000..c46cadd653
--- /dev/null
+++ b/cli/src/updater-installed-worker.ts
@@ -0,0 +1,12 @@
+import { exit } from 'node:process'
+import { workerData } from 'node:worker_threads'
+import { runOnboardingCheck, type PreparedOnboardingCheck } from './onboarding/background-check'
+import { scanUpdaterInstalled } from './onboarding/updater-installed'
+
+void runOnboardingCheck(workerData as PreparedOnboardingCheck, {
+ channel: 'updater-installed',
+ step: 'add_updater',
+ scan: scanUpdaterInstalled,
+}).catch(() => {
+ // 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 58362732cc..9066f6d705 100644
--- a/cli/src/user/account.ts
+++ b/cli/src/user/account.ts
@@ -39,7 +39,3 @@ 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
new file mode 100644
index 0000000000..311188a09d
--- /dev/null
+++ b/cli/src/user/whoami.ts
@@ -0,0 +1,46 @@
+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 resolveAccountIdentity(supabase: SupabaseClient, apikey: string) {
+ const [userId, emailResult] = await Promise.all([
+ resolveUserIdFromApiKey(supabase, apikey, true),
+ 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 { userId, email: emailResult.data }
+}
+
+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 4d8124c426..03e6d67f86 100644
--- a/cli/src/utils.ts
+++ b/cli/src/utils.ts
@@ -1992,7 +1992,7 @@ type SendEventPayload = TrackOptions & { nonPersonTags?: Record
| { notifyConsole?: false, icon?: never }
)
-export async function sendEvent(capgkey: string, payload: SendEventPayload, verbose?: boolean, signal?: AbortSignal): Promise {
+export async function sendEvent(capgkey: string, payload: SendEventPayload, verbose?: boolean, signal?: AbortSignal, apiHost?: string, redirect?: RequestInit['redirect']): Promise {
const telemetryDisabled = isTruthyEnvValue(env.CAPGO_DISABLE_TELEMETRY) || isTruthyEnvValue(env.CAPGO_DISABLE_POSTHOG)
if (telemetryDisabled && !payload.notifyConsole)
return
@@ -2019,9 +2019,9 @@ export async function sendEvent(capgkey: string, payload: SendEventPayload, verb
if (verbose) {
log.info(`Get remove config: for ${payload.event}`)
}
- // 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)
+ // 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
if (verbose) {
log.info(`Sending analytics event: ${JSON.stringify(enrichedPayload)}`)
}
@@ -2034,7 +2034,7 @@ export async function sendEvent(capgkey: string, payload: SendEventPayload, verb
: controller.signal
try {
- const fetchResponse = await fetch(`${config.hostApi}/private/events`, {
+ const fetchResponse = await fetch(`${trimTrailingSlashes(hostApi)}/private/events`, {
method: 'POST',
body: JSON.stringify(enrichedPayload),
headers: buildCliRequestHeaders({
@@ -2042,6 +2042,7 @@ export async function sendEvent(capgkey: string, payload: SendEventPayload, verb
'capgkey': capgkey,
}),
signal: eventSignal,
+ redirect,
})
clearTimeout(timeoutId)
diff --git a/cli/test/prescan/checks-ios-profiles.test.ts b/cli/test/prescan/checks-ios-profiles.test.ts
index 18ec56be61..9855cc5f2d 100644
--- a/cli/test/prescan/checks-ios-profiles.test.ts
+++ b/cli/test/prescan/checks-ios-profiles.test.ts
@@ -8,6 +8,7 @@ import {
profileExpiry,
profileTypeVsMode,
targetsCovered,
+ wildcardProfileTargets,
} from '../../src/build/prescan/checks/ios-profiles'
import { makeCtx, makeP12, makeProfileXml, makeProfileXmlWithCert, makeProject } from './helpers'
@@ -167,6 +168,7 @@ describe('ios/targets-covered', () => {
expect(f[0]?.severity).toBe('error')
expect(f[0]?.title).toContain('1 signable target')
expect(f[0]?.detail).toContain('Widget')
+ expect(f[0]?.fix).toContain('npx @capgo/cli@latest build credentials ios-provisioning')
})
it('passes when every signable target bundle id is covered', async () => {
const dir = makeProject({ 'ios/App/App.xcodeproj/project.pbxproj': TWO_TARGET_PBXPROJ })
@@ -177,4 +179,71 @@ describe('ios/targets-covered', () => {
const ctx = makeCtx({ projectDir: dir, platform: 'ios', credentials: { CAPGO_IOS_PROVISIONING_MAP: map } })
expect(await targetsCovered.run(ctx)).toEqual([])
})
+
+ it('reports present empty, malformed, and invalid maps without suggesting the command', async () => {
+ const dir = makeProject({ 'ios/App/App.xcodeproj/project.pbxproj': TWO_TARGET_PBXPROJ })
+ for (const raw of ['{}', 'not json', JSON.stringify({ bad: { profile: 'not-a-profile' } })]) {
+ const ctx = makeCtx({ projectDir: dir, platform: 'ios', credentials: { CAPGO_IOS_PROVISIONING_MAP: raw } })
+ const findings = await targetsCovered.run(ctx)
+ expect(findings[0]?.severity).toBe('error')
+ expect(findings[0]?.fix).toContain('Save or update')
+ expect(findings[0]?.fix).not.toContain('ios-provisioning')
+ }
+ })
+
+ it('keeps generic repair guidance for a single-target project', async () => {
+ const singleTarget = TWO_TARGET_PBXPROJ
+ .replace(/ AA11BB22CC33DD44[\s\S]*? };\n 13B07F931A680F5B00A75B9A/, ' 13B07F931A680F5B00A75B9A')
+ .replace(/ AA11BB22CC33DD55[\s\S]*? };\n };/, ' };')
+ const dir = makeProject({ 'ios/App/App.xcodeproj/project.pbxproj': singleTarget })
+ const map = JSON.stringify({ other: { profile: b64(makeProfileXml({ bundleId: 'com.other.app' })), name: 'Other' } })
+ const findings = await targetsCovered.run(makeCtx({ projectDir: dir, platform: 'ios', credentials: { CAPGO_IOS_PROVISIONING_MAP: map } }))
+ expect(findings[0]?.fix).toContain('--ios-provisioning-profile')
+ expect(findings[0]?.fix).not.toContain('build credentials ios-provisioning')
+ })
+})
+
+describe('ios/wildcard-profile-targets', () => {
+ it('owns matching wildcard targets and recommends the repair command', async () => {
+ const dir = makeProject({ 'ios/App/App.xcodeproj/project.pbxproj': TWO_TARGET_PBXPROJ })
+ const wildcard = b64(makeProfileXml({ bundleId: 'com.demo.*' }))
+ const map = JSON.stringify({
+ 'com.demo.app': { profile: b64(makeProfileXml()), name: 'App' },
+ wildcard: { profile: wildcard, name: 'Wildcard' },
+ })
+ const ctx = makeCtx({ projectDir: dir, platform: 'ios', credentials: { CAPGO_IOS_PROVISIONING_MAP: map } })
+
+ const findings = await wildcardProfileTargets.run(ctx)
+ expect(findings[0]?.severity).toBe('error')
+ expect(findings[0]?.detail).toContain('Widget')
+ expect(findings[0]?.fix).toContain('npx @capgo/cli@latest build credentials ios-provisioning')
+ expect(await targetsCovered.run(ctx)).toEqual([])
+ })
+
+ it('fails unsupported when different wildcard profiles match', async () => {
+ const dir = makeProject({ 'ios/App/App.xcodeproj/project.pbxproj': TWO_TARGET_PBXPROJ })
+ const map = JSON.stringify({
+ 'com.demo.app': { profile: b64(makeProfileXml()), name: 'App' },
+ broad: { profile: b64(makeProfileXml({ bundleId: '*' })), name: 'Broad' },
+ prefix: { profile: b64(makeProfileXml({ bundleId: 'com.demo.*' })), name: 'Prefix' },
+ })
+ const findings = await wildcardProfileTargets.run(makeCtx({ projectDir: dir, platform: 'ios', credentials: { CAPGO_IOS_PROVISIONING_MAP: map } }))
+ expect(findings[0]?.title).toBe('Sorry, multiple matching wildcard provisioning profiles are not supported')
+ expect(findings[0]?.fix).toContain('Remove or replace')
+ })
+
+ it('deduplicates identical wildcard bytes and ignores exact-complete or nonmatching maps', async () => {
+ const dir = makeProject({ 'ios/App/App.xcodeproj/project.pbxproj': TWO_TARGET_PBXPROJ })
+ const wildcard = b64(makeProfileXml({ bundleId: 'com.demo.*' }))
+ const duplicateMap = JSON.stringify({ first: wildcard, second: { profile: wildcard, name: 'Duplicate' } })
+ const duplicateFindings = await wildcardProfileTargets.run(makeCtx({ projectDir: dir, platform: 'ios', credentials: { CAPGO_IOS_PROVISIONING_MAP: duplicateMap } }))
+ expect(duplicateFindings[0]?.title).not.toContain('multiple')
+
+ const exactMap = JSON.stringify({
+ 'com.demo.app': b64(makeProfileXml()),
+ 'com.demo.app.widget': b64(makeProfileXml({ bundleId: 'com.demo.app.widget' })),
+ wildcard: b64(makeProfileXml({ bundleId: 'org.other.*' })),
+ })
+ expect(await wildcardProfileTargets.run(makeCtx({ projectDir: dir, platform: 'ios', credentials: { CAPGO_IOS_PROVISIONING_MAP: exactMap } }))).toEqual([])
+ })
})
diff --git a/cli/test/prescan/engine.test.ts b/cli/test/prescan/engine.test.ts
index 8939f5049d..cc53738432 100644
--- a/cli/test/prescan/engine.test.ts
+++ b/cli/test/prescan/engine.test.ts
@@ -150,15 +150,15 @@ describe('fixture helpers', () => {
})
describe('registry', () => {
- it('contains all 84 checks with unique ids', () => {
+ it('contains all 85 checks with unique ids', () => {
const ids = ALL_CHECKS.map(c => c.id)
expect(new Set(ids).size).toBe(ids.length)
- expect(ids.length).toBe(84)
+ expect(ids.length).toBe(85)
for (const expected of [
'shared/apikey-permission', 'shared/app-exists', 'shared/credentials-saved',
'shared/cap-sync-stale', 'shared/node-linker-layout', 'shared/bundle-id-consistency',
'ios/p12-opens', 'ios/p12-legacy-encryption', 'ios/p12-expiry', 'ios/profile-expiry', 'ios/profile-bundle-match',
- 'ios/profile-type-vs-mode', 'ios/cert-profile-pairing', 'ios/targets-covered',
+ 'ios/profile-type-vs-mode', 'ios/cert-profile-pairing', 'ios/targets-covered', 'ios/wildcard-profile-targets',
'ios/infoplist-sanity', 'ios/asc-key-valid',
// 11 ios plist checks
'ios/plist-bundle-id-format', 'ios/plist-version-short-format', 'ios/plist-version-build-format',
diff --git a/cli/test/test-analytics.mjs b/cli/test/test-analytics.mjs
index 98830d0d11..e11bcc1f17 100644
--- a/cli/test/test-analytics.mjs
+++ b/cli/test/test-analytics.mjs
@@ -46,7 +46,8 @@ try {
delete process.env.CAPGO_DISABLE_TELEMETRY
delete process.env.CAPGO_DISABLE_POSTHOG
let requests = stubFetch()
- 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 } })
+ 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 flushAnalytics()
const req = findEvent(requests)
assert.ok(req, 'expected a /private/events request')
@@ -67,6 +68,9 @@ 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'
diff --git a/cli/test/test-android-gcp.mjs b/cli/test/test-android-gcp.mjs
index 5286dc7204..d1405a2148 100644
--- a/cli/test/test-android-gcp.mjs
+++ b/cli/test/test-android-gcp.mjs
@@ -109,15 +109,29 @@ await test('enableService short-circuits on noop.DONE_OPERATION without polling'
await test('sanitizeGcpProjectDisplayName strips em-dash + invalid chars', async () => {
const { sanitizeGcpProjectDisplayName } = await importGcp()
- assertEquals(sanitizeGcpProjectDisplayName('Capgo Native Build β com.example.app'), 'Capgo Native Build com.example')
+ assertEquals(sanitizeGcpProjectDisplayName('Capgo Native Build β com.example.app'), 'Capgo Native Build com example')
assertEquals(sanitizeGcpProjectDisplayName('foo_bar_baz'), 'foo bar baz')
assertEquals(sanitizeGcpProjectDisplayName('_leading and trailing_'), 'leading and trailing')
})
+await test('sanitizeGcpProjectDisplayName strips periods from appIds (GCP rejects dots)', async () => {
+ const { sanitizeGcpProjectDisplayName } = await importGcp()
+ const out = sanitizeGcpProjectDisplayName('Capgo Native Build com.spotties.spotties')
+ assert(!out.includes('.'), `display name must not contain periods, got "${out}"`)
+ assert(out.length >= 4 && out.length <= 30, `expected 4β30 chars, got ${out.length}: "${out}"`)
+ assert(/^[A-Za-z0-9]/.test(out), `must start with letter/digit, got "${out}"`)
+ assert(/[A-Za-z0-9]$/.test(out), `must end with letter/digit, got "${out}"`)
+ assertEquals(
+ sanitizeGcpProjectDisplayName('Capgo Native Build com.example.app'),
+ 'Capgo Native Build com example',
+ )
+})
+
await test('sanitizeGcpProjectDisplayName respects 30-char max + start/end rules', async () => {
const { sanitizeGcpProjectDisplayName } = await importGcp()
const long = 'Capgo Native Build com.very.long.app.name'
const out = sanitizeGcpProjectDisplayName(long)
+ assert(!out.includes('.'), `display name must not contain periods, got "${out}"`)
assert(out.length <= 30, `expected β€30 chars, got ${out.length}`)
assert(/^[A-Za-z0-9]/.test(out), `must start with letter/digit, got "${out}"`)
assert(/[A-Za-z0-9]$/.test(out), `must end with letter/digit, got "${out}"`)
diff --git a/cli/test/test-app-list-output-text.mjs b/cli/test/test-app-list-output-text.mjs
index 3c039cb8c2..1bc05da2af 100644
--- a/cli/test/test-app-list-output-text.mjs
+++ b/cli/test/test-app-list-output-text.mjs
@@ -47,36 +47,35 @@ finally {
const keyFixture = mkdtempSync(join(tmpdir(), 'capgo-app-list-key-'))
const projectFixture = join(keyFixture, 'project')
-const originalHome = process.env.HOME
-const originalCwd = process.cwd()
-const originalFixtureToken = process.env.CAPGO_TOKEN
try {
mkdirSync(projectFixture)
writeFileSync(join(keyFixture, '.capgo'), '')
writeFileSync(join(projectFixture, '.capgo'), 'local-output-text-token')
- process.env.HOME = keyFixture
- delete process.env.CAPGO_TOKEN
- process.chdir(projectFixture)
-
- const messages = []
- assert.equal(findSavedKey(false, message => messages.push(message)), 'local-output-text-token')
- assert.deepEqual(messages, ['Use local API key .capgo'])
-
- writeFileSync(join(projectFixture, '.capgo'), '')
- messages.length = 0
- assert.throws(() => findSavedKey(false, message => messages.push(message)), /No Capgo API key found/)
- assert.deepEqual(messages, [], 'does not announce an empty API-key file')
+ const childEnv = { ...process.env, HOME: keyFixture, USERPROFILE: keyFixture }
+ delete childEnv.CAPGO_TOKEN
+ const utilsUrl = new URL('../src/utils.ts', import.meta.url).href
+ const child = spawnSync(process.execPath, ['--eval', `
+ import { writeFileSync } from 'node:fs'
+ import { findSavedKey } from ${JSON.stringify(utilsUrl)}
+ const messages = []
+ const key = findSavedKey(false, message => messages.push(message))
+ const localMessages = [...messages]
+ writeFileSync('.capgo', '')
+ messages.length = 0
+ let emptyError = ''
+ try { findSavedKey(false, message => messages.push(message)) }
+ catch (error) { emptyError = error.message }
+ process.stdout.write('CAPGO_TEST_RESULT=' + JSON.stringify({ key, localMessages, messages, emptyError }))
+ `], { cwd: projectFixture, encoding: 'utf8', env: childEnv })
+ assert.equal(child.status, 0, child.stderr)
+ const marker = 'CAPGO_TEST_RESULT='
+ const result = JSON.parse(child.stdout.slice(child.stdout.lastIndexOf(marker) + marker.length))
+ assert.equal(result.key, 'local-output-text-token')
+ assert.deepEqual(result.localMessages, ['Use local API key .capgo'])
+ assert.deepEqual(result.messages, [], 'does not announce an empty API-key file')
+ assert.match(result.emptyError, /No Capgo API key found/)
}
finally {
- process.chdir(originalCwd)
- if (originalHome === undefined)
- delete process.env.HOME
- else
- process.env.HOME = originalHome
- if (originalFixtureToken === undefined)
- delete process.env.CAPGO_TOKEN
- else
- process.env.CAPGO_TOKEN = originalFixtureToken
rmSync(keyFixture, { recursive: true, force: true })
}
diff --git a/cli/test/test-app-todo.mjs b/cli/test/test-app-todo.mjs
new file mode 100644
index 0000000000..4cf617f0d4
--- /dev/null
+++ b/cli/test/test-app-todo.mjs
@@ -0,0 +1,182 @@
+#!/usr/bin/env node
+import assert from 'node:assert/strict'
+import { spawnSync } from 'node:child_process'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { formatAppTodoList, getAppTodoSteps, readAppTodoProgress } 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: [],
+}
+
+for (const version of [1, 2, 3, 4, 0, -1, 1.5, '3', undefined]) {
+ const value = { setup: { todo_list_version: version, 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), '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 ? '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 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' }))
+ const preload = join(fixture, 'fetch.mjs')
+ writeFileSync(preload, `
+ const nativeFetch = globalThis.fetch
+ 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/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 (url.includes('/private/onboarding_progress')) {
+ 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
+ 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:/)
+ }
+ else if (scenario === 'empty') assert.match(text, /0\/12 completed/)
+ 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)
+ }
+ }
+ }
+}
+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 e234358ce7..2ed8aa82f3 100644
--- a/cli/test/test-auth-session.mjs
+++ b/cli/test/test-auth-session.mjs
@@ -37,6 +37,7 @@ 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
@@ -111,6 +112,33 @@ 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-background-check-shutdown.mjs b/cli/test/test-background-check-shutdown.mjs
new file mode 100644
index 0000000000..e1a709b4dc
--- /dev/null
+++ b/cli/test/test-background-check-shutdown.mjs
@@ -0,0 +1,187 @@
+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 { performance } from 'node:perf_hooks'
+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') })])
+ const interruptedAt = performance.now()
+ child.kill('SIGINT')
+ const result = await completion
+ assert.equal(result.code, 130)
+ assert.equal(result.signal, null)
+ assert.ok(performance.now() - interruptedAt < 1_000)
+ 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-build-cache-payload.mjs b/cli/test/test-build-cache-payload.mjs
new file mode 100644
index 0000000000..a51195a3f4
--- /dev/null
+++ b/cli/test/test-build-cache-payload.mjs
@@ -0,0 +1,90 @@
+#!/usr/bin/env node
+
+console.log('π§ͺ Testing build job cache payload...\n')
+
+let testsPassed = 0
+let testsFailed = 0
+
+async function test(name, fn) {
+ try {
+ console.log(`\nπ ${name}`)
+ await fn()
+ console.log(`β
PASSED: ${name}`)
+ testsPassed++
+ }
+ catch (error) {
+ console.error(`β FAILED: ${name}`)
+ console.error(` Error: ${error.message}`)
+ testsFailed++
+ }
+}
+
+function assertDeepEquals(actual, expected, message) {
+ const actualJson = JSON.stringify(actual)
+ const expectedJson = JSON.stringify(expected)
+ if (actualJson !== expectedJson) {
+ throw new Error(message || `Expected ${expectedJson}, got ${actualJson}`)
+ }
+}
+
+const { buildJobCachePayload, shouldLogFailedBuildCacheHint } = await import('../src/build/request.ts')
+
+await test('buildJobCachePayload omits cache fields by default', () => {
+ assertDeepEquals(buildJobCachePayload(), {})
+ assertDeepEquals(buildJobCachePayload(undefined), {})
+ assertDeepEquals(buildJobCachePayload({ cache: true }), {})
+})
+
+await test('buildJobCachePayload sends cache_enabled false when opted out', () => {
+ assertDeepEquals(buildJobCachePayload({ cache: false }), { cache_enabled: false })
+})
+
+await test('buildJobCachePayload sends cache_key and cache_fingerprint_extra when set', () => {
+ assertDeepEquals(buildJobCachePayload({ cacheKey: 'prod' }), {
+ cache_key: 'prod',
+ cache_fingerprint_extra: 'prod',
+ })
+ assertDeepEquals(buildJobCachePayload({ cacheKey: ' rc ' }), {
+ cache_key: 'rc',
+ cache_fingerprint_extra: 'rc',
+ })
+})
+
+await test('buildJobCachePayload omits cache_key when blank', () => {
+ assertDeepEquals(buildJobCachePayload({ cacheKey: ' ' }), {})
+})
+
+await test('buildJobCachePayload can combine no-cache with cache_key (cache_key ignored by builder when disabled)', () => {
+ assertDeepEquals(buildJobCachePayload({ cache: false, cacheKey: 'prod' }), {
+ cache_enabled: false,
+ cache_key: 'prod',
+ cache_fingerprint_extra: 'prod',
+ })
+})
+
+await test('shouldLogFailedBuildCacheHint is on for default CLI builds', () => {
+ if (!shouldLogFailedBuildCacheHint({}))
+ throw new Error('default CLI failure should show the cache tip')
+ if (!shouldLogFailedBuildCacheHint({ aiAnalysisMode: 'auto-prompt' }))
+ throw new Error('auto-prompt CLI failure should show the cache tip')
+ if (!shouldLogFailedBuildCacheHint({ aiAnalysisMode: 'skip' }))
+ throw new Error('skip-AI CLI failure should still show the cache tip')
+})
+
+await test('shouldLogFailedBuildCacheHint is off for onboarding TUI caller-handled mode', () => {
+ if (shouldLogFailedBuildCacheHint({ aiAnalysisMode: 'caller-handled' }))
+ throw new Error('TUI caller-handled mode must not stream the cache tip into build-log-view')
+})
+
+await test('shouldLogFailedBuildCacheHint is off when cache is already isolated or disabled', () => {
+ if (shouldLogFailedBuildCacheHint({ cache: false }))
+ throw new Error('no-cache builds should not show the cache tip')
+ if (shouldLogFailedBuildCacheHint({ cacheKey: 'prod' }))
+ throw new Error('cache-key builds should not show the cache tip')
+ if (!shouldLogFailedBuildCacheHint({ cacheKey: ' ' }))
+ throw new Error('blank cache-key should still show the cache tip')
+})
+
+console.log(`\n${testsPassed} passed, ${testsFailed} failed`)
+if (testsFailed > 0)
+ process.exit(1)
diff --git a/cli/test/test-capacitor-config-native-import.mjs b/cli/test/test-capacitor-config-native-import.mjs
new file mode 100644
index 0000000000..11029f933c
--- /dev/null
+++ b/cli/test/test-capacitor-config-native-import.mjs
@@ -0,0 +1,43 @@
+import assert from 'node:assert/strict'
+import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import process from 'node:process'
+import { fileURLToPath } from 'node:url'
+import { loadConfigTarget } from '../src/config/index.ts'
+
+const cliRoot = dirname(dirname(fileURLToPath(import.meta.url)))
+const root = mkdtempSync(join(cliRoot, '.capgo-native-import-config-'))
+
+const major = Number(process.versions.node.split('.')[0])
+const supportsNativeImport = Boolean(process.versions.bun) || (Number.isFinite(major) && major >= 22)
+if (!supportsNativeImport) {
+ console.log('Skipping native-import config test: requires Bun or Node.js 22+')
+ process.exit(0)
+}
+
+try {
+ writeFileSync(join(root, 'package.json'), JSON.stringify({
+ name: 'native-import-capacitor-app',
+ private: true,
+ }))
+ writeFileSync(join(root, 'capacitor-app-id.ts'), `export const appId = 'com.example.native-import'
+`)
+ writeFileSync(join(root, 'capacitor.config.ts'), `import { appId } from './capacitor-app-id.ts'
+
+export default {
+ appId,
+ appName: 'Native import app',
+ webDir: 'www',
+}
+`)
+
+ const configPath = join(root, 'capacitor.config.ts')
+ const targeted = await loadConfigTarget(configPath)
+ assert.equal(targeted.appId, 'com.example.native-import')
+ assert.equal(targeted.appName, 'Native import app')
+ assert.equal(targeted.webDir, 'www')
+}
+finally {
+ if (existsSync(root))
+ rmSync(root, { recursive: true, force: true })
+}
diff --git a/cli/test/test-capacitor-config-typescript7.mjs b/cli/test/test-capacitor-config-typescript7.mjs
new file mode 100644
index 0000000000..20253fa6d2
--- /dev/null
+++ b/cli/test/test-capacitor-config-typescript7.mjs
@@ -0,0 +1,68 @@
+import assert from 'node:assert/strict'
+import { createRequire } from 'node:module'
+import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import process from 'node:process'
+import { fileURLToPath } from 'node:url'
+import { loadConfig, loadConfigTarget } from '../src/config/index.ts'
+
+const cliRoot = dirname(dirname(fileURLToPath(import.meta.url)))
+const root = mkdtempSync(join(cliRoot, '.capgo-ts7-config-'))
+
+function writeTypescript7Stub(dir) {
+ const typescriptDir = join(dir, 'node_modules', 'typescript')
+ mkdirSync(typescriptDir, { recursive: true })
+ writeFileSync(join(typescriptDir, 'package.json'), JSON.stringify({
+ name: 'typescript',
+ version: '7.0.2',
+ main: 'index.js',
+ }))
+ // TypeScript 7's default export no longer has transpileModule / ModuleKind.
+ writeFileSync(join(typescriptDir, 'index.js'), `'use strict'
+module.exports = { version: '7.0.2', versionMajorMinor: '7.0' }
+`)
+}
+
+try {
+ writeFileSync(join(root, 'package.json'), JSON.stringify({
+ name: 'ts7-capacitor-app',
+ private: true,
+ dependencies: {
+ typescript: '7.0.2',
+ },
+ }))
+ writeTypescript7Stub(root)
+ const configPath = join(root, 'capacitor.config.ts')
+ writeFileSync(configPath, `export default {
+ appId: 'com.example.ts7',
+ appName: 'TS7 app',
+ webDir: 'www',
+}
+`)
+
+ const ts7 = createRequire(join(root, 'package.json'))('typescript')
+ assert.equal(ts7.version, '7.0.2')
+ assert.equal(ts7.transpileModule, undefined)
+ assert.equal(ts7.ModuleKind, undefined)
+
+ const previousCwd = process.cwd()
+ try {
+ process.chdir(root)
+ const loaded = await loadConfig()
+ assert.equal(loaded.config.appId, 'com.example.ts7')
+ assert.equal(loaded.config.appName, 'TS7 app')
+ assert.equal(loaded.config.webDir, 'www')
+ assert.equal(loaded.path, configPath)
+
+ const targeted = await loadConfigTarget(configPath)
+ assert.equal(targeted.appId, 'com.example.ts7')
+ assert.equal(targeted.webDir, 'www')
+ }
+ finally {
+ process.chdir(previousCwd)
+ }
+}
+finally {
+ if (existsSync(root))
+ rmSync(root, { recursive: true, force: true })
+}
diff --git a/cli/test/test-channel-list.mjs b/cli/test/test-channel-list.mjs
new file mode 100644
index 0000000000..8e75d6c3cf
--- /dev/null
+++ b/cli/test/test-channel-list.mjs
@@ -0,0 +1,210 @@
+#!/usr/bin/env node
+process.env.CAPGO_DISABLE_POSTHOG = '1'
+import assert from 'node:assert/strict'
+import { spawnSync } from 'node:child_process'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { checkAppExists, checkAppExistsAndHasPermissionOrgErr } from '../src/api/app.ts'
+import { displayChannels, formatChannels, getActiveChannels } from '../src/api/channels.ts'
+import { CliUserError } from '../src/shared/cli-user-error.ts'
+import { visibleWidth } from '../src/terminal-table.ts'
+import { shouldCapturePosthogException } from '../src/posthog.ts'
+
+const appId = 'com.example.channel.list'
+const options = { apikey: 'test-channel-list-key', supaHost: 'http://localhost:54321', supaAnon: 'test-anon-key', silent: true }
+const originalFetch = globalThis.fetch
+const calls = []
+let responseStatus = 200
+let responseBody = { app_id: appId }
+const httpChannel = {
+ id: 1, name: 'production', public: true, ios: true, android: false,
+ disableAutoUpdate: 'major', disableAutoUpdateUnderNative: true,
+ allow_device_self_set: true, allow_emulator: false, allow_device: true,
+ allow_dev: false, allow_prod: true, version: null,
+}
+const supabase = {
+ supabaseUrl: options.supaHost,
+ supabaseKey: options.supaAnon,
+ rpc(name) {
+ assert.equal(name, 'cli_check_permission')
+ return Promise.resolve({ data: false, error: null })
+ },
+}
+
+globalThis.fetch = async (input) => {
+ calls.push(String(input))
+ return new Response(JSON.stringify(responseBody), {
+ status: responseStatus,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+function permissionError(permission) {
+ return (error) => {
+ assert.ok(error instanceof CliUserError)
+ assert.match(error.message, new RegExp(permission.replaceAll('.', '\\.')))
+ assert.doesNotMatch(error.message, /non-2xx|not found|upload/)
+ assert.equal(error.context.appId, appId)
+ assert.equal(shouldCapturePosthogException(error), false)
+ return true
+ }
+}
+
+try {
+ for (const status of [401, 403]) {
+ responseStatus = status
+ responseBody = { error: 'cannot_access_app', message: "You can't access this app" }
+ await assert.rejects(() => checkAppExists(options.apikey, appId, options), permissionError('app.read'))
+ await assert.rejects(
+ () => checkAppExistsAndHasPermissionOrgErr(supabase, options.apikey, appId, 'app.read_channels', true, true),
+ permissionError('app.read'),
+ )
+ }
+
+ responseStatus = 200
+ responseBody = { app_id: appId }
+ await assert.rejects(
+ () => checkAppExistsAndHasPermissionOrgErr(supabase, options.apikey, appId, 'app.read_channels', true, true),
+ permissionError('app.read_channels'),
+ )
+
+ responseStatus = 404
+ assert.equal(await checkAppExists(options.apikey, appId, options), false)
+
+ for (const status of [400, 401, 403]) {
+ responseStatus = status
+ responseBody = { error: 'cannot_access_app', message: "You can't access this app" }
+ await assert.rejects(() => getActiveChannels(options, appId), permissionError('app.read_channels'))
+ }
+
+ responseStatus = 500
+ responseBody = { error: 'database_unavailable', message: 'Please retry later' }
+ await assert.rejects(() => getActiveChannels(options, appId), (error) => {
+ assert.ok(!(error instanceof CliUserError))
+ assert.match(error.message, /Cannot list channels: database_unavailable \| Please retry later/)
+ assert.doesNotMatch(error.message, /not found|non-2xx/)
+ return true
+ })
+ await assert.rejects(() => checkAppExists(options.apikey, appId, options), /database_unavailable \| Please retry later/)
+
+ responseStatus = 200
+ responseBody = [httpChannel]
+ const channels = await getActiveChannels(options, appId)
+ assert.equal(channels[0].ios, true)
+ assert.equal(channels[0].android, false)
+ assert.equal(channels[0].version, undefined)
+ assert.equal(channels[0].disable_auto_update, 'major')
+ assert.equal(channels[0].disable_auto_update_under_native, true)
+ assert.ok(calls.some(url => url.includes('/channel?app_id=com.example.channel.list&page=0')))
+
+ responseBody = httpChannel
+ const singleChannel = await getActiveChannels(options, appId)
+ assert.equal(singleChannel.length, 1, 'preserves a single-channel object response')
+ assert.equal(singleChannel[0].name, 'production')
+ assert.equal(singleChannel[0].ios, true)
+ assert.equal(singleChannel[0].android, false)
+
+ const input = [...channels, { ...channels[0], id: 2, name: 'ζ΅θ―', version: { name: '1.2.3' } }]
+ const wide = formatChannels(input, 240)
+ const lines = wide.split('\n')
+ assert.equal(new Set(lines.map(visibleWidth)).size, 1, 'all table lines have equal visible width')
+ const borders = lines.filter(line => line.startsWith('β')).map(line => {
+ const positions = []
+ for (let index = 0; index < line.length; index++) {
+ if (line[index] === 'β')
+ positions.push(visibleWidth(line.slice(0, index)))
+ }
+ return positions
+ })
+ for (const positions of borders)
+ assert.deepEqual(positions, borders[0], 'header and data column borders align')
+ assert.match(wide, /Unlinked/)
+ assert.doesNotMatch(wide, /β
|β|undefined|null/)
+ assert.ok(wide.indexOf('ζ΅θ―') < wide.indexOf('production'), 'preserves display order')
+ assert.equal(input[0].name, 'production', 'does not mutate channel order')
+
+ const narrow = formatChannels(channels, 80)
+ assert.ok(narrow.split('\n').every(line => visibleWidth(line) <= 80))
+ assert.match(narrow, /Setting.*Value/)
+ assert.match(narrow, /iOS\s+β Yes/)
+ assert.match(narrow, /Android\s+β No/)
+ assert.match(narrow, /Updates Under Native\s+β No/)
+ const longName = 'a'.repeat(150)
+ const wrapped = formatChannels([{ ...channels[0], name: longName }], 80)
+ assert.ok(wrapped.split('\n').every(line => visibleWidth(line) <= 80), 'long values wrap in narrow terminals')
+ assert.equal(formatChannels([]), 'No channels found.')
+ displayChannels(channels, true)
+
+ responseBody = Array.from({ length: 50 }, (_, id) => ({ ...httpChannel, id }))
+ let pages = 0
+ globalThis.fetch = async (input) => {
+ assert.equal(new URL(String(input)).searchParams.get('page'), String(pages))
+ return Response.json(pages++ === 0 ? responseBody : [{ ...httpChannel, id: 50 }])
+ }
+ assert.equal((await getActiveChannels(options, appId)).length, 51)
+ assert.equal(pages, 2)
+
+ console.log('Channel list permission, output, platform settings, and pagination tests passed')
+}
+finally {
+ globalThis.fetch = originalFetch
+}
+
+const fixture = mkdtempSync(join(tmpdir(), 'capgo-channel-list-'))
+try {
+ const preload = join(fixture, 'fetch.mjs')
+ writeFileSync(preload, `
+ const nativeFetch = globalThis.fetch
+ const scenario = process.env.CAPGO_CHANNEL_LIST_SCENARIO
+ globalThis.fetch = async (input) => {
+ const url = input?.url ?? String(input)
+ if (!url.startsWith('http') || url.includes('.wasm'))
+ return nativeFetch(input)
+ if (url.includes('/private/config'))
+ return Response.json({})
+ if (url.includes('/rpc/reject_access_due_to_2fa_for_app'))
+ return Response.json(false)
+ if (url.includes('/rpc/cli_check_permission'))
+ return Response.json(scenario !== 'denied-channel')
+ if (url.includes('/app/' + ${JSON.stringify(appId)})) {
+ if (scenario === 'denied-app')
+ return Response.json({ error: 'cannot_access_app' }, { status: 401 })
+ return Response.json({ app_id: ${JSON.stringify(appId)}, owner_org: 'test-org' })
+ }
+ if (url.includes('/channel?')) {
+ if (scenario === 'denied-http')
+ return Response.json({ error: 'cannot_access_app' }, { status: 400 })
+ const channel = ${JSON.stringify(httpChannel)}
+ return Response.json(scenario === 'single-object' ? channel : [channel])
+ }
+ return Response.json({ status: 'ok' })
+ }
+ `)
+ for (const scenario of ['denied-app', 'denied-channel', 'denied-http', 'allowed', 'single-object']) {
+ const child = spawnSync('node', [
+ '--import', preload, new URL('../dist/index.js', import.meta.url).pathname,
+ 'channel', 'list', appId, '-a', options.apikey,
+ '--supa-host', options.supaHost, '--supa-anon', options.supaAnon,
+ ], {
+ encoding: 'utf8', timeout: 15000,
+ env: { ...process.env, CAPGO_CHANNEL_LIST_SCENARIO: scenario },
+ })
+ const output = child.stdout + child.stderr
+ assert.equal(child.status, scenario.startsWith('denied') ? 1 : 0, output)
+ assert.doesNotMatch(output, /Edge Function returned|non-2xx/)
+ if (scenario === 'denied-app')
+ assert.match(output, /app.read permission/)
+ else if (scenario.startsWith('denied'))
+ assert.match(output, /app.read_channels/)
+ else {
+ assert.match(output, /Unlinked/)
+ assert.match(output, /iOS\s+β Yes/)
+ assert.match(output, /Android\s+β No/)
+ }
+ }
+ console.log('Built CLI prints permission failures and readable channel settings')
+}
+finally {
+ rmSync(fixture, { recursive: true, force: true })
+}
diff --git a/cli/test/test-cli-help.mjs b/cli/test/test-cli-help.mjs
new file mode 100644
index 0000000000..7fc4ee6cfc
--- /dev/null
+++ b/cli/test/test-cli-help.mjs
@@ -0,0 +1,60 @@
+#!/usr/bin/env node
+
+import assert from 'node:assert/strict'
+import { spawnSync } from 'node:child_process'
+
+const cliDir = new URL('..', import.meta.url)
+
+function getHelp(...command) {
+ const result = spawnSync(process.execPath, ['dist/index.js', ...command, '--help'], {
+ cwd: cliDir,
+ encoding: 'utf8',
+ })
+ assert.equal(result.status, 0, result.stderr)
+ return result.stdout
+}
+
+const buildHelp = getHelp('build')
+assert.match(buildHelp, /Build native iOS and Android apps with Capgo Cloud\./)
+assert.match(buildHelp, /Quick start:/)
+assert.match(buildHelp, /needed \[options\] \[appId\]\s+Check whether a native build is required/)
+assert.match(buildHelp, /request \[options\] \[appId\]\s+Request a native build from Capgo Cloud/)
+assert.match(buildHelp, /credentials\s+Manage locally saved build credentials/)
+assert.doesNotMatch(buildHelp, /SECURITY GUARANTEE|CAPTURE THE OUTPUT|The project is zipped/)
+assert.ok(buildHelp.trimEnd().split('\n').length < 40, 'build help should remain compact')
+
+const credentialsHelp = getHelp('build', 'credentials')
+assert.match(credentialsHelp, /Credentials are stored in ~\/\.capgo-credentials\/credentials\.json globally/)
+assert.match(credentialsHelp, /ios-provisioning \[options\]\s+Set up profiles for every signable iOS target/)
+assert.match(credentialsHelp, /save \[options\]\s+Save iOS or Android build credentials/)
+assert.match(credentialsHelp, /export \[options\] \s+Export one saved credential value/)
+assert.doesNotMatch(credentialsHelp, /Opens a native window|Reuses an eligible saved wildcard profile|iOS Example/)
+assert.ok(credentialsHelp.trimEnd().split('\n').length < 45, 'credentials help should remain compact')
+
+const requestHelp = getHelp('build', 'request')
+for (const heading of [
+ 'General options:',
+ 'iOS options:',
+ 'Android options:',
+ 'Store options:',
+ 'Output and version options:',
+ 'Prescan and diagnostics options:',
+ 'Capgo options:',
+]) {
+ assert.match(requestHelp, new RegExp(heading))
+}
+
+const saveHelp = getHelp('build', 'credentials', 'save')
+const updateHelp = getHelp('build', 'credentials', 'update')
+for (const help of [saveHelp, updateHelp]) {
+ assert.match(help, /General options:/)
+ assert.match(help, /iOS options:/)
+ assert.match(help, /Android options:/)
+ assert.match(help, /Build defaults:/)
+}
+
+for (const help of [buildHelp, credentialsHelp, requestHelp, saveHelp, updateHelp]) {
+ assert.doesNotMatch(help, /npx @capgo\/cli(?!@latest)/)
+}
+
+console.log('β
CLI help readability checks passed')
diff --git a/cli/test/test-cli-recovery.mjs b/cli/test/test-cli-recovery.mjs
index 3e9d26c392..d0510b9ed3 100644
--- a/cli/test/test-cli-recovery.mjs
+++ b/cli/test/test-cli-recovery.mjs
@@ -253,7 +253,7 @@ await test('resolveUpdaterPackageJsonPath resolves root-relative package.json op
try {
process.chdir(root)
const resolved = resolveUpdaterPackageJsonPath('missing/package.json,apps/mobile/package.json')
- assert.equal(resolved, join(root, 'apps', 'mobile', 'package.json'))
+ assert.equal(resolved, realpathSync(join(root, 'apps', 'mobile', 'package.json')))
}
finally {
process.chdir(previousCwd)
diff --git a/cli/test/test-credentials-export.mjs b/cli/test/test-credentials-export.mjs
index 4d21130aae..d336a5db3c 100644
--- a/cli/test/test-credentials-export.mjs
+++ b/cli/test/test-credentials-export.mjs
@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { canDecodeCredentialBase64, decodeCredentialBase64 } from '../src/build/credentials-base64.ts'
+import { resolveCredentialsStore } from '../src/build/credentials-store-selection.ts'
const {
isCredentialsExportInvocation,
resolveCredentialsExport,
@@ -126,6 +127,27 @@ const appId = 'com.example.app'
const localIos = { ios: { BUILD_CERTIFICATE_BASE64: 'local-cert', P12_PASSWORD: '' } }
const globalAndroid = { android: { ANDROID_KEYSTORE_FILE: 'global-store' } }
+await test('shared store resolver selects the sole configured store', () => {
+ assert.deepEqual(
+ resolveCredentialsStore({ appId }, { local: localIos, global: null }),
+ { source: 'local', saved: localIos },
+ )
+})
+
+await test('shared store resolver rejects split local and global credentials', () => {
+ assert.throws(
+ () => resolveCredentialsStore({ appId }, { local: localIos, global: globalAndroid }),
+ /pass --local or --global/i,
+ )
+})
+
+await test('shared store resolver never falls back from an explicit source', () => {
+ assert.throws(
+ () => resolveCredentialsStore({ appId, local: true }, { local: null, global: globalAndroid }),
+ /local store/i,
+ )
+})
+
await test('automatically chooses a local-only configured source and platform', () => {
assert.deepEqual(
resolveCredentialsExport('BUILD_CERTIFICATE_BASE64', { appId }, { local: localIos, global: null }),
diff --git a/cli/test/test-fail-on-incompatible.mjs b/cli/test/test-fail-on-incompatible.mjs
index 3ecec3e18a..83993a590f 100644
--- a/cli/test/test-fail-on-incompatible.mjs
+++ b/cli/test/test-fail-on-incompatible.mjs
@@ -10,6 +10,8 @@
import assert from 'node:assert/strict'
import { shouldBlockIncompatibleUpload } from '../src/bundle/builder-cta.ts'
import { checkValidOptions } from '../src/bundle/upload.ts'
+import { rejectOrAcceptIncompatibleChannelBundle } from '../src/channel/set.ts'
+import { requestBuildOptionsSchema, updateChannelOptionsSchema, uploadOptionsSchema } from '../src/schemas/sdk.ts'
let failures = 0
@@ -134,6 +136,108 @@ test('--ignore-metadata-check alone does not trigger the conflict', () => {
assert.doesNotThrow(() => checkValidOptions({ ignoreMetadataCheck: true }))
})
+test('--accept-incompatible together with --fail-on-incompatible is rejected', () => {
+ assert.throws(
+ () => checkValidOptions({ acceptIncompatible: true, failOnIncompatible: true }),
+ (error) => {
+ assert.ok(error instanceof Error, 'expected an Error to be thrown')
+ assert.match(error.message, /--accept-incompatible/, 'message should mention --accept-incompatible')
+ assert.match(error.message, /--fail-on-incompatible/, 'message should mention --fail-on-incompatible')
+ return true
+ },
+ )
+})
+
+test('--accept-incompatible together with --ignore-metadata-check is rejected', () => {
+ assert.throws(
+ () => checkValidOptions({ acceptIncompatible: true, ignoreMetadataCheck: true }),
+ (error) => {
+ assert.ok(error instanceof Error, 'expected an Error to be thrown')
+ assert.match(error.message, /--accept-incompatible/, 'message should mention --accept-incompatible')
+ assert.match(error.message, /--ignore-metadata-check/, 'message should mention --ignore-metadata-check')
+ return true
+ },
+ )
+})
+
+test('--accept-incompatible alone does not trigger a conflict', () => {
+ assert.doesNotThrow(() => checkValidOptions({ acceptIncompatible: true }))
+})
+
+test('SDK uploadOptionsSchema rejects acceptIncompatible with ignoreCompatibilityCheck', () => {
+ const result = uploadOptionsSchema.safeParse({
+ appId: 'com.example.app',
+ path: './dist',
+ acceptIncompatible: true,
+ ignoreCompatibilityCheck: true,
+ })
+ assert.equal(result.success, false)
+ if (result.success)
+ return
+ assert.ok(result.error.issues.some(issue => issue.path.includes('ignoreCompatibilityCheck')))
+})
+
+test('SDK uploadOptionsSchema accepts acceptIncompatible alone', () => {
+ const result = uploadOptionsSchema.safeParse({
+ appId: 'com.example.app',
+ path: './dist',
+ acceptIncompatible: true,
+ })
+ assert.equal(result.success, true)
+})
+
+test('SDK uploadOptionsSchema rejects string acceptIncompatible', () => {
+ const result = uploadOptionsSchema.safeParse({
+ appId: 'com.example.app',
+ path: './dist',
+ acceptIncompatible: 'false',
+ })
+ assert.equal(result.success, false)
+})
+
+test('SDK requestBuildOptionsSchema rejects non-string cacheKey', () => {
+ const result = requestBuildOptionsSchema.safeParse({
+ appId: 'com.example.app',
+ platform: 'ios',
+ cacheKey: 123,
+ })
+ assert.equal(result.success, false)
+})
+
+test('SDK requestBuildOptionsSchema rejects null cacheKey', () => {
+ const result = requestBuildOptionsSchema.safeParse({
+ appId: 'com.example.app',
+ platform: 'ios',
+ cacheKey: null,
+ })
+ assert.equal(result.success, false)
+})
+
+test('SDK requestBuildOptionsSchema trims cacheKey and omits whitespace-only values', () => {
+ const trimmed = requestBuildOptionsSchema.parse({
+ appId: 'com.example.app',
+ platform: 'ios',
+ cacheKey: ' prod ',
+ })
+ assert.equal(trimmed.cacheKey, 'prod')
+
+ const omitted = requestBuildOptionsSchema.parse({
+ appId: 'com.example.app',
+ platform: 'ios',
+ cacheKey: ' ',
+ })
+ assert.equal(omitted.cacheKey, undefined)
+})
+
+test('SDK updateChannelOptionsSchema rejects string acceptIncompatible', () => {
+ const result = updateChannelOptionsSchema.safeParse({
+ channelId: 'production',
+ appId: 'com.example.app',
+ acceptIncompatible: 'false',
+ })
+ assert.equal(result.success, false)
+})
+
test('--rollout-advance with --dry-upload is rejected', () => {
assert.throws(
() => checkValidOptions({ rolloutAdvance: true, dryUpload: true }),
@@ -149,6 +253,43 @@ test('--rollout-advance alone does not trigger a dry-upload conflict', () => {
assert.doesNotThrow(() => checkValidOptions({ rolloutAdvance: true }))
})
+console.log('\nπ§ͺ Testing rejectOrAcceptIncompatibleChannelBundle...\n')
+
+test('channel set throws when incompatible and not accepted', () => {
+ assert.throws(
+ () => rejectOrAcceptIncompatibleChannelBundle({
+ silent: true,
+ acceptIncompatible: false,
+ incompatible: true,
+ finalCompatibility: [],
+ heading: 'nope',
+ errorMessage: 'Bundle is not compatible with production channel',
+ }),
+ /Bundle is not compatible with production channel/,
+ )
+})
+
+test('channel set continues when incompatible and accepted', () => {
+ assert.doesNotThrow(() => rejectOrAcceptIncompatibleChannelBundle({
+ silent: true,
+ acceptIncompatible: true,
+ incompatible: true,
+ finalCompatibility: [],
+ heading: 'nope',
+ errorMessage: 'Bundle is not compatible with production channel',
+ }))
+})
+
+test('channel set continues when compatible', () => {
+ assert.doesNotThrow(() => rejectOrAcceptIncompatibleChannelBundle({
+ silent: true,
+ incompatible: false,
+ finalCompatibility: [],
+ heading: 'nope',
+ errorMessage: 'nope',
+ }))
+})
+
if (failures > 0) {
console.error(`\nβ ${failures} fail-on-incompatible test(s) failed`)
process.exit(1)
diff --git a/cli/test/test-init-app-conflict.mjs b/cli/test/test-init-app-conflict.mjs
index 3aecafd6b7..d210994ae8 100644
--- a/cli/test/test-init-app-conflict.mjs
+++ b/cli/test/test-init-app-conflict.mjs
@@ -1,7 +1,9 @@
import assert from 'node:assert/strict'
+import { createClient } from '@supabase/supabase-js'
import { findAppInOrganization } from '../src/api/app.ts'
import { buildAppIdConflictSuggestions, isAppAlreadyExistsError } from '../src/init/app-conflict.ts'
import { isChannelAlreadyExistsError } from '../src/init/channel-conflict.ts'
+import { selectOnboardingChannel } from '../src/init/channel-selection.ts'
let failures = 0
@@ -139,6 +141,140 @@ await t('findAppInOrganization returns null for another org or missing app', asy
}
})
+async function withChannelSelection(channels, answers, run, lookupError) {
+ const calls = { reuse: [], chooseName: [], create: [] }
+ const supabase = createClient('https://example.supabase.co', 'test-key', {
+ auth: { persistSession: false, autoRefreshToken: false },
+ })
+ mockAppFetch(async (url) => {
+ const request = new URL(url)
+ assert.equal(request.pathname, '/rest/v1/channels')
+ assert.equal(request.searchParams.get('app_id'), 'eq.com.example.app')
+ assert.equal(request.searchParams.get('select'), 'name,public')
+ return jsonResponse(lookupError ?? channels, lookupError ? 403 : 200)
+ })
+ const prompts = {
+ reuseChannel: async (name) => {
+ calls.reuse.push(name)
+ const answer = answers.reuse.shift()
+ if (answer instanceof Error)
+ throw answer
+ assert.equal(typeof answer, 'boolean', 'unexpected reuse prompt')
+ return answer
+ },
+ chooseName: async (names) => {
+ calls.chooseName.push([...names])
+ const name = answers.names.shift()
+ assert.equal(typeof name, 'string', 'unexpected new-channel prompt')
+ return name
+ },
+ createChannel: async (name) => {
+ calls.create.push(name)
+ const error = answers.createErrors?.shift()
+ if (error)
+ throw error
+ },
+ }
+ try {
+ await run(() => selectOnboardingChannel(supabase, 'com.example.app', answers.preferredName ?? 'production', prompts), calls)
+ }
+ finally {
+ globalThis.fetch = originalFetch
+ }
+}
+
+for (const name of ['production', 'staging']) {
+ await t(`onboarding offers to reuse an existing ${name} channel before creating anything`, async () => {
+ await withChannelSelection([{ name, public: true }], { reuse: [true], names: [] }, async (select, calls) => {
+ assert.equal(await select(), name)
+ assert.deepEqual(calls, { reuse: [name], chooseName: [], create: [] })
+ })
+ })
+}
+
+await t('onboarding keeps the channel-name picker for apps without channels', async () => {
+ await withChannelSelection([], { reuse: [], names: ['production'] }, async (select, calls) => {
+ assert.equal(await select(), 'production')
+ assert.deepEqual(calls, { reuse: [], chooseName: [[]], create: ['production'] })
+ })
+})
+
+await t('declining reuse creates a new channel with the chosen name', async () => {
+ await withChannelSelection([{ name: 'production', public: true }], { reuse: [false], names: ['beta'] }, async (select, calls) => {
+ assert.equal(await select(), 'beta')
+ assert.deepEqual(calls, { reuse: ['production'], chooseName: [['production']], create: ['beta'] })
+ })
+})
+
+await t('choosing another existing channel asks for reuse instead of recreating it', async () => {
+ await withChannelSelection([
+ { name: 'beta', public: false },
+ { name: 'production', public: true },
+ ], { reuse: [false, true], names: ['beta'] }, async (select, calls) => {
+ assert.equal(await select(), 'beta')
+ assert.deepEqual(calls.reuse, ['production', 'beta'])
+ assert.deepEqual(calls.create, [])
+ })
+})
+
+await t('declining an already-used custom name returns to name selection', async () => {
+ await withChannelSelection([{ name: 'beta', public: false }], { reuse: [false, false], names: ['beta', 'dev'] }, async (select, calls) => {
+ assert.equal(await select(), 'dev')
+ assert.deepEqual(calls.reuse, ['beta', 'beta'])
+ assert.deepEqual(calls.create, ['dev'])
+ })
+})
+
+for (const [preferredName, expected] of [['production', 'production'], ['beta', 'beta'], ['missing', 'staging']]) {
+ await t(`onboarding prefers ${expected} when the saved channel is ${preferredName}`, async () => {
+ await withChannelSelection([
+ { name: 'beta', public: false },
+ { name: 'production', public: false },
+ { name: 'staging', public: true },
+ ], { preferredName, reuse: [true], names: [] }, async (select, calls) => {
+ assert.equal(await select(), expected)
+ assert.deepEqual(calls.reuse, [expected])
+ assert.deepEqual(calls.create, [])
+ })
+ })
+}
+
+await t('channel lookup failures stop onboarding before prompting or creating', async () => {
+ await withChannelSelection([], { reuse: [], names: [] }, async (select, calls) => {
+ await assert.rejects(select, /Cannot check existing channels:.*permission denied/)
+ assert.deepEqual(calls, { reuse: [], chooseName: [], create: [] })
+ }, { message: 'permission denied', code: '42501' })
+})
+
+await t('cancelling reuse stops without creating a channel', async () => {
+ const cancelled = new Error('Operation cancelled')
+ await withChannelSelection([{ name: 'production', public: true }], { reuse: [cancelled], names: [] }, async (select, calls) => {
+ await assert.rejects(select, error => error === cancelled)
+ assert.deepEqual(calls.create, [])
+ })
+})
+
+await t('a duplicate creation error offers reuse and permits a different new name', async () => {
+ await withChannelSelection([], {
+ reuse: [false],
+ names: ['production', 'beta'],
+ createErrors: [new Error('duplicate key value violates unique constraint "unique_name_app_id"')],
+ }, async (select, calls) => {
+ assert.equal(await select(), 'beta')
+ assert.deepEqual(calls.reuse, ['production'])
+ assert.deepEqual(calls.chooseName, [[], ['production']])
+ assert.deepEqual(calls.create, ['production', 'beta'])
+ })
+})
+
+await t('other creation failures propagate without offering reuse', async () => {
+ const failed = new Error('network unavailable')
+ await withChannelSelection([], { reuse: [], names: ['production'], createErrors: [failed] }, async (select, calls) => {
+ await assert.rejects(select, error => error === failed)
+ assert.deepEqual(calls.reuse, [])
+ })
+})
+
if (failures > 0) {
console.error(`\nβ ${failures} init app conflict test(s) failed`)
process.exit(1)
diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs
index b0053b202c..c7e63d8da6 100644
--- a/cli/test/test-init-guardrails.mjs
+++ b/cli/test/test-init-guardrails.mjs
@@ -329,6 +329,12 @@ t('init updater config always starts from native version 0.0.0', () => {
})
})
+t('init updater config downloads from the channel selected during onboarding', () => {
+ for (const directInstall of [false, true]) {
+ assert.equal(getInitUpdaterPluginConfig('com.example.app', directInstall, 'staging').defaultChannel, 'staging')
+ }
+})
+
t('instant updates install splash-screen matching Capacitor major', () => {
assert.equal(getSplashScreenVersionToInstall('7.4.0'), '^7.0.0')
assert.equal(getSplashScreenVersionToInstall('8.0.0'), 'latest')
diff --git a/cli/test/test-init-upload-error-gate.mjs b/cli/test/test-init-upload-error-gate.mjs
index 1e3c5ddab5..2ff6898a20 100644
--- a/cli/test/test-init-upload-error-gate.mjs
+++ b/cli/test/test-init-upload-error-gate.mjs
@@ -1,6 +1,10 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
+import {
+ getBundleUploadFailureRecoveryOptions,
+ mergeMonorepoRootUploadPaths,
+} from '../src/init/upload-recovery.ts'
const runtime = readFileSync(fileURLToPath(new URL('../src/init/runtime.tsx', import.meta.url)), 'utf8')
const app = readFileSync(fileURLToPath(new URL('../src/init/ui/app.tsx', import.meta.url)), 'utf8')
@@ -16,6 +20,18 @@ assert.match(app, /else\s+\{\s+exit\(130\)/)
assert.doesNotMatch(app, /key\.escape/)
assert.match(command, /waitForInitStreamingContinue\('Press Enter to continue, or Ctrl\+C to cancel\.'/)
assert.match(command, /updateInitStreamingStatus\('error', failureText\)/)
+assert.deepEqual(
+ getBundleUploadFailureRecoveryOptions().map(option => option.value),
+ ['retry', 'retry-with-monorepo-paths'],
+)
+assert.equal(
+ mergeMonorepoRootUploadPaths(
+ { packageJson: './package.json' },
+ { packageJson: './apps/mobile/package.json' },
+ '/workspace/app',
+ ).packageJson,
+ '/workspace/app/package.json,/workspace/app/apps/mobile/package.json',
+)
assert.doesNotMatch(app, /Press Enter to continue\. Press Ctrl\+C to cancel\./)
assert.match(replicationProgress, /reporter\?: ReplicationProgressReporter/)
assert.match(replicationProgress, /const progressReporter = reporter \?\? clackReplicationReporter/)
diff --git a/cli/test/test-init-upload-monorepo-recovery.mjs b/cli/test/test-init-upload-monorepo-recovery.mjs
new file mode 100644
index 0000000000..25067fabb6
--- /dev/null
+++ b/cli/test/test-init-upload-monorepo-recovery.mjs
@@ -0,0 +1,102 @@
+import assert from 'node:assert/strict'
+import { shellQuotePath } from '../src/app/info.ts'
+import {
+ formatBundleUploadRunnerCommand,
+ getBundleUploadFailureRecoveryOptions,
+ joinUniqueUploadPaths,
+ mergeMonorepoRootUploadPaths,
+ MONOREPO_ROOT_PATHS_NOTE,
+ MONOREPO_UPLOAD_RETRY_HINT,
+ resolveUploadPaths,
+ withMonorepoUploadRetryHint,
+} from '../src/init/upload-recovery.ts'
+
+function escapeRegExp(value) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+assert.match(MONOREPO_ROOT_PATHS_NOTE, /monorepo\/workspace root/)
+assert.match(MONOREPO_ROOT_PATHS_NOTE, /not the app package/)
+assert.match(MONOREPO_UPLOAD_RETRY_HINT, /monorepo root package\.json/)
+assert.match(MONOREPO_UPLOAD_RETRY_HINT, /monorepo root node_modules/)
+
+const options = getBundleUploadFailureRecoveryOptions()
+assert.deepEqual(options.map(option => option.value), ['retry', 'retry-with-monorepo-paths'])
+assert.match(options[1].label, /monorepo root package\.json and node_modules/)
+assert.match(options[1].hint ?? '', /Workspace root/)
+
+assert.equal(joinUniqueUploadPaths(), undefined)
+assert.equal(joinUniqueUploadPaths(''), undefined)
+assert.equal(joinUniqueUploadPaths('/app/package.json', '/root/package.json'), '/app/package.json,/root/package.json')
+assert.equal(joinUniqueUploadPaths('/root/package.json', '/root/package.json'), '/root/package.json')
+assert.equal(joinUniqueUploadPaths('/app/package.json,/root/package.json', '/root/package.json'), '/app/package.json,/root/package.json')
+assert.equal(joinUniqueUploadPaths(undefined, ' ./node_modules , /root/node_modules '), './node_modules,/root/node_modules')
+
+const promptCwd = '/workspace/app'
+assert.equal(resolveUploadPaths(undefined, promptCwd), undefined)
+assert.equal(resolveUploadPaths('./package.json', promptCwd), '/workspace/app/package.json')
+assert.equal(resolveUploadPaths('./package.json,./apps/mobile/package.json', promptCwd), '/workspace/app/package.json,/workspace/app/apps/mobile/package.json')
+assert.equal(resolveUploadPaths('/already/absolute/package.json', promptCwd), '/already/absolute/package.json')
+assert.equal(resolveUploadPaths('./node_modules', promptCwd), '/workspace/app/node_modules')
+
+const packageJson = '/Users/a/My Project/package.json'
+const nodeModules = '/Users/a/My Project/node_modules'
+assert.equal(shellQuotePath(packageJson, 'linux'), '\'/Users/a/My Project/package.json\'')
+assert.equal(shellQuotePath(packageJson, 'win32'), '"/Users/a/My Project/package.json"')
+assert.equal(shellQuotePath(nodeModules, 'linux'), '\'/Users/a/My Project/node_modules\'')
+assert.equal(shellQuotePath(nodeModules, 'win32'), '"/Users/a/My Project/node_modules"')
+
+const uploadCommand = formatBundleUploadRunnerCommand('npx -y', 'com.example.app', {
+ bundle: '1.0.1',
+ channel: 'production',
+ packageJson,
+ nodeModules,
+})
+assert.match(uploadCommand, new RegExp(`--package-json ${escapeRegExp(shellQuotePath(packageJson))}`))
+assert.match(uploadCommand, new RegExp(`--node-modules ${escapeRegExp(shellQuotePath(nodeModules))}`))
+
+assert.equal(withMonorepoUploadRetryHint(''), MONOREPO_UPLOAD_RETRY_HINT)
+assert.equal(
+ withMonorepoUploadRetryHint('Missing dependencies or invalid dependencies'),
+ `Missing dependencies or invalid dependencies\n${MONOREPO_UPLOAD_RETRY_HINT}`,
+)
+assert.equal(
+ withMonorepoUploadRetryHint(`already hinted\n${MONOREPO_UPLOAD_RETRY_HINT}`),
+ `already hinted\n${MONOREPO_UPLOAD_RETRY_HINT}`,
+)
+
+assert.deepEqual(
+ mergeMonorepoRootUploadPaths(
+ { packageJson: './package.json', nodeModules: './node_modules' },
+ { packageJson: './apps/mobile/package.json', nodeModules: './apps/mobile/node_modules' },
+ promptCwd,
+ ),
+ {
+ packageJson: '/workspace/app/package.json,/workspace/app/apps/mobile/package.json',
+ nodeModules: '/workspace/app/node_modules,/workspace/app/apps/mobile/node_modules',
+ },
+)
+assert.deepEqual(
+ mergeMonorepoRootUploadPaths(
+ { packageJson: '/root/package.json', nodeModules: '/root/node_modules' },
+ { packageJson: '/root/package.json', nodeModules: '/root/node_modules' },
+ promptCwd,
+ ),
+ {
+ packageJson: '/root/package.json',
+ nodeModules: '/root/node_modules',
+ },
+)
+assert.deepEqual(
+ mergeMonorepoRootUploadPaths(
+ { packageJson: './package.json', nodeModules: undefined },
+ { packageJson: undefined, nodeModules: './node_modules' },
+ promptCwd,
+ ),
+ {
+ packageJson: '/workspace/app/package.json',
+ nodeModules: '/workspace/app/node_modules',
+ },
+)
+
+console.log('β
init upload monorepo recovery tests passed')
diff --git a/cli/test/test-ios-provisioning-command.mjs b/cli/test/test-ios-provisioning-command.mjs
new file mode 100644
index 0000000000..7e9a27bc20
--- /dev/null
+++ b/cli/test/test-ios-provisioning-command.mjs
@@ -0,0 +1,488 @@
+#!/usr/bin/env node
+
+import assert from 'node:assert/strict'
+import { Buffer } from 'node:buffer'
+import { spawnSync } from 'node:child_process'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { makeProfileXml } from './prescan/helpers.ts'
+import { DuplicateProfileError, runIosProvisioningCommand } from '../src/build/ios-provisioning-command.ts'
+
+let passed = 0
+
+async function test(name, fn) {
+ try {
+ await fn()
+ passed++
+ console.log(`β
PASSED: ${name}`)
+ }
+ catch (error) {
+ console.error(`β FAILED: ${name}`)
+ throw error
+ }
+}
+
+function profile(bundleId, name = 'Test Profile') {
+ return Buffer.from(makeProfileXml({ bundleId }).replace('Test Profile', `${name}`)).toString('base64')
+}
+
+function map(entries) {
+ return JSON.stringify(entries)
+}
+
+function appTarget(bundleId = 'com.example.app') {
+ return { name: 'App', bundleId, productType: 'com.apple.product-type.application' }
+}
+
+function widgetTarget(bundleId = 'com.example.app.widget') {
+ return { name: 'Widget', bundleId, productType: 'com.apple.product-type.app-extension' }
+}
+
+function iosCredentials(provisioningMap, extra = {}) {
+ return { ios: { CAPGO_IOS_PROVISIONING_MAP: provisioningMap, ...extra } }
+}
+
+function generationCredentials(provisioningMap, extra = {}) {
+ return iosCredentials(provisioningMap, {
+ APPLE_KEY_ID: 'KEY1234567',
+ APPLE_ISSUER_ID: '11111111-2222-3333-4444-555555555555',
+ APPLE_KEY_CONTENT: Buffer.from('test-p8-pem').toString('base64'),
+ BUILD_CERTIFICATE_BASE64: 'test-p12-base64',
+ ...extra,
+ })
+}
+
+function baseDeps(overrides = {}) {
+ const state = { prompts: [], writes: [], logs: [], appleCalls: [] }
+ const deps = {
+ loadProject: async () => ({ appId: 'com.example.app', targets: [appTarget()] }),
+ loadStores: async () => ({
+ local: iosCredentials(map({ 'com.example.app': profile('com.example.app') })),
+ global: null,
+ }),
+ persistMap: async (appId, source, value) => state.writes.push({ appId, source, value: structuredClone(value) }),
+ canPrompt: () => true,
+ confirm: async (message) => {
+ state.prompts.push(message)
+ return true
+ },
+ logInfo: message => state.logs.push(message),
+ generateJwt: () => {
+ state.appleCalls.push('generateJwt')
+ return 'token'
+ },
+ verifyApiKey: async () => state.appleCalls.push('verifyApiKey'),
+ openP12: () => {
+ state.appleCalls.push('openP12')
+ return { sha1: 'a'.repeat(40) }
+ },
+ findCertBySha1: async () => {
+ state.appleCalls.push('findCertBySha1')
+ return { id: 'cert-id' }
+ },
+ ensureBundleId: async () => {
+ state.appleCalls.push('ensureBundleId')
+ return { bundleIdResourceId: 'bundle-id' }
+ },
+ createProfile: async () => {
+ state.appleCalls.push('createProfile')
+ return { profileId: 'profile-id', profileName: 'Created', profileContent: profile('com.example.app') }
+ },
+ deleteProfile: async () => state.appleCalls.push('deleteProfile'),
+ ...overrides,
+ }
+ return { deps, state }
+}
+
+await test('rejects an empty app id, no targets, and unresolved target bundle ids before store reads', async () => {
+ for (const [project, expected] of [
+ [{ appId: '', targets: [appTarget()] }, /app id/i],
+ [{ appId: 'com.example.app', targets: [] }, /signable/i],
+ [{ appId: 'com.example.app', targets: [appTarget('$(PRODUCT_BUNDLE_IDENTIFIER)')] }, /resolve.*bundle id/i],
+ ]) {
+ let storeReads = 0
+ const { deps } = baseDeps({
+ loadProject: async () => project,
+ loadStores: async () => {
+ storeReads++
+ return { local: null, global: null }
+ },
+ })
+ await assert.rejects(runIosProvisioningCommand({}, deps), expected)
+ assert.equal(storeReads, 0)
+ }
+})
+
+await test('uses the Capacitor app id for source selection and follows shared split-store rules', async () => {
+ let loadedAppId
+ const exact = iosCredentials(map({ 'com.example.app': profile('com.example.app') }))
+ const { deps } = baseDeps({
+ loadStores: async (appId) => {
+ loadedAppId = appId
+ return { local: exact, global: exact }
+ },
+ })
+ await assert.rejects(runIosProvisioningCommand({}, deps), /pass --local or --global/i)
+ assert.equal(loadedAppId, 'com.example.app')
+ await runIosProvisioningCommand({ local: true }, deps)
+
+ let storeReads = 0
+ const invalid = baseDeps({
+ loadStores: async () => {
+ storeReads++
+ return { local: exact, global: exact }
+ },
+ })
+ await assert.rejects(runIosProvisioningCommand({ local: true, global: true }, invalid.deps), /cannot use --local and --global together/i)
+ assert.equal(storeReads, 0)
+})
+
+await test('requires existing iOS credentials and a valid nonempty provisioning map', async () => {
+ for (const [saved, expected] of [
+ [{ android: { ANDROID_KEYSTORE_FILE: 'store' } }, /iOS Builder credentials/i],
+ [{ ios: {} }, /No saved Builder credentials/i],
+ [{ ios: { BUILD_CERTIFICATE_BASE64: 'cert' } }, /provisioning profile map.*saved/i],
+ [iosCredentials('{}'), /no profiles/i],
+ [iosCredentials('{broken'), /valid JSON/i],
+ [iosCredentials(map({ bad: 'not-a-profile' })), /bad.*invalid/i],
+ ]) {
+ const { deps } = baseDeps({ loadStores: async () => ({ local: saved, global: null }) })
+ await assert.rejects(runIosProvisioningCommand({}, deps), expected)
+ }
+})
+
+await test('rejects ad hoc but ignores app-specific passwords when exact coverage is complete', async () => {
+ const exactMap = map({ 'com.example.app': profile('com.example.app') })
+ const adHoc = baseDeps({
+ loadStores: async () => ({ local: iosCredentials(exactMap, { CAPGO_IOS_DISTRIBUTION: 'ad_hoc' }), global: null }),
+ })
+ await assert.rejects(runIosProvisioningCommand({}, adHoc.deps), /ad hoc.*not supported/i)
+
+ const complete = baseDeps({
+ loadStores: async () => ({
+ local: iosCredentials(exactMap, { APPLE_APP_SPECIFIC_PASSWORD: 'ignored', APPLE_APP_ID: 'ignored' }),
+ global: null,
+ }),
+ })
+ await runIosProvisioningCommand({}, complete.deps)
+ assert.equal(complete.state.prompts.length, 0)
+ assert.equal(complete.state.appleCalls.length, 0)
+ assert.match(complete.state.logs.at(-1), /all iOS targets/i)
+})
+
+await test('confirms one wildcard reuse and persists exact canonical entries in one write', async () => {
+ const wildcard = profile('com.example.*', 'Wildcard Profile')
+ const { deps, state } = baseDeps({
+ loadProject: async () => ({ appId: 'com.example.app', targets: [appTarget(), widgetTarget()] }),
+ loadStores: async () => ({ local: iosCredentials(map({ wildcard })), global: null }),
+ })
+ await runIosProvisioningCommand({}, deps)
+
+ assert.equal(state.prompts.length, 1)
+ assert.equal(state.prompts[0], `Update the provisioning profile map so these targets reuse "Wildcard Profile"?
+
+ β’ App
+ Bundle ID: com.example.app
+ β’ Widget
+ Bundle ID: com.example.app.widget`)
+ assert.equal(state.writes.length, 1)
+ assert.equal(state.writes[0].source, 'local')
+ assert.deepEqual(state.writes[0].value['com.example.app'], { profile: wildcard, name: 'Wildcard Profile' })
+ assert.deepEqual(state.writes[0].value['com.example.app.widget'], { profile: wildcard, name: 'Wildcard Profile' })
+ assert.equal(state.appleCalls.length, 0)
+})
+
+await test('requires an interactive terminal when wildcard confirmation is needed', async () => {
+ const { deps, state } = baseDeps({
+ loadProject: async () => ({ appId: 'com.example.app', targets: [widgetTarget()] }),
+ loadStores: async () => ({ local: iosCredentials(map({ wildcard: profile('*') })), global: null }),
+ canPrompt: () => false,
+ })
+ await assert.rejects(runIosProvisioningCommand({}, deps), /interactive terminal/i)
+ assert.equal(state.prompts.length, 0)
+ assert.equal(state.writes.length, 0)
+})
+
+await test('declining wildcard reuse falls through to dedicated generation requirements', async () => {
+ const { deps, state } = baseDeps({
+ loadProject: async () => ({ appId: 'com.example.app', targets: [widgetTarget()] }),
+ loadStores: async () => ({
+ local: iosCredentials(map({ wildcard: profile('*') }), { APPLE_APP_SPECIFIC_PASSWORD: 'not-supported' }),
+ global: null,
+ }),
+ confirm: async (message) => {
+ state.prompts.push(message)
+ return false
+ },
+ })
+ await assert.rejects(runIosProvisioningCommand({}, deps), /app-specific password.*not supported/i)
+ assert.equal(state.writes.length, 0)
+})
+
+await test('fails before prompting or writing when different wildcard profiles match', async () => {
+ const { deps, state } = baseDeps({
+ loadProject: async () => ({ appId: 'com.example.app', targets: [widgetTarget()] }),
+ loadStores: async () => ({
+ local: iosCredentials(map({ broad: profile('*', 'Broad'), prefix: profile('com.example.*', 'Prefix') })),
+ global: null,
+ }),
+ })
+ await assert.rejects(
+ runIosProvisioningCommand({}, deps),
+ /Sorry, multiple matching wildcard provisioning profiles are not supported/,
+ )
+ assert.equal(state.prompts.length, 0)
+ assert.equal(state.writes.length, 0)
+})
+
+await test('validates local p8, API access, P12, and Apple certificate before generation confirmation', async () => {
+ const events = []
+ let p12Password
+ const { deps, state } = baseDeps({
+ loadProject: async () => ({ appId: 'com.example.app', targets: [appTarget()] }),
+ loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }),
+ generateJwt: (_keyId, _issuerId, pem) => {
+ events.push(`jwt:${pem}`)
+ return 'token'
+ },
+ verifyApiKey: async () => events.push('verify'),
+ openP12: (_certificate, password) => {
+ p12Password = password
+ events.push('p12')
+ return { sha1: 'a'.repeat(40) }
+ },
+ findCertBySha1: async () => {
+ events.push('cert')
+ return { id: 'cert-id' }
+ },
+ confirm: async (message) => {
+ events.push('confirm')
+ state.prompts.push(message)
+ return false
+ },
+ })
+ await assert.rejects(runIosProvisioningCommand({}, deps), /generation was declined/i)
+ assert.equal(p12Password, '')
+ assert.deepEqual(events, ['jwt:test-p8-pem', 'verify', 'p12', 'jwt:test-p8-pem', 'cert', 'confirm'])
+ assert.equal(state.writes.length, 0)
+})
+
+await test('invalid p8 and inaccessible Apple keys fail before confirmation without leaking credential values', async () => {
+ const malformed = baseDeps({
+ loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') }), { APPLE_KEY_CONTENT: '***' }), global: null }),
+ })
+ await assert.rejects(runIosProvisioningCommand({}, malformed.deps), /saved App Store Connect \.p8 key is invalid/i)
+ assert.equal(malformed.state.appleCalls.length, 0)
+
+ const secret = Buffer.from('very-secret-p8').toString('base64')
+ const invalid = baseDeps({
+ loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') }), { APPLE_KEY_CONTENT: secret }), global: null }),
+ generateJwt: () => { throw new Error(`bad ${secret}`) },
+ })
+ let p8Error
+ try {
+ await runIosProvisioningCommand({}, invalid.deps)
+ }
+ catch (error) {
+ p8Error = error
+ }
+ assert.match(p8Error.message, /saved App Store Connect \.p8 key is invalid/i)
+ assert.doesNotMatch(p8Error.message, new RegExp(secret))
+ assert.equal(invalid.state.prompts.length, 0)
+
+ const inaccessible = baseDeps({
+ loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }),
+ verifyApiKey: async () => { throw new Error('access rejected') },
+ })
+ await assert.rejects(runIosProvisioningCommand({}, inaccessible.deps), /does not have access/i)
+ assert.equal(inaccessible.state.prompts.length, 0)
+ assert.deepEqual(inaccessible.state.appleCalls, ['generateJwt'])
+})
+
+await test('invalid P12 and an Apple certificate mismatch fail before generation confirmation', async () => {
+ const badP12 = baseDeps({
+ loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }),
+ openP12: () => { throw new Error('bad certificate') },
+ })
+ await assert.rejects(runIosProvisioningCommand({}, badP12.deps), /signing certificate or P12 password is invalid/i)
+ assert.equal(badP12.state.prompts.length, 0)
+
+ const noMatch = baseDeps({
+ loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }),
+ findCertBySha1: async () => null,
+ })
+ await assert.rejects(runIosProvisioningCommand({}, noMatch.deps), /certificate is not available.*\.p8 key/i)
+ assert.equal(noMatch.state.prompts.length, 0)
+})
+
+await test('generates targets sequentially with fresh JWTs and persists after each success', async () => {
+ const events = []
+ let jwt = 0
+ const { deps, state } = baseDeps({
+ loadProject: async () => ({ appId: 'com.example.app', targets: [appTarget(), widgetTarget()] }),
+ loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') }), { APPLE_APP_SPECIFIC_PASSWORD: 'ignored' }), global: null }),
+ generateJwt: () => `token-${++jwt}`,
+ verifyApiKey: async token => events.push(`verify:${token}`),
+ findCertBySha1: async token => {
+ events.push(`cert:${token}`)
+ return { id: 'cert-id' }
+ },
+ ensureBundleId: async (token, bundleId) => {
+ events.push(`ensure:${token}:${bundleId}`)
+ return { bundleIdResourceId: `resource-${bundleId}` }
+ },
+ createProfile: async (token, resourceId, certId, bundleId) => {
+ events.push(`create:${token}:${resourceId}:${certId}:${bundleId}`)
+ return { profileId: `profile-${bundleId}`, profileName: `Capgo ${bundleId}`, profileContent: profile(bundleId) }
+ },
+ })
+ await runIosProvisioningCommand({}, deps)
+
+ assert.equal(state.prompts.length, 1)
+ assert.equal(state.prompts[0], `Generate App Store provisioning profiles for these targets?
+
+ β’ App
+ Bundle ID: com.example.app
+ β’ Widget
+ Bundle ID: com.example.app.widget`)
+ assert.equal(state.writes.length, 2)
+ assert.ok(state.writes[0].value['com.example.app'])
+ assert.equal(state.writes[0].value['com.example.app.widget'], undefined)
+ assert.ok(state.writes[1].value['com.example.app.widget'])
+ assert.deepEqual(events, [
+ 'verify:token-1',
+ 'cert:token-2',
+ 'ensure:token-3:com.example.app',
+ 'create:token-4:resource-com.example.app:cert-id:com.example.app',
+ 'ensure:token-5:com.example.app.widget',
+ 'create:token-6:resource-com.example.app.widget:cert-id:com.example.app.widget',
+ ])
+})
+
+await test('keeps earlier persisted profiles when a later target fails', async () => {
+ let creates = 0
+ const { deps, state } = baseDeps({
+ loadProject: async () => ({ appId: 'com.example.app', targets: [appTarget(), widgetTarget()] }),
+ loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }),
+ createProfile: async (_token, _resource, _cert, bundleId) => {
+ creates++
+ if (creates === 2)
+ throw new Error('Apple create failed')
+ return { profileId: 'first', profileName: 'First', profileContent: profile(bundleId) }
+ },
+ })
+ await assert.rejects(runIosProvisioningCommand({}, deps), /could not create.*Widget/i)
+ assert.equal(state.writes.length, 1)
+ assert.ok(state.writes[0].value['com.example.app'])
+})
+
+await test('replaces only duplicate profiles after confirmation and retries creation once', async () => {
+ const duplicates = [
+ { id: 'duplicate-1', name: 'Capgo one', profileType: 'IOS_APP_STORE' },
+ { id: 'duplicate-2', name: 'Capgo two', profileType: 'IOS_APP_STORE' },
+ ]
+ let creates = 0
+ const deleted = []
+ const { deps, state } = baseDeps({
+ loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }),
+ createProfile: async (_token, _resource, _cert, bundleId) => {
+ creates++
+ if (creates === 1)
+ throw new DuplicateProfileError(duplicates)
+ return { profileId: 'replacement', profileName: 'Replacement', profileContent: profile(bundleId) }
+ },
+ deleteProfile: async (_token, id) => deleted.push(id),
+ })
+ await runIosProvisioningCommand({}, deps)
+
+ assert.equal(state.prompts.length, 2)
+ assert.match(state.prompts[1], /Capgo one.*Capgo two/s)
+ assert.deepEqual(deleted, ['duplicate-1', 'duplicate-2'])
+ assert.equal(creates, 2)
+ assert.equal(state.writes.length, 1)
+})
+
+await test('duplicate decline and replacement retry failure stop without recursive deletion', async () => {
+ const duplicate = new DuplicateProfileError([{ id: 'duplicate-1', name: 'Existing', profileType: 'IOS_APP_STORE' }])
+ let prompt = 0
+ const declined = baseDeps({
+ loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }),
+ confirm: async (message) => {
+ declined.state.prompts.push(message)
+ return ++prompt === 1
+ },
+ createProfile: async () => { throw duplicate },
+ })
+ await assert.rejects(runIosProvisioningCommand({}, declined.deps), /replacement was declined/i)
+ assert.equal(declined.state.appleCalls.includes('deleteProfile'), false)
+
+ let deletes = 0
+ const retry = baseDeps({
+ loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }),
+ createProfile: async () => { throw duplicate },
+ deleteProfile: async () => { deletes++ },
+ })
+ await assert.rejects(runIosProvisioningCommand({}, retry.deps), /were deleted.*could not be created/i)
+ assert.equal(deletes, 1)
+ assert.equal(retry.state.writes.length, 0)
+})
+
+await test('duplicate replacement requires a second interactive confirmation and reports deletion failure', async () => {
+ const duplicate = new DuplicateProfileError([{ id: 'duplicate-1', name: 'Existing', profileType: 'IOS_APP_STORE' }])
+ let promptChecks = 0
+ const noninteractive = baseDeps({
+ loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }),
+ canPrompt: () => ++promptChecks === 1,
+ createProfile: async () => { throw duplicate },
+ })
+ await assert.rejects(runIosProvisioningCommand({}, noninteractive.deps), /interactive terminal/i)
+ assert.equal(noninteractive.state.appleCalls.includes('deleteProfile'), false)
+
+ const deleteFailure = baseDeps({
+ loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }),
+ createProfile: async () => { throw duplicate },
+ deleteProfile: async () => { throw new Error('delete failed') },
+ })
+ await assert.rejects(runIosProvisioningCommand({}, deleteFailure.deps), /could not delete all existing/i)
+ assert.equal(deleteFailure.state.writes.length, 0)
+})
+
+await test('duplicate replacement reports profiles deleted before a later deletion fails', async () => {
+ const duplicates = [
+ { id: 'duplicate-1', name: 'Capgo one', profileType: 'IOS_APP_STORE' },
+ { id: 'duplicate-2', name: 'Capgo two', profileType: 'IOS_APP_STORE' },
+ ]
+ const attemptedDeletes = []
+ const partialFailure = baseDeps({
+ loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }),
+ createProfile: async () => { throw new DuplicateProfileError(duplicates) },
+ deleteProfile: async (_token, id) => {
+ attemptedDeletes.push(id)
+ if (id === 'duplicate-2')
+ throw new Error('delete failed')
+ },
+ })
+
+ await assert.rejects(runIosProvisioningCommand({}, partialFailure.deps), (error) => {
+ assert.match(error.message, /Apple already deleted:/)
+ assert.match(error.message, /β’ Capgo one/)
+ assert.doesNotMatch(error.message, /β’ Capgo two/)
+ assert.match(error.message, /saved map was not changed/i)
+ return true
+ })
+ assert.deepEqual(attemptedDeletes, ['duplicate-1', 'duplicate-2'])
+ assert.equal(partialFailure.state.writes.length, 0)
+})
+
+await test('registers lowercase ios-provisioning help with only the supported command options', () => {
+ const cliDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+ const help = spawnSync(process.execPath, [resolve(cliDir, 'src/index.ts'), 'build', 'credentials', 'ios-provisioning', '--help'], { encoding: 'utf8' })
+ assert.equal(help.status, 0, help.stderr)
+ assert.match(help.stdout, /Usage: @capgo\/cli build credentials ios-provisioning \[options\]/)
+ assert.match(help.stdout, /--local/)
+ assert.match(help.stdout, /--global/)
+ assert.match(help.stdout, /npx @capgo\/cli@latest build credentials ios-provisioning/)
+ assert.doesNotMatch(help.stdout, /--app-?[iI]d|--yes/)
+})
+
+console.log(`\nβ
iOS provisioning command tests passed (${passed})`)
diff --git a/cli/test/test-ios-provisioning-map.mjs b/cli/test/test-ios-provisioning-map.mjs
new file mode 100644
index 0000000000..df7e380837
--- /dev/null
+++ b/cli/test/test-ios-provisioning-map.mjs
@@ -0,0 +1,157 @@
+#!/usr/bin/env node
+
+import assert from 'node:assert/strict'
+import { makeProfileXml } from './prescan/helpers.ts'
+import {
+ analyzeProvisioningCoverage,
+ parseProvisioningMap,
+ ProvisioningMapError,
+} from '../src/build/ios-provisioning-map.ts'
+
+let passed = 0
+
+function test(name, fn) {
+ try {
+ fn()
+ passed++
+ console.log(`β
PASSED: ${name}`)
+ }
+ catch (error) {
+ console.error(`β FAILED: ${name}`)
+ throw error
+ }
+}
+
+function profile(bundleId, name = 'Test Profile') {
+ const xml = makeProfileXml({ bundleId }).replace('Test Profile', `${name}`)
+ return Buffer.from(xml).toString('base64')
+}
+
+function target(name, bundleId) {
+ return { name, bundleId, productType: 'com.apple.product-type.app-extension' }
+}
+
+function mapJson(entries) {
+ return JSON.stringify(entries)
+}
+
+test('distinguishes missing, empty, malformed, and invalid maps', () => {
+ for (const [raw, code] of [
+ [undefined, 'missing'],
+ [mapJson({}), 'empty'],
+ ['{broken', 'malformed'],
+ [mapJson({ app: { profile: 'not-a-profile', name: 'Wrong' } }), 'invalid'],
+ ]) {
+ assert.throws(
+ () => parseProvisioningMap(raw),
+ error => error instanceof ProvisioningMapError && error.code === code,
+ )
+ }
+})
+
+test('canonicalizes legacy and object entries with the embedded profile name', () => {
+ const appProfile = profile('com.example.app', 'Canonical App')
+ const widgetProfile = profile('com.example.widget', 'Canonical Widget')
+ const parsed = parseProvisioningMap(mapJson({
+ 'com.example.app': appProfile,
+ 'com.example.widget': { profile: widgetProfile, name: 'Stale name' },
+ }))
+
+ assert.deepEqual({ ...parsed }, {
+ 'com.example.app': { profile: appProfile, name: 'Canonical App' },
+ 'com.example.widget': { profile: widgetProfile, name: 'Canonical Widget' },
+ })
+ assert.equal(parsed['com.example.app'].bundleId, 'com.example.app')
+ assert.equal(parsed['com.example.widget'].bundleId, 'com.example.widget')
+ assert.equal(JSON.stringify(parsed), mapJson({
+ 'com.example.app': { profile: appProfile, name: 'Canonical App' },
+ 'com.example.widget': { profile: widgetProfile, name: 'Canonical Widget' },
+ }))
+})
+
+test('uses exact map keys for coverage and groups duplicate target bundle IDs', () => {
+ const shared = profile('com.example.widget')
+ const map = parseProvisioningMap(mapJson({ 'some-other-key': shared }))
+ const coverage = analyzeProvisioningCoverage([
+ target('Widget One', 'com.example.widget'),
+ target('Widget Two', 'com.example.widget'),
+ ], map)
+
+ assert.equal(coverage.exact.length, 0)
+ assert.deepEqual(coverage.missing, [{ bundleId: 'com.example.widget', targetNames: ['Widget One', 'Widget Two'] }])
+})
+
+test('returns unresolved build-setting bundle IDs separately', () => {
+ const coverage = analyzeProvisioningCoverage([
+ target('Resolved', 'com.example.app'),
+ target('Missing setting', '$(WIDGET_BUNDLE_ID)'),
+ target('Blank', ''),
+ ], parseProvisioningMap(mapJson({ 'com.example.app': profile('com.example.app') })))
+
+ assert.deepEqual(coverage.exact.map(item => item.bundleId), ['com.example.app'])
+ assert.deepEqual(coverage.unresolved.map(item => item.name), ['Missing setting', 'Blank'])
+ assert.equal(coverage.missing.length, 0)
+})
+
+test('matches universal and prefix wildcards only against eligible missing targets', () => {
+ const prefix = profile('com.example.*', 'Prefix Wildcard')
+ const coverage = analyzeProvisioningCoverage([
+ target('Exact App', 'com.example.app'),
+ target('Widget', 'com.example.widget'),
+ target('Other', 'org.other.extension'),
+ ], parseProvisioningMap(mapJson({
+ 'com.example.app': { profile: profile('com.example.app'), name: 'Exact' },
+ wildcard: { profile: prefix, name: 'Ignored' },
+ })))
+
+ assert.deepEqual(coverage.exact.map(item => item.bundleId), ['com.example.app'])
+ assert.deepEqual(coverage.wildcardReuse?.targets.map(item => item.bundleId), ['com.example.widget'])
+ assert.deepEqual(coverage.generation.map(item => item.bundleId), ['org.other.extension'])
+
+ const universal = analyzeProvisioningCoverage(
+ [target('Other', 'org.other.extension')],
+ parseProvisioningMap(mapJson({ wildcard: profile('*', 'Universal') })),
+ )
+ assert.deepEqual(universal.wildcardReuse?.targets.map(item => item.bundleId), ['org.other.extension'])
+})
+
+test('deduplicates identical wildcard bytes stored under multiple keys', () => {
+ const wildcard = profile('com.example.*', 'Shared Wildcard')
+ const coverage = analyzeProvisioningCoverage(
+ [target('Widget', 'com.example.widget')],
+ parseProvisioningMap(mapJson({ first: wildcard, second: { profile: wildcard, name: 'Other' } })),
+ )
+
+ assert.equal(coverage.wildcardConflict.length, 0)
+ assert.deepEqual(coverage.wildcardReuse?.sourceKeys, ['first', 'second'])
+})
+
+test('reports different matching wildcard profiles as an unsupported conflict', () => {
+ const coverage = analyzeProvisioningCoverage(
+ [target('Widget', 'com.example.widget')],
+ parseProvisioningMap(mapJson({
+ broad: profile('*', 'Broad'),
+ prefix: profile('com.example.*', 'Prefix'),
+ })),
+ )
+
+ assert.equal(coverage.wildcardReuse, null)
+ assert.deepEqual(coverage.wildcardConflict.map(item => item.bundleId), ['com.example.widget'])
+ assert.equal(coverage.generation.length, 0)
+})
+
+test('does not reuse a wildcard for a target with an exact key', () => {
+ const coverage = analyzeProvisioningCoverage(
+ [target('App', 'com.example.app')],
+ parseProvisioningMap(mapJson({
+ 'com.example.app': profile('com.example.app', 'Exact'),
+ wildcard: profile('*', 'Wildcard'),
+ })),
+ )
+
+ assert.deepEqual(coverage.exact.map(item => item.bundleId), ['com.example.app'])
+ assert.equal(coverage.wildcardReuse, null)
+ assert.equal(coverage.missing.length, 0)
+})
+
+console.log(`\nβ
iOS provisioning map tests passed (${passed})`)
diff --git a/cli/test/test-mcp.mjs b/cli/test/test-mcp.mjs
index 51d66745f7..c8d9a677e9 100644
--- a/cli/test/test-mcp.mjs
+++ b/cli/test/test-mcp.mjs
@@ -68,6 +68,7 @@ try {
'capgo_upload_bundle',
'capgo_update_channel',
'capgo_get_stats',
+ 'capgo_observe',
'capgo_login',
'capgo_whoami',
'capgo_logout',
diff --git a/cli/test/test-notify-app-ready-background.mjs b/cli/test/test-notify-app-ready-background.mjs
new file mode 100644
index 0000000000..8494600a03
--- /dev/null
+++ b/cli/test/test-notify-app-ready-background.mjs
@@ -0,0 +1,722 @@
+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(`