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(``, '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-loader img { + width: 150px; + height: 150px; + filter: var(--app-loader-logo-filter); + } + + + + +
+ +
+
+ diff --git a/knip.json b/knip.json index 37195cf15d..4c7d0bfc39 100644 --- a/knip.json +++ b/knip.json @@ -65,6 +65,7 @@ "cli": { "entry": [ "src/index.ts!", + "src/notify-app-ready-worker.ts!", "src/sdk.ts!", "src/mcp/server.ts!" ], diff --git a/messages/en.context.json b/messages/en.context.json index 82fee5cfa4..830abd915c 100644 --- a/messages/en.context.json +++ b/messages/en.context.json @@ -82,6 +82,7 @@ "action-app-memory-warning": "Used in Capgo web console areas: services. Role: device or update event label in logs/analytics. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "action-app-moved-to-background": "Used in Capgo web console areas: services. Role: device or update event label in logs/analytics. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "action-app-moved-to-foreground": "Used in Capgo web console areas: services. Role: device or update event label in logs/analytics. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "action-app-nav": "Used in Capgo web console areas: services. Role: device or update event label in logs/analytics. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "action-backend-refusal": "Used in Capgo web console areas: services. Role: device or update event label in logs/analytics. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "action-blocked-by-server-url": "Used in Capgo web console areas: services. Role: device or update event label in logs/analytics. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "action-cannot-get-bundle": "Used in Capgo web console areas: services. Role: device or update event label in logs/analytics. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -188,13 +189,115 @@ "add-channel-role": "Used in Capgo web console areas: components/permissions. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "add-member": "Used in Capgo web console. Role: UI label about \"add member\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "add-members": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "add-one": "Used in Capgo web console areas: components. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "add-one": "Used in Capgo web console areas: components, components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "add-organization": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "add-webhook": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "add-your-first-app-t": "Used in Capgo web console areas: components/dashboard, pages. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "add-your-first-app-to-see-dashboard": "Used in Capgo web console areas: pages. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "add-your-first-bundle": "Used in Capgo web console areas: components/dashboard, components/tables. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "admin": "Used in Capgo web console areas: pages, services, types. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-question-flow-answered": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-question-flow-continuation-note": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-question-flow-continued": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-question-flow-counting-note": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-question-flow-description": "Used in Capgo web console areas: components/admin. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-question-flow-did-not-continue": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-question-flow-empty": "Used in Capgo web console areas: components/admin. Role: empty state text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-question-flow-excluded-note": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-question-flow-no-answer": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-question-flow-percent-group": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-question-flow-percent-reached": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-question-flow-reached": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-question-flow-skipped": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-question-flow-title": "Used in Capgo web console areas: components/admin. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-question-flow-unavailable": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests": "Used in Capgo web console areas: components/admin, constants, pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-assignments": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-animation-description": "Used in Capgo web console areas: components/admin. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-animation-progress": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-animation-title": "Used in Capgo web console areas: components/admin. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-cohort": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-cohort-automatic": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-cohort-reduced-motion": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-cohort-replay": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-cohort-title": "Used in Capgo web console areas: components/admin. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-cohort-unavailable": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-complete-short": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-completed": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-completion": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-continued": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-continued-short": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-description": "Used in Capgo web console areas: components/admin. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-diagnostic-note": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-difference": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-interrupted": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-median-skip": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-median-watch": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-minutes-seconds": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-pending": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-posthog-unavailable": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-posthog-unconfigured": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-reached-short": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-replays": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-retention-aria": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-retention-description": "Used in Capgo web console areas: components/admin. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-retention-title": "Used in Capgo web console areas: components/admin. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-seconds": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-skip-bucket": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-skip-short": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-skipped": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-skips": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-small-sample": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-stage-console-assign": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-stage-metrics": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-stage-routing": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-stage-selector": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-stage-self-assign": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-started": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-status-collecting": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-status-collecting-description": "Used in Capgo web console areas: components/admin. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-status-confidence": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-status-control-ahead": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-status-inconclusive": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-status-treatment-ahead": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-title": "Used in Capgo web console areas: components/admin. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-updated": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-users": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-viewer-count": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-channel-viewers": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-control": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-description": "Used in Capgo web console areas: components/admin. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-development-environment-ai-assistant": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-development-environment-ai-assistant-note": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-development-environment-assigned": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-development-environment-cohort": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-development-environment-description": "Used in Capgo web console areas: components/admin. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-development-environment-empty": "Used in Capgo web console areas: components/admin. Role: empty state text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-development-environment-hand-coded": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-development-environment-hosted-builder": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-development-environment-no-selection-note": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-development-environment-no-selection-yet": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-development-environment-other": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-development-environment-title": "Used in Capgo web console areas: components/admin. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-empty": "Used in Capgo web console areas: components/admin. Role: empty state text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-environment-intent-description": "Used in Capgo web console areas: components/admin. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-environment-intent-empty": "Used in Capgo web console areas: components/admin. Role: empty state text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-environment-intent-group": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-environment-intent-note": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-environment-intent-people": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-environment-intent-title": "Used in Capgo web console areas: components/admin. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-experiment": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-load-error": "Used in Capgo web console areas: pages/admin/dashboard. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-publish-outcome-cohort": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-publish-outcome-description": "Used in Capgo web console areas: components/admin. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-publish-outcome-exposed": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-publish-outcome-inferred-note": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-publish-outcome-no-selection-yet": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-publish-outcome-selected-another-intent": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-publish-outcome-selected-publish": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-publish-outcome-title": "Used in Capgo web console areas: components/admin. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-total": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-ab-tests-treatment": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "admin-app-build-onboarding-chart-title": "Used in platform admin dashboard. Role: section or dialog title about \"admin app build onboarding chart title\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "admin-app-build-onboarding-cli-builds": "Used in platform admin dashboard. Role: UI label about \"admin app build onboarding cli builds\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "admin-app-build-onboarding-manual-builds": "Used in platform admin dashboard. Role: UI label about \"admin app build onboarding manual builds\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -366,7 +469,7 @@ "api-key-selected-apps-only-org-filter": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "api-key-selected-apps-only-org-filter-description": "Used in Capgo web console areas: pages. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "api-key-updated": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "api-keys": "Used in Capgo web console areas: components, pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "api-keys": "Used in Capgo web console areas: components, components/dashboard, pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "api-keys-are-used-for-cli-and-public-api": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "api-keys-hidden-by-scope-filter-many": "Used in Capgo web console areas: components. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "api-keys-legacy-title": "Used in API key management. Role: section or dialog title about \"api keys legacy title\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -391,9 +494,6 @@ "app-not-found-description": "Used in Capgo web console areas: components, components/dashboard, pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-ai-help-button": "Used in Capgo web console areas: components/dashboard. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-ai-help-caption": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "app-onboarding-ai-help-prompt": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "app-onboarding-ai-help-status-existing": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "app-onboarding-ai-help-status-new": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-ai-help-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-ai-help-with-key": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-alt-setup-summary": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -426,6 +526,7 @@ "app-onboarding-cli-step-build_project": "Used in app onboarding flow. Role: UI label about \"app onboarding cli step build_project\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-cli-step-completion": "Used in app onboarding flow. Role: short UI label about \"app onboarding cli step completion\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-cli-step-done": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "app-onboarding-cli-step-login_cli_mcp": "Used in app onboarding flow. Role: UI label about \"app onboarding cli step login_cli_mcp\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-cli-step-pending": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-cli-step-run_device": "Used in app onboarding flow. Role: UI label about \"app onboarding cli step run_device\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-cli-step-select_platform": "Used in app onboarding flow. Role: UI label about \"app onboarding cli step select_platform\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -445,7 +546,7 @@ "app-onboarding-continue-setup": "Used in Capgo web console areas: components, components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-continue-short": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-continue-to-org": "Used in app onboarding flow. Role: UI label about \"app onboarding continue to org\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "app-onboarding-dont-show-again": "Used in Capgo web console areas: components/dashboard, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "app-onboarding-dont-show-again": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-existing-no": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-existing-no-helper": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-existing-question": "Used in app onboarding flow. Role: UI sentence about \"app onboarding existing question\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -504,6 +605,7 @@ "app-onboarding-store-help": "Used in app onboarding flow. Role: helper or description text about \"app onboarding store help\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-store-import-button": "Used in Capgo web console areas: components/dashboard. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-store-imported-help": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "app-onboarding-store-imported-missing-app-id": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-store-link-label": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-store-link-placeholder": "Used in Capgo web console areas: components/dashboard. Role: input placeholder. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-store-screenshot-alt": "Used in app onboarding flow. Role: UI label about \"app onboarding store screenshot alt\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -527,7 +629,6 @@ "app-onboarding-toast-store-metadata-error": "Used in Capgo web console areas: components/dashboard. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-use-different-icon": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-use-imported-icon": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "app-onboarding-v2-ai-help-status": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-v2-appid-dialog-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-v2-appid-dialog-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-v2-appid-help": "Used in app onboarding flow. Role: helper or description text about \"app onboarding v2 appid help\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -535,11 +636,12 @@ "app-onboarding-v2-details-title": "Used in app onboarding flow. Role: section or dialog title about \"app onboarding v2 details title\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-v2-store-import-help": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-v2-store-import-toggle": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "app-perm": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "app-perm": "Used in Capgo web console areas: components/dashboard, components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-to-channel": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-transferred": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-will-be-transferred": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "apply": "Used in Capgo web console areas: components. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "apply": "Used in Capgo web console areas: components, pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "apply-rollout-percentage": "Used in Capgo web console. Role: UI label about \"apply rollout percentage\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "apps": "Used in Capgo web console areas: components, components/dashboard, components/tables, composables, layouts. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "apps-activity-trend": "Used in Capgo web console. Role: UI label about \"apps activity trend\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "apps-onboarding-by-method": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -589,6 +691,10 @@ "auth-verify-access-hint": "Used in Capgo web console areas: pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "auto-pause": "Used in Capgo web console areas: pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "auto-pause-action": "Used in Capgo web console areas: pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "auto-pause-action-placeholder": "Used in Capgo web console areas: pages/app. Role: input placeholder. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "auto-pause-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "auto-pause-info": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "auto-pause-settings-unchanged": "Used in Capgo web console areas: utils. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "automated": "Used in Capgo web console areas: components/tables. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "available-channels": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "available-in-the-san": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -604,10 +710,10 @@ "bandwidth-usage": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "base": "Used in Capgo web console areas: pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "before": "Used in Capgo web console areas: components/tables. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "beta": "Used in Capgo web console areas: components/dashboard, constants, pages, pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "beta": "Used in Capgo web console areas: components/dashboard, constants, pages, pages/app, utils. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "billed-annually-at": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "billed-monthly-at": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "billing": "Used in Capgo web console areas: components, constants, layouts. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "billing": "Used in Capgo web console areas: constants, layouts. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "billing-access-required": "Used in Capgo web console areas: layouts, pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "billing-cycle": "Used in Capgo web console areas: components/dashboard, pages/admin/dashboard, pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "billing-period": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -653,7 +759,7 @@ "build-timeout-help": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "build-timeout-invalid": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "build-timeout-label": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "builder": "Used in Capgo web console areas: components/dashboard, constants, pages/admin/dashboard, pages/onboarding, utils. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "builder": "Used in Capgo web console areas: components/dashboard, constants, pages/admin/dashboard, pages/onboarding, services. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "builder-promo-arrow": "Used in Capgo web console. Role: UI label about \"builder promo arrow\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "builder-promo-back": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "builder-promo-banner-cta": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -734,7 +840,7 @@ "bundle-to-subscribed": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "bundle-to-testflight": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "bundle_uploads": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "bundles": "Used in Capgo web console areas: components/tables, constants, layouts, stores. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "bundles": "Used in Capgo web console areas: components/dashboard, components/tables, constants, layouts, stores. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "bundles-deleted": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "bundles-deletion-key-change-warning": "Used in Capgo web console areas: pages/settings/organization. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "bundles-deletion-warning": "Used in Capgo web console areas: pages/settings/organization. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -744,7 +850,7 @@ "button-browse": "Used in Capgo web console areas: pages/settings/account, pages/settings/organization. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "button-camera": "Used in Capgo web console areas: pages/settings/account, pages/settings/organization. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "button-cancel": "Used in Capgo web console areas: components, components/dashboard, components/organization, components/organizations, components/tables. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "button-confirm": "Used in Capgo web console areas: components/dashboard, components/modals, components/tables, pages, pages/app. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "button-confirm": "Used in Capgo web console areas: components/dashboard, components/modals, components/tables, pages, pages/settings/account. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "button-delete": "Used in Capgo web console areas: components/dashboard, components/organizations, components/tables, pages/app, pages/settings/organization. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "button-deny-invite": "Used in Capgo web console areas: components/dashboard. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "button-invite": "Used in Capgo web console areas: pages/settings/organization. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -755,6 +861,9 @@ "buy-credits": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "by": "Used in Capgo web console areas: components/tables. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "by-clicking-on-them": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "cache-ttl-display": "Used in Capgo web console areas: utils. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "cache-ttl-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "cache-ttl-placeholder": "Used in Capgo web console areas: pages/app. Role: input placeholder. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "cache-ttl-seconds": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "cancel": "Used in Capgo web console areas: components, components/dashboard, components/modals, components/organization, components/organizations. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "cancel-subscriptions-and-continue": "Used in Capgo web console areas: pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -812,10 +921,12 @@ "cannot-view-plans": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "cannot_invite_user_without_account": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "capgo-updates-disabled": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "captcha": "Used in Capgo web console areas: pages, pages/settings/account, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "captcha-fail": "Used in Capgo web console areas: pages, pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "captcha": "Used in Capgo web console areas: pages, pages/settings/account, pages/settings/organization, services. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "captcha-fail": "Used in Capgo web console areas: pages, pages/settings/account, services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "captcha-not-available": "Used in Capgo web console areas: components/dashboard, pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "captcha-required": "Used in Capgo web console areas: components/dashboard, pages, pages/settings/account, pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "captcha-unavailable": "Used in the signed-in email verification send step when CAPTCHA fails or its script cannot initialize. Role: status message explaining that the user must retry the challenge before sending a code.", + "captcha-resend-unavailable": "Used on the confirmation email resend page when the CAPTCHA widget fails or cannot initialize. Role: status message explaining that the user can attempt resend or reload to retry the challenge. The server still validates CAPTCHA requirements.", "change": "Used in Capgo web console areas: components, components/admin, components/dashboard, pages/settings/account, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "change-api-key-name": "Used in API key management. Role: UI label about \"change api key name\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "change-app-organisation-owner": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -836,12 +947,61 @@ "changed-name": "Used in Capgo web console. Role: UI label about \"changed name\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "changed-password-suc": "Used in Capgo web console areas: pages/onboarding, pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "changes": "Used in Capgo web console areas: components/tables. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "channel": "Used in Capgo web console areas: app, components/permissions, components/tables, composables, layouts. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel": "Used in Capgo web console areas: app, components/dashboard, components/permissions, components/tables, composables. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-actions": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-allow-device-self-set": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-builtin": "Used in Capgo web console areas: components/tables, pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-bundle-linked": "Used in Capgo web console areas: components/tables, pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "channel-create": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-activity": "Used in channel settings. Role: UI label about \"channel console assign activity\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-activity-caption": "Used in channel settings. Role: UI sentence about \"channel console assign activity caption\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-app-dashboard-heading": "Used in application settings or app detail. Role: section or dialog title about \"channel console assign app dashboard heading\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-apps-description": "Used in channel settings. Role: helper or description text about \"channel console assign apps description\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-dashboard-description": "Used in channel settings. Role: helper or description text about \"channel console assign dashboard description\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-dashboard-heading": "Used in channel settings. Role: section or dialog title about \"channel console assign dashboard heading\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-devices-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-healthy": "Used in channel settings. Role: short UI label about \"channel console assign healthy\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-kicker": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-org": "Used in channel settings. Role: UI label about \"channel console assign org\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-plan-active": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-release-caption": "Used in channel settings. Role: UI label about \"channel console assign release caption\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-release-health": "Used in channel settings. Role: UI label about \"channel console assign release health\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-replay": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-stage-label": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-success": "Used in Capgo web console areas: components/dashboard. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-takeaway": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-console-assign-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create": "Used in Capgo web console areas: components/dashboard, components/tables, utils. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-capgo": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-channel-label": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-continue": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-created-label": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-default-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-default-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-existing-label": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-form-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-kicker": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-load-error": "Used in Capgo web console areas: components/dashboard. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-loading": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-name-help": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-name-invalid": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-name-placeholder": "Used in Capgo web console areas: components/dashboard. Role: input placeholder. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-name-required": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-name-taken": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-preview-label": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-preview-placeholder": "Used in Capgo web console areas: components/dashboard. Role: input placeholder. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-self-assign-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-self-assign-disallow-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-self-assign-disallow-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-self-assign-short": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-self-assign-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-submit": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-submit-error": "Used in Capgo web console areas: components/dashboard. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-success-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-success-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-create-onboarding-toggle-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-created": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-created-with-bundle": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-default-active": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -856,17 +1016,20 @@ "channel-disable-auto-update-under-native": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-id": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-is-public": "Used in Capgo web console areas: components/tables, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "channel-link": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-link": "Used in Capgo web console areas: components/dashboard, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-link-fail": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-linked": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-linking": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "channel-name": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-name": "Used in Capgo web console areas: components/dashboard, components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-name-placeholder": "Used in Capgo web console areas: components/tables. Role: input placeholder. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-not-compatible-with-android": "Used in channel settings. Role: UI label about \"channel not compatible with android\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-not-compatible-with-electron": "Used in channel settings. Role: UI label about \"channel not compatible with electron\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-not-compatible-with-ios": "Used in channel settings. Role: UI label about \"channel not compatible with ios\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-not-found": "Used in Capgo web console areas: components/dashboard, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-not-found-description": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-onboarding-default-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-onboarding-default-kicker": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-onboarding-default-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-override-ignored-default": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-override-retention-desc": "Used in Capgo web console areas: components. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-override-retention-help": "Used in Capgo web console areas: components. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -888,6 +1051,70 @@ "channel-permissions-principal": "Used in Capgo web console areas: components/permissions. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-permissions-role": "Used in Capgo web console areas: components/permissions. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-permissions-title": "Used in Capgo web console areas: components/tables, pages. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-capgo-response": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-channels-label": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-checking": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-default": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-device": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-device-id": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-device-request": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-kicker": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-not-assigned": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-replay": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-stage-label": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-takeaway-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-takeaway-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-update-available": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-update-question": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-routing-mockup-using-default": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-app-name": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-behind-scenes": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-beta-action": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-beta-caption": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-beta-channel": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-beta-enabled": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-capgo-validator": "Used in channel settings. Role: UI label about \"channel self assign capgo validator\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-channel-node": "Used in channel settings. Role: UI label about \"channel self assign channel node\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-channels-label": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-code-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-code-label": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-current-channel": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-current-version": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-demo-date": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-demo-temperature": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-device-node": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-device-storage-caption": "Used in channel settings. Role: UI sentence about \"channel self assign device storage caption\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-device-storage-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-device-storage-value": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-dialog-cancel": "Used in channel settings. Role: UI label about \"channel self assign dialog cancel\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-dialog-confirm": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-dialog-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-dialog-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-discover-tab": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-home-balance": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-home-tab": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-home-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-kicker": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-ota-caption": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-portfolio-label": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-portfolio-performance": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-production": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-replay": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-response-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-settings": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-stage-label": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-success": "Used in Capgo web console areas: components/dashboard. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-takeaway-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-takeaway-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-updates": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-validation-allowed": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-validation-check": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-validation-checking": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "channel-self-assign-validation-request": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-stats-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-stats-latest-snapshot-help": "Used in channel settings. Role: helper or description text about \"channel stats latest snapshot help\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "channel-stats-period-help": "Used in channel settings. Role: helper or description text about \"channel stats period help\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -924,7 +1151,7 @@ "choose-which-channel-to-link-this-bundle-to": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "clear-filters": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "clear-selection": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "cli": "Used in Capgo web console areas: components/dashboard, constants, pages/admin/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "cli": "Used in Capgo web console areas: components/dashboard, constants, pages/admin/dashboard, utils. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "cli-doc": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "cli-login-ai-caption": "Used in Capgo web console areas: pages. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "cli-login-ai-copied": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -953,7 +1180,15 @@ "cli-login-reveal-dialog-title": "Used in Capgo web console areas: pages. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "cli-login-reveal-key": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "cli-login-security-warning": "Used in Capgo web console areas: pages. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "cli-login-skipped-organizations": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "cli-login-skipped-description": "Used in Capgo web console areas: components. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "cli-login-skipped-description-empty": "Used in Capgo web console areas: components. Role: empty state text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "cli-login-skipped-hint": "Used in Capgo web console areas: components. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "cli-login-skipped-reason-invite": "Used in Capgo web console areas: components. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "cli-login-skipped-reason-role": "Used in Capgo web console areas: components. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "cli-login-skipped-reason-security": "Used in Capgo web console areas: components. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "cli-login-skipped-reasons-intro": "Used in Capgo web console areas: components. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "cli-login-skipped-refresh": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "cli-login-skipped-title": "Used in Capgo web console areas: components. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "cli-login-success-title": "Used in Capgo web console areas: pages. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "cli-login-title": "Used in Capgo web console areas: pages. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "cli-login-waiting": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1026,16 +1261,38 @@ "completed-update-download": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "completed-update-download-within-7-days": "Used in Capgo web console areas: pages/admin/dashboard. Role: filter or date-range option label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "confidence": "Used in Capgo web console areas: pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confidence-placeholder": "Used in Capgo web console areas: pages/app. Role: input placeholder. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "configured": "Used in Capgo web console areas: pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "confirm": "Used in Capgo web console areas: components/modals, components/tables, composables, pages/app, pages/settings/organization. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "confirm-action": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-action": "Used in Capgo web console. Role: UI sentence about \"confirm action\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-auto-pause-settings-description": "Used in Capgo web console areas: utils. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-auto-pause-settings-title": "Used in Capgo web console areas: utils. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "confirm-email-sent": "Used in Capgo web console areas: pages. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-email-send-failed": "Used on the confirmation email resend page when a request throws without an error message. Role: error message asking the user to retry sending the confirmation email.", + "confirm-enable-rollout-description": "Used in Capgo web console areas: utils. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-enable-rollout-title": "Used in Capgo web console areas: utils. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "confirm-overwrite": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "confirm-overwrite-msg": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "confirm-password": "Used in Capgo web console areas: pages, pages/onboarding, pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-pause-rollout-description": "Used in Capgo web console areas: utils. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-pause-rollout-title": "Used in Capgo web console areas: utils. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-promote-rollout-description": "Used in Capgo web console areas: utils. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-promote-rollout-title": "Used in Capgo web console areas: utils. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-resume-rollout-description": "Used in Capgo web console areas: utils. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-resume-rollout-title": "Used in Capgo web console areas: utils. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "confirm-rollback-desc": "Used in Capgo web console areas: components/tables. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-rollback-rollout-description": "Used in Capgo web console areas: utils. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-rollback-rollout-title": "Used in Capgo web console areas: utils. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-rollout-percentage-description": "Used in Capgo web console areas: utils. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-rollout-percentage-title": "Used in Capgo web console areas: utils. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-rollout-settings-description": "Used in Capgo web console areas: utils. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-rollout-settings-title": "Used in Capgo web console areas: utils. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-set-rollout-target-description": "Used in Capgo web console areas: utils. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-set-rollout-target-title": "Used in Capgo web console areas: utils. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "confirm-switch-to-billing-period-for-cumulative": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "confirm-transfer": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-update-package-description": "Used in Capgo web console areas: utils. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "confirm-update-package-title": "Used in Capgo web console areas: utils. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "connect-admin-note": "Used in Capgo web console areas: pages. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "connect-allow-org-create": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "connect-allow-org-create-hint": "Used in Capgo web console areas: pages. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1073,11 +1330,12 @@ "connect-token-name": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "connect-token-name-default": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "consider-pushing-update": "Used in Capgo web console. Role: UI sentence about \"consider pushing update\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "continue": "Used in Capgo web console areas: components/dashboard, modules, pages, pages/app, services. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "continue": "Used in Capgo web console areas: components/dashboard, composables, modules, pages, pages/app. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "continue-with-sso": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "cooldown-minutes": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "cooldown-minutes-placeholder": "Used in Capgo web console areas: pages/app. Role: input placeholder. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "copied-to-clipboard": "Used in Capgo web console areas: components, components/dashboard, components/tables, pages/app, pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "copy": "Used in Capgo web console areas: components/tables, pages. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "copy": "Used in Capgo web console areas: components/tables, pages, pages/app. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "copy-account-id": "Used in Capgo web console areas: pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "copy-and-close": "Used in Capgo web console areas: services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "copy-command": "Used in Capgo web console areas: components/dashboard, pages, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1087,7 +1345,7 @@ "copy-organization-id": "Used in Capgo web console areas: components, pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "copy-secret": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "copy-success": "Used in Capgo web console areas: pages/app. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "country": "Used in Capgo web console areas: pages/app, pages/settings/account. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "country": "Used in Capgo web console areas: components/dashboard, pages/app, pages/settings/account. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "create": "Used in Capgo web console areas: components, pages, pages/settings/organization. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "create-a-channel-first-to-link-bundles": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "create-a-free-account": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1113,7 +1371,7 @@ "credit-transaction-occurred-at": "Used in Capgo web console areas: pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "credit-transaction-purchase": "Used in Capgo web console areas: pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "credit-transaction-refund": "Used in Capgo web console areas: pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "credits": "Used in Capgo web console areas: components, constants, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "credits": "Used in Capgo web console areas: constants, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "credits-auto-top-up-add-card": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "credits-auto-top-up-description": "Used in Capgo web console areas: pages/settings/organization. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "credits-auto-top-up-label": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1142,7 +1400,7 @@ "credits-load-error": "Used in Capgo web console areas: pages/settings/organization. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "credits-next-expiration": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "credits-only-badge": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "credits-only-banner": "Used in Capgo web console areas: components, pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "credits-only-banner": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "credits-only-info-description": "Used in Capgo web console areas: components. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "credits-only-info-link": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "credits-only-info-title": "Used in Capgo web console areas: components. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1178,7 +1436,7 @@ "credits-used-dollars-description": "Used in Capgo web console areas: pages/settings/organization. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "credits-used-in-period": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "cumulative": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "current": "Used in Capgo web console areas: components/tables, pages/app. Role: filter or date-range option label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "current": "Used in Capgo web console areas: components/tables, pages/app, services. Role: filter or date-range option label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "current-average-delay": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "current-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "current-channel-version": "Used in channel settings. Role: UI label about \"current channel version\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1202,14 +1460,14 @@ "dashboard-overview-kicker": "Used in Capgo web console. Role: UI label about \"dashboard overview kicker\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "dashboard-overview-thirty-day-copy": "Used in Capgo web console. Role: UI sentence about \"dashboard overview thirty day copy\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "dashboard-overview-title": "Used in Capgo web console. Role: section or dialog title about \"dashboard overview title\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "dashboard-tab-installs": "Used in Capgo web console areas: constants. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "dashboard-tab-usage": "Used in Capgo web console areas: constants, pages. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "dashboard-tab-installs": "Used in Capgo web console areas: components/dashboard, constants. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "dashboard-tab-usage": "Used in Capgo web console areas: components/dashboard, constants, pages. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "date": "Used in Capgo web console areas: components/dashboard, components/tables, pages/admin/dashboard, pages/app, services. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "date-number-format": "Used in Capgo web console areas: pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "date-number-format-help": "Used in Capgo web console areas: pages/settings/account. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "date-range": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "day": "Used in Capgo web console areas: components/admin, components/tables, pages, pages/admin/dashboard, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "days": "Used in Capgo web console areas: pages/admin/dashboard, pages/app, services. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "days": "Used in Capgo web console areas: pages/admin/dashboard, pages/app, services, utils. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "days-ago": "Used in Capgo web console areas: services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "days-remaining": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "debug-api-description": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1218,7 +1476,7 @@ "debug-channel-api-request": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "debug-channel-api-tip": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "debug-channel-api-warning": "Used in Capgo web console areas: pages/app. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "default-channel": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "default-channel": "Used in Capgo web console areas: components/dashboard, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "default-download-channel": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "default-download-channel-android-only-desc": "Used in channel settings. Role: helper or description text about \"default download channel android only desc\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "default-download-channel-android-only-empty": "Used in channel settings. Role: empty state text about \"default download channel android only empty\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1324,12 +1582,12 @@ "deployment-success": "Used in Capgo web console areas: components/dashboard. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "deployment-time": "Used in Capgo web console. Role: UI label about \"deployment time\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "deployment_statistics": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "deployments": "Used in Capgo web console areas: constants, pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "description": "Used in Capgo web console areas: components/modals, components/organization, components/tables, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "deployments": "Used in Capgo web console areas: components/dashboard, constants, pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "description": "Used in Capgo web console areas: components/modals, components/organization, components/tables, pages/settings/organization, services. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "detailed-usage-plan": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "details": "Used in Capgo web console areas: components/admin, components/dashboard, components/tables, pages, pages/admin/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "device": "Used in Capgo web console areas: app, layouts, pages/app, stores. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "device-id": "Used in Capgo web console areas: components/tables, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "device": "Used in Capgo web console areas: app, components/dashboard, layouts, pages/app, stores. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "device-id": "Used in Capgo web console areas: components/dashboard, components/tables, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "device-id-placeholder": "Used in Capgo web console areas: pages/app. Role: input placeholder. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "device-injected": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "device-injected-2": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1360,12 +1618,12 @@ "devices-updated": "Used in Capgo web console. Role: UI label about \"devices updated\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "devices-will-appear-here": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "dialog-with-custom-input": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "disable": "Used in Capgo web console areas: pages/app, pages/settings/account, pages/settings/organization. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "disable": "Used in Capgo web console areas: pages/settings/account, pages/settings/organization. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "disable-auto-downgra": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "disable-capgo-updates": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "disable-capgo-updates-desc": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "disableAutoUpdateToMajor": "Used in Capgo web console areas: pages/app, services, types. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "disabled": "Used in Capgo web console areas: components, components/bundle, components/forms, components/organizations, pages. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "disabled": "Used in Capgo web console areas: components, components/bundle, components/dashboard, components/forms, components/organizations. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "disabled-capgo-updates": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "discord": "Used in Capgo web console areas: components, pages/admin/dashboard, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "discord-username": "Used in Capgo web console areas: components/dashboard, pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1380,7 +1638,7 @@ "distribution-stage-play-unknown": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "distribution-stage-store-live": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "distribution-stage-testflight": "Used in Capgo web console areas: pages/admin/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "documentation": "Used in Capgo web console areas: components. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "documentation": "Used in Capgo web console areas: components, components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "done": "Used in Capgo web console areas: components, components/dashboard, utils. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "dont-have-an-account": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "downgrade": "Used in Capgo web console areas: pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1399,11 +1657,21 @@ "email-not-verified-banner-title": "Used in Capgo web console areas: pages. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "email-otp-2fa-description": "Used in Capgo web console areas: pages/settings/account. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "email-otp-2fa-title": "Used in organization/account two-factor authentication. Role: section or dialog title about \"email otp 2fa title\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "email-otp-back-to-code": "Button on the email verification send step that returns to entering a previously sent code without sending another email.", + "email-otp-enter-description": "Helper text on the code entry step after a verification email has been successfully sent. The email address is displayed above.", + "email-otp-resend-code": "Button on the email verification code entry step that opens the CAPTCHA and send step to request another code.", + "email-otp-send-description": "Helper text on the first email verification step, before sending a code. The email address is displayed above.", + "email-verification-title": "Title of the email verification page for a user whose account action requires verifying their email address.", "email-otp-code-required": "Used in Capgo web console areas: pages, pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "email-otp-expired": "Used in Capgo web console. Role: UI sentence about \"email otp expired\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "email-otp-not-verified": "Used in Capgo web console. Role: UI label about \"email otp not verified\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "email-otp-rate-limit-countdown": "Used in Capgo web console areas: pages, pages/settings/account. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "email-otp-rate-limit-wait": "Used in Capgo web console areas: services. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "email-otp-rate-limit-wait-unknown": "Used in Capgo web console areas: services. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "email-otp-required": "Used in Capgo web console. Role: UI label about \"email otp required\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "email-otp-send-code": "Used in Capgo web console areas: pages, pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "email-otp-send-failed": "Used in Capgo web console areas: pages, pages/settings/account, services. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "email-otp-send-wait": "Used in Capgo web console areas: pages, pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "email-otp-sent": "Used in Capgo web console areas: pages, pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "email-otp-verified": "Used in Capgo web console areas: pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "email-otp-verified-until": "Used in Capgo web console. Role: UI label about \"email otp verified until\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1420,7 +1688,7 @@ "enable-2FA": "Used in Capgo web console. Role: UI label about \"enable 2FA\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "enable-password-policy": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "enable-policy": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "enabled": "Used in Capgo web console areas: components, pages/app, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "enabled": "Used in Capgo web console areas: components, components/dashboard, pages/app, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "encrypted": "Used in Capgo web console areas: components, components/tables, pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "encrypted-bundles-enforcement-disabled": "Used in Capgo web console areas: pages/settings/organization. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "encrypted-bundles-enforcement-enable-anyway": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1452,7 +1720,7 @@ "enterprise-sso-chart-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "enterprise-sso-chart-title": "Used in Capgo web console areas: pages/admin/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "enterprise-sso-count": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "error": "Used in Capgo web console areas: app, components/tables, pages, pages/settings/organization, utils. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "error": "Used in Capgo web console areas: app, components/admin, components/tables, pages, pages/settings/organization. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "error-adding-member": "Used in Capgo web console areas: pages/settings/organization. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "error-assigning-role": "Used in Capgo web console areas: components/dashboard, components/permissions, components/tables, pages/settings/organization. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "error-categories": "Used in Capgo web console areas: pages/app. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1469,7 +1737,7 @@ "error-fetching-members": "Used in Capgo web console areas: pages/settings/organization. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "error-fetching-role-bindings": "Used in Capgo web console areas: components/dashboard, components/tables. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "error-fetching-versions": "Used in Capgo web console areas: pages/app. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "error-invalid-version": "Used in Capgo web console areas: pages/app. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "error-invalid-version": "Used in Capgo web console areas: pages/app, utils. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "error-inviting-user": "Used in Capgo web console areas: components/dashboard, pages/settings/organization. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "error-loading-channel-access": "Used in Capgo web console areas: components/permissions. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "error-loading-channel-permissions": "Used in Capgo web console areas: components/permissions, pages. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1530,6 +1798,7 @@ "failed-to-regenerate-api-key": "Used in Capgo web console areas: pages. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "failed-to-update-policy": "Used in Capgo web console areas: pages/settings/organization. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "failure-rate-bps": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "failure-rate-placeholder": "Used in Capgo web console areas: pages/app. Role: input placeholder. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "famous-apps": "Used in Capgo web console areas: constants, pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "famous-apps-all-tiers": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "famous-apps-category": "Used in Capgo web console areas: pages/admin/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1571,14 +1840,22 @@ "forgot": "Used in Capgo web console areas: pages. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "forgot-check-email": "Used in Capgo web console areas: pages. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "forgot-success": "Used in Capgo web console areas: pages. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "free-trial": "Used in Capgo web console areas: components, pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "free-trial": "Used in Capgo web console areas: components, components/dashboard, pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "frontend-onboarding": "Used in Capgo web console areas: constants, pages/admin/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "frontend-onboarding-apps": "Used in Capgo web console areas: pages/admin/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "frontend-onboarding-attempts": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "frontend-onboarding-attempts-subtitle": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "frontend-onboarding-cli-agent-multiple": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "frontend-onboarding-cli-agent-no-cli": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "frontend-onboarding-cli-agent-none": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "frontend-onboarding-cli-agent-unknown": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "frontend-onboarding-cli-checklist-coverage-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "frontend-onboarding-cli-checklist-coverage-v4": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "frontend-onboarding-cli-checklist-step-detail": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "frontend-onboarding-cli-checklist-unavailable": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "frontend-onboarding-cli-checklist-version": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "frontend-onboarding-cli-checklist-version-1": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "frontend-onboarding-cli-checklist-version-2": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "frontend-onboarding-completed": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "frontend-onboarding-completed-subtitle": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "frontend-onboarding-daily-attempts": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1609,7 +1886,7 @@ "frontend-onboarding-deduplicate-by-user": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "frontend-onboarding-deduplicate-by-user-chart": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "frontend-onboarding-funnel-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "frontend-onboarding-funnel-v1-legacy": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "frontend-onboarding-funnel-v1-legacy": "Used in Capgo web console. Role: UI label about \"frontend onboarding funnel v1 legacy\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "frontend-onboarding-funnel-v2": "Used in Capgo web console. Role: UI label about \"frontend onboarding funnel v2\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "frontend-onboarding-funnel-v3": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "frontend-onboarding-funnel-v4": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1722,13 +1999,10 @@ "getting-started-complete-desc": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "getting-started-count": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "getting-started-description": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "getting-started-dismiss": "Used in Capgo web console areas: components/dashboard, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "getting-started-dismiss-error": "Used in Capgo web console areas: components/dashboard, pages/app. Role: error message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "getting-started-dont-show-again": "Used in Capgo web console areas: components/dashboard, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "getting-started-verify": "Used in Capgo web console areas: pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "getting-started-verify-done": "Used in Capgo web console areas: pages/app. Role: success message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "getting-started-verify-error": "Used in Capgo web console areas: pages/app. Role: error message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "getting-started-dismiss": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "getting-started-dismiss-error": "Used in Capgo web console areas: components/dashboard, pages/app. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "getting-started-done": "Used in Capgo web console areas: pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "getting-started-dont-show-again": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "getting-started-essential": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "getting-started-grow": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "getting-started-live-update": "Used in Capgo web console areas: utils. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1737,6 +2011,9 @@ "getting-started-percent": "Used in Capgo web console areas: pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "getting-started-self-test-hint": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "getting-started-store-release-desc": "Used in Capgo web console areas: utils, pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "getting-started-verify": "Used in Capgo web console areas: pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "getting-started-verify-done": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "getting-started-verify-error": "Used in Capgo web console areas: pages/app. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "github-stars-trend": "Used in Capgo web console. Role: UI label about \"github stars trend\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "github-username": "Used in Capgo web console areas: components/dashboard, composables, pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "github-username-confirm": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1773,6 +2050,8 @@ "here": "Used in Capgo web console areas: components/tables, pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "history": "Used in Capgo web console areas: constants. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "home": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "hour": "Used in Capgo web console areas: components/admin, pages/admin/dashboard, pages/settings/account, services, utils. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "hours": "Used in Capgo web console areas: services, utils. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "hours-ago": "Used in Capgo web console areas: services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "how-to-verify-signature": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "i-am-sure": "Used in Capgo web console areas: pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1791,7 +2070,8 @@ "invalid-group-id": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "invalid-mfa-code": "Used in Capgo web console areas: pages, pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "invalid-password": "Used in Capgo web console areas: pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "invalid-rollout-percentage": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "invalid-rollout-cache-ttl": "Used in Capgo web console areas: utils. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "invalid-rollout-percentage": "Used in Capgo web console areas: utils. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "invalid-uuid": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "invalid-version": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "invitation-failed": "Used in Capgo web console areas: components/dashboard, pages/settings/organization. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1841,25 +2121,25 @@ "last-release": "Used in Capgo web console. Role: UI label about \"last release\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "last-run": "Used in Capgo web console areas: components/dashboard, pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "last-seen": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "last-update": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "last-upload": "Used in Capgo web console areas: components/tables, pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "last-version": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "latest": "Used in Capgo web console. Role: short UI label about \"latest\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "last-update": "Used in Capgo web console areas: components/dashboard, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "last-upload": "Used in Capgo web console areas: components/dashboard, components/tables, pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "last-version": "Used in Capgo web console areas: components/dashboard, components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "latest": "Used in Capgo web console areas: services. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "latest-day-in-selected-period": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "latest-day-in-selected-period-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "latest-snapshot": "Used in Capgo web console. Role: UI label about \"latest snapshot\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "launch-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "leaving": "Used in Capgo web console areas: pages. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "legacy-header-signature-desc": "Used in Capgo web console areas: pages/settings/organization. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "limit-reached-no-credits": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "limit-reached-with-credits": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "limit-reached-no-credits": "Used in usage credits billing. Role: UI label about \"limit reached no credits\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "limit-reached-with-credits": "Used in usage credits billing. Role: UI label about \"limit reached with credits\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "limit-to-app": "Used in Capgo web console. Role: UI sentence about \"limit to app\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "limit-to-org": "Used in Capgo web console. Role: UI sentence about \"limit to org\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "link": "Used in Capgo web console areas: components/admin, pages, pages/admin/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "link-new-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "link-this-bundle-to-another-channel": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "linked-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "loading": "Used in Capgo web console areas: components, components/bundle, components/dashboard, components/permissions, components/tables. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "loading": "Used in Capgo web console areas: components, components/admin, components/bundle, components/dashboard, components/permissions. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "loading-statistics": "Used in Capgo web console areas: components/bundle, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "loading-version": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "log-as": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1885,7 +2165,7 @@ "login-query-session-prompt": "Used in Capgo web console areas: pages. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "login-to-your-account": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "logout": "Used in Capgo web console areas: pages/onboarding, pages/settings/account. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "logs": "Used in Capgo web console areas: constants, pages/app, stores. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "logs": "Used in Capgo web console areas: components/dashboard, constants, pages/app, stores. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "low-adoption-warning": "Used in Capgo web console areas: pages/app. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "major": "Used in Capgo web console areas: pages/app, types. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "manage": "Used in Capgo web console areas: components, components/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1914,7 +2194,7 @@ "manifest-summary-files": "Used in Capgo web console areas: pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "manifest-to-download": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "manifest-total-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "manual-rollout-pause": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "manual-rollout-pause": "Used in Capgo web console areas: utils. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "mau": "Used in Capgo web console areas: components/dashboard, components/tables, pages/admin/dashboard, pages/settings/organization, services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "mau-usage": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "max-apikey-expiration-days": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1935,11 +2215,15 @@ "mfa-fail": "Used in Capgo web console areas: pages/settings/account. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "mfa-invalid-code": "Used in Capgo web console areas: pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "min-attempts": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "min-attempts-placeholder": "Used in Capgo web console areas: pages/app. Role: input placeholder. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "min-failures": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "min-failures-placeholder": "Used in Capgo web console areas: pages/app. Role: input placeholder. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "min-plugin-version": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "min-update-version": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "minimum-length": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "minor": "Used in Capgo web console areas: pages/app, types. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "minute": "Used in Capgo web console areas: utils. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "minutes": "Used in Capgo web console areas: pages/settings/account, utils. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "minutes-ago": "Used in Capgo web console areas: services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "minutes-short": "Used in Capgo web console areas: pages/settings/organization, services. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "minutes-unit": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1960,7 +2244,13 @@ "name-contains-invalid-characters": "Used in Capgo web console. Role: UI label about \"name contains invalid characters\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "name-length-error": "Used in Capgo web console areas: pages. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "name-required": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "native": "Used in Capgo web console areas: components/dashboard, constants, pages/app, services, utils. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native": "Used in Capgo web console areas: components/dashboard, constants, pages/app, pages/preview, services. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-active-devices-android": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-active-devices-help": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-active-devices-ios": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-active-devices-last-30-days": "Used in Capgo web console areas: components/dashboard. Role: filter or date-range option label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-active-devices-selected-period": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-active-devices-total": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-dependencies": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-dependencies-description": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-action-breakdown": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1991,30 +2281,30 @@ "native-observe-platform": "Used in Capgo web console areas: pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-plugin-adoption": "Used in Capgo web console. Role: UI label about \"native observe plugin adoption\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-plugin-adoption-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "native-observe-plugin-behind-stats": "Used in Capgo web console areas: pages/app. Role: helper or description text. Keep placeholders {count} and {share}. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-observe-plugin-behind-stats": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-plugin-capacitor-major": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-plugin-devices-behind": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-plugin-distribution": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-plugin-distribution-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "native-observe-plugin-docs": "Used in Capgo web console areas: pages/app. Role: link label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-observe-plugin-docs": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-plugin-fleet-share": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-plugin-install-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-plugin-latest-for-major": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-plugin-majors": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-plugin-majors-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-plugin-most-reported": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "native-observe-plugin-most-reported-share": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "native-observe-plugin-other-version-devices": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-observe-plugin-most-reported-share": "Used in Capgo web console. Role: UI label about \"native observe plugin most reported share\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-observe-plugin-other-version-devices": "Used in Capgo web console. Role: UI label about \"native observe plugin other version devices\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-plugin-production-devices": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-plugin-scope": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "native-observe-plugin-status-behind": "Used in Capgo web console areas: pages/app. Role: status badge. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "native-observe-plugin-status-current": "Used in Capgo web console areas: pages/app. Role: status badge. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "native-observe-plugin-status-unknown": "Used in Capgo web console areas: pages/app. Role: status badge. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "native-observe-plugin-status-unsupported": "Used in Capgo web console areas: pages/app. Role: status badge. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "native-observe-plugin-upgrade-current": "Used in Capgo web console areas: pages/app. Role: helper or description text. Keep placeholders {major} and {latest}. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "native-observe-plugin-upgrade-needed": "Used in Capgo web console areas: pages/app. Role: helper or description text. Keep placeholders {current}, {major}, and {latest}. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "native-observe-plugin-upgrade-title": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "native-observe-plugin-upgrade-unsupported": "Used in Capgo web console areas: pages/app. Role: helper or description text. Keep placeholder {current}. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-observe-plugin-status-behind": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-observe-plugin-status-current": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-observe-plugin-status-unknown": "Used in Capgo web console areas: pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-observe-plugin-status-unsupported": "Used in Capgo web console areas: pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-observe-plugin-upgrade-current": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-observe-plugin-upgrade-needed": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-observe-plugin-upgrade-title": "Used in Capgo web console areas: pages/app. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-observe-plugin-upgrade-unsupported": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-plugin-version": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-release-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-releases": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2031,6 +2321,8 @@ "native-observe-volume-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-webview-loads": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "native-observe-webview-p90": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-platform-active-trend": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "native-platform-active-trend-help": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "need-more-contact-us": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "need-upgrade-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "need-upgrade-trend": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2067,7 +2359,7 @@ "no-native-dependencies": "Used in Capgo web console areas: pages/app. Role: empty state text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "no-native-dependencies-description": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "no-organization-selected": "Used in Capgo web console areas: pages/settings/account, pages/settings/organization. Role: empty state text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "no-permission": "Used in Capgo web console areas: components/dashboard, components/tables, pages/app, pages/settings/organization. Role: empty state text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "no-permission": "Used in Capgo web console areas: components/dashboard, components/tables, pages/app, pages/settings/organization, utils. Role: empty state text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "no-permission-ask-super-admin": "Used in Capgo web console areas: components/tables, pages/app. Role: empty state text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "no-recent-releases": "Used in Capgo web console areas: components/dashboard. Role: empty state text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "no-releases-yet": "Used in Capgo web console areas: components/dashboard. Role: empty state text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2122,9 +2414,17 @@ "notification-platform-ios": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "notification-provider-config-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "notification-provider-config-json": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "notification-provider-secret-file-android": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "notification-provider-secret-file-ios": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "notification-provider-secret-on-file-android": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "notification-provider-secret-on-file-ios": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "notification-provider-secret-ref": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "notification-provider-secret-ref-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "notification-provider-secret-ref-help": "Used in Capgo web console. Role: helper or description text about \"notification provider secret ref help\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "notification-provider-secret-ref-placeholder": "Used in Capgo web console. Role: input placeholder about \"notification provider secret ref placeholder\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "notification-provider-secret-ref-self-host-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "notification-provider-secret-selected": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "notification-provider-secret-upload-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "notification-provider-self-host-advanced": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "notification-provider-setup": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "notification-push-update": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "notification-push-update-enabled": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2151,7 +2451,7 @@ "notification-update-install-now": "Used in Capgo web console areas: pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "notification-update-push-success": "Used in Capgo web console areas: pages/app. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "notification-view-stats": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "notifications": "Used in Capgo web console areas: constants, pages, pages/admin/dashboard, pages/settings/account, stores. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "notifications": "Used in Capgo web console areas: components/dashboard, constants, pages, pages/admin/dashboard, pages/settings/account. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "notifications-activity": "Used in Capgo web console areas: components/dashboard, pages, pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "notifications-billing-period-stats": "Used in Capgo web console areas: components/dashboard, pages, pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "notifications-billing-period-stats-desc": "Used in Capgo web console areas: pages, pages/settings/account. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2188,7 +2488,7 @@ "notifications-weekly-stats": "Used in Capgo web console areas: components/dashboard, pages, pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "notifications-weekly-stats-desc": "Used in Capgo web console areas: pages, pages/settings/account. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "notify": "Used in Capgo web console areas: pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "observe": "Used in Capgo web console areas: constants, layouts, pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "observe": "Used in Capgo web console areas: components/dashboard, constants, layouts, pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "of": "Used in Capgo web console areas: components. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "ok": "Used in Capgo web console areas: components/dashboard, pages, pages/admin/dashboard, pages/app, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "on-current-version": "Used in Capgo web console. Role: UI label about \"on current version\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2226,6 +2526,34 @@ "onboarding-outcome-in-progress": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "onboarding-outcome-skipped": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "onboarding-outcome-switched-to-manual": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-14-days": "Used in Capgo web console areas: components/admin. Role: filter or date-range option label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-3-days": "Used in Capgo web console areas: components/admin. Role: filter or date-range option label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-7-days": "Used in Capgo web console areas: components/admin. Role: filter or date-range option label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-append-warning": "Used in Capgo web console areas: components/admin. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-cell-note": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-credit-note": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-cutoff": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-error": "Used in Capgo web console areas: components/admin. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-ever": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-ever-note": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-fallbacks": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-invites": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-last-sync": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-loading": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-methodology": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-month": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-no-public-row": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-refresh": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-retry": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-scope": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-signup-note": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-signups": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-snapshot-note": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-source-note": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-sync-mode": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-title": "Used in Capgo web console areas: components/admin. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-unavailable": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-payment-cohorts-window-note": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "onboarding-prerequisites-built": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "onboarding-prerequisites-capacitor": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "onboarding-prerequisites-cli-desc": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2250,6 +2578,7 @@ "onboarding-wizard-dropoff": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "onboarding-wizard-dropoff-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "onboarding-wizard-not-started": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "onboarding-wizard-publish-app-question": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "one-day": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "one-day-left": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "one-hour-short": "Used in Capgo web console. Role: short UI label about \"one hour short\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2310,7 +2639,7 @@ "organization-email": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-id": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-insights": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "organization-invite": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-invite": "Used in Capgo web console areas: components/admin, pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-name": "Used in Capgo web console areas: components/dashboard, pages, pages/onboarding, pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-not-found": "Used in Capgo web console areas: components/dashboard, pages/onboarding. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-active-users-plus": "Used in Capgo web console areas: components/dashboard, pages/onboarding. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2324,6 +2653,16 @@ "organization-onboarding-continue-logo": "Used in Capgo web console areas: pages/onboarding. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-create-app": "Used in Capgo web console areas: pages/onboarding. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-delete-imported-details": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-development-environment-option-ai_assistant-desc": "Used in Capgo web console. Role: helper or description text about \"organization onboarding development environment option ai_assistant desc\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-development-environment-option-ai_assistant-label": "Used in Capgo web console. Role: UI label about \"organization onboarding development environment option ai_assistant label\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-development-environment-option-hand_coded-desc": "Used in Capgo web console. Role: helper or description text about \"organization onboarding development environment option hand_coded desc\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-development-environment-option-hand_coded-label": "Used in Capgo web console. Role: UI label about \"organization onboarding development environment option hand_coded label\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-development-environment-option-hosted_builder-desc": "Used in Capgo web console. Role: helper or description text about \"organization onboarding development environment option hosted_builder desc\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-development-environment-option-hosted_builder-label": "Used in Capgo web console. Role: UI label about \"organization onboarding development environment option hosted_builder label\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-development-environment-option-other-label": "Used in Capgo web console. Role: short UI label about \"organization onboarding development environment option other label\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-development-environment-question": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-development-environment-required": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-development-environment-skip": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-existing-users-helper": "Used in Capgo web console areas: components/dashboard, pages/onboarding. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-existing-users-label": "Used in Capgo web console areas: components/dashboard, pages/onboarding. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-finish-setup": "Used in Capgo web console areas: pages/onboarding. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2335,13 +2674,15 @@ "organization-onboarding-imported-logo-unavailable": "Used in Capgo web console areas: components/dashboard, pages/onboarding. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-intent-hint": "Used in Capgo web console areas: components/dashboard, pages/onboarding. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-intent-option-both-desc": "Used in Capgo web console. Role: helper or description text about \"organization onboarding intent option both desc\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "organization-onboarding-intent-option-both-label": "Used in Capgo web console. Role: short UI label about \"organization onboarding intent option both label\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-intent-option-both-label": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-intent-option-builder-desc": "Used in Capgo web console. Role: helper or description text about \"organization onboarding intent option builder desc\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "organization-onboarding-intent-option-builder-label": "Used in Capgo web console. Role: UI label about \"organization onboarding intent option builder label\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-intent-option-builder-label": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-intent-option-exploring-desc": "Used in Capgo web console. Role: helper or description text about \"organization onboarding intent option exploring desc\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "organization-onboarding-intent-option-exploring-label": "Used in Capgo web console. Role: UI label about \"organization onboarding intent option exploring label\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-intent-option-exploring-label": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-intent-option-ota-desc": "Used in Capgo web console. Role: helper or description text about \"organization onboarding intent option ota desc\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "organization-onboarding-intent-option-ota-label": "Used in Capgo web console. Role: UI label about \"organization onboarding intent option ota label\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-intent-option-ota-label": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-intent-option-publish-desc": "Used in Capgo web console. Role: helper or description text about \"organization onboarding intent option publish desc\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-intent-option-publish-label": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-intent-question": "Used in Capgo web console areas: components/dashboard, pages/onboarding. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-intent-required": "Used in Capgo web console areas: components/dashboard, pages/onboarding. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-intent-step": "Used in Capgo web console. Role: UI label about \"organization onboarding intent step\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2402,6 +2743,10 @@ "organization-onboarding-upload-logo": "Used in Capgo web console areas: pages/onboarding. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-use-imported-logo": "Used in Capgo web console areas: pages/onboarding. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-user-scale-required": "Used in Capgo web console areas: components/dashboard, pages/onboarding. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-webnative-check-website": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-webnative-continue-capgo": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-webnative-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "organization-onboarding-webnative-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-website-fetch-failed": "Used in Capgo web console areas: components/dashboard, pages/onboarding. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-website-help": "Used in Capgo web console areas: components/dashboard, pages/onboarding. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "organization-onboarding-website-import-success": "Used in Capgo web console areas: components/dashboard. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2421,7 +2766,7 @@ "overriding-default-channel": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "overview": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "package-name": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "pages": "Used in Capgo web console areas: components. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "pages": "Used in Capgo web console areas: components, components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "paid-orgs-only": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "paid-subscriptions-to-cancel": "Used in Capgo web console areas: pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "partial-adoption": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2487,6 +2832,7 @@ "picture-delete-fail": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "picture-uploaded": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plan": "Used in Capgo web console areas: pages/admin/dashboard, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plan-active": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plan-bandwidth": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plan-desc": "Used in Capgo web console areas: pages/settings/organization. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plan-distribution": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2502,7 +2848,8 @@ "plan-feature-service-sla": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plan-feature-soc2-certified": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plan-full-comparison-link": "Used in Capgo web console areas: pages/settings/organization. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "plan-inactive": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plan-inactive": "Used in billing/plans. Role: UI label about \"plan inactive\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plan-limit-reached": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plan-maker": "Used in billing/plans. Role: UI label about \"plan maker\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plan-native-build-concurrency": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plan-page-warn": "Used in Capgo web console areas: pages/settings/organization. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2567,7 +2914,7 @@ "plugin-sandbox-js-only-packages": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plugin-sandbox-native-bridge-tests": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plugin-sandbox-skipped-native-setup": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "plugin-version": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plugin-version": "Used in Capgo web console areas: components/dashboard, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plugins": "Used in Capgo web console areas: components/dashboard, constants, pages/admin/dashboard, pages/app, services. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "popup-blocked-open-manually": "Used in Capgo web console areas: services. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "prediction": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2614,15 +2961,26 @@ "reason-optional": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "reason-placeholder": "Used in Capgo web console areas: components/dashboard. Role: input placeholder. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "recent-releases-active": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "recommended": "Used in Capgo web console areas: pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "recommended": "Used in Capgo web console areas: components/dashboard, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "refer-and-earn": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "refresh": "Used in Capgo web console areas: components, components/dashboard, pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "refresh": "Used in Capgo web console areas: components, components/admin, components/dashboard, pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "register": "Used in Capgo web console areas: pages. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "register-heading": "Used in Capgo web console areas: pages. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "register-next": "Used in Capgo web console areas: pages. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "register-terms-disclaimer": "Used in Capgo web console areas: pages. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "register-to-org": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "registered-at": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "registration-comparison-cutoff": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "registration-comparison-description": "Used in Capgo web console areas: components/admin. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "registration-comparison-full-month": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "registration-comparison-load-error": "Used in Capgo web console areas: components/admin. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "registration-comparison-month": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "registration-comparison-not-tracked": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "registration-comparison-self-signup": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "registration-comparison-title": "Used in Capgo web console areas: components/admin. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "registration-comparison-total-tracked": "Used in Capgo web console areas: components/admin. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "registration-comparison-tracking-note": "Used in Capgo web console areas: components/admin. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "registration-comparison-unknown-other": "Used in Capgo web console areas: components/admin. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "registration-to-subscription-conversion": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "registrations-by-source": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "registrations-by-source-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2632,7 +2990,7 @@ "released": "Used in Capgo web console areas: components/dashboard, pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "released-on-channel": "Used in Capgo web console. Role: UI label about \"released on channel\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "reload": "Used in Capgo web console areas: components, components/admin, components/dashboard, components/organization, components/tables. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "remove": "Used in Capgo web console areas: components/dashboard, components/organization, components/tables, pages/settings/organization. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "remove": "Used in Capgo web console areas: components/dashboard, components/organization, components/tables, pages/app, pages/settings/organization. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "remove-api-key-scope-filter": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "remove-bundle-filter": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "remove-bundle-from-channel": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2688,7 +3046,7 @@ "retention-never": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "retention-never-info": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "retention-to-big": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "retry": "Used in Capgo web console areas: components, pages, pages/settings/organization. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "retry": "Used in Capgo web console areas: components, components/admin, pages, pages/admin/dashboard, pages/settings/organization. Role: button or action label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "revenue": "Used in Capgo web console areas: constants, pages/admin/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "revert-channel-to-built-in-version": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "revert-to-builtin": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2773,8 +3131,13 @@ "rollout-delivery-upload-hint": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "rollout-delivery-zero-hint": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "rollout-percentage": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-percentage-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-percentage-placeholder": "Used in Capgo web console areas: pages/app. Role: input placeholder. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-percentage-unchanged": "Used in Capgo web console areas: utils. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "rollout-policy": "Used in Capgo web console. Role: UI label about \"rollout policy\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "rollout-settings-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-settings-info": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "rollout-settings-unchanged": "Used in Capgo web console areas: utils. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "rollout-target": "Used in Capgo web console areas: pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "rollout-target-linked": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "save-changes": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2786,11 +3149,11 @@ "search-api-keys": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "search-apps": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "search-builds": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "search-by-device-id": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "search-by-device-id": "Used in Capgo web console areas: components/dashboard, components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "search-by-device-id-": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "search-by-device-id-0": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "search-by-name": "Used in Capgo web console areas: components/tables, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "search-by-name-or-app-id": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "search-by-name-or-app-id": "Used in Capgo web console areas: components/dashboard, components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "search-by-name-or-bundle-id": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "search-by-name-or-email": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "search-by-record-id": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2802,6 +3165,8 @@ "search-role-bindings": "Used in Capgo web console areas: components/dashboard, components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "search-scope-items": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "search-versions": "Used in Capgo web console areas: components/tables, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "second": "Used in Capgo web console areas: utils. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "seconds": "Used in Capgo web console areas: utils. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "secret-copied": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "secret-copy-failed": "Used in Capgo web console areas: pages/settings/organization. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "secure-key-created": "Used in Capgo web console areas: services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2857,7 +3222,7 @@ "set-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "set-even-not-compatible": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "set-expiration-date": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "set-rollout-target": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "set-rollout-target": "Used in Capgo web console. Role: UI label about \"set rollout target\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "setting-change-details": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "setting-changed": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "settings": "Used in Capgo web console areas: components/dashboard, constants, layouts, pages/settings/account, stores. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2958,7 +3323,7 @@ "starting-out-existing-users": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "starting-out-no-users": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "statistics": "Used in Capgo web console. Role: short UI label about \"statistics\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "status": "Used in Capgo web console areas: components, components/dashboard, components/organizations, components/tables, pages. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "status": "Used in Capgo web console areas: components, components/admin, components/dashboard, components/organizations, components/tables. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "storage-chart-mode": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "storage-hourly": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "storage-total": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -3060,7 +3425,7 @@ "top-priority": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "top-priority-empty": "Used in Capgo web console areas: pages/app. Role: empty state text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "top-priority-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "total": "Used in Capgo web console areas: components/dashboard, components/tables, pages/app, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "total": "Used in Capgo web console areas: components/admin, components/dashboard, components/tables, pages/app, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "total-above-plan": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "total-above-plan-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "total-devices": "Used in Capgo web console. Role: UI label about \"total devices\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -3074,6 +3439,7 @@ "translation-unavailable": "Used in Capgo web console areas: services. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "trial-banner-cta": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "trial-banner-message": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "trial-days-left": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "trial-end-date": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "trial-ended-description": "Used in Capgo web console areas: components. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "trial-ended-plans-description": "Used in Capgo web console areas: pages/settings/organization. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -3081,11 +3447,12 @@ "trial-extended-badge": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "trial-extension-conversion-trend": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "trial-extensions": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "trial-left": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "trial-left": "Used in Capgo web console. Role: UI label about \"trial left\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "trial-organizations-list": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "trial-plan-expired": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "trial-over": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "trial-plan-expired": "Used in billing/plans. Role: UI label about \"trial plan expired\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "try-a-different-search-term": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "type": "Used in Capgo web console areas: components/tables, pages, pages/app, services. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "type": "Used in Capgo web console areas: components/tables, pages, pages/app, services, utils. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "type-app-id-to-confirm": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "type-device-id": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "type-device-id-msg": "Used in Capgo web console areas: pages/app. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -3106,6 +3473,7 @@ "unified-onboarding-setup-builder-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "unified-onboarding-setup-ota-subtitle": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "unified-onboarding-setup-ota-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "unified-onboarding-step-channel": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "unified-onboarding-step-intent": "Used in Capgo web console areas: components/dashboard, pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "unified-onboarding-step-organization": "Used in Capgo web console areas: components/dashboard, pages/admin/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "unified-onboarding-step-setup": "Used in Capgo web console areas: components/dashboard, pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -3138,17 +3506,23 @@ "update-delivery-trend": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "update-delivery-trend-help": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "update-package": "Used in Capgo web console areas: components/tables, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "update-package-all": "Used in Capgo web console areas: components/tables, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "update-package-delta": "Used in Capgo web console areas: components/tables, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "update-package-delta-from-builtin": "Used in Capgo web console areas: components/tables, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-all": "Used in Capgo web console areas: components/tables, utils. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-all-description": "Used in Capgo web console areas: utils. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-delta": "Used in Capgo web console areas: components/tables, utils. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-delta-description": "Used in Capgo web console areas: utils. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-delta-from-builtin": "Used in Capgo web console areas: components/tables, utils. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-delta-from-builtin-description": "Used in Capgo web console areas: utils. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "update-package-delta-required": "Used in Capgo web console areas: services. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "update-package-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "update-package-zip": "Used in Capgo web console areas: components/tables, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "update-package-zip-from-builtin": "Used in Capgo web console areas: components/tables, pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-help": "Used in Capgo web console areas: utils. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-info": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-zip": "Used in Capgo web console areas: components/tables, utils. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-zip-description": "Used in Capgo web console areas: utils. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-zip-from-builtin": "Used in Capgo web console areas: components/tables, utils. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-zip-from-builtin-description": "Used in Capgo web console areas: utils. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "update-package-zip-required": "Used in Capgo web console areas: services. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "update-password-now": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "update_statistics": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "updated-at": "Used in Capgo web console areas: components/tables, pages/app, pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "updated-at": "Used in Capgo web console areas: components/dashboard, components/tables, pages/app, pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "updated-default-download-channel": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "updated-default-upload-channel": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "updated-devices": "Used in Capgo web console. Role: UI label about \"updated devices\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -3186,6 +3560,7 @@ "users-must-change-password": "Used in Capgo web console areas: pages/settings/organization. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "users-trend": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "users-will-be-locked-out": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "using-credits": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "validate": "Used in Capgo web console areas: pages/onboarding. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "validate-email": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "verification-code": "Used in Capgo web console areas: pages/settings/account. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -3259,6 +3634,7 @@ "what-to-do-with-photo": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "what-to-do-with-photo-dec": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "window-minutes": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "window-minutes-placeholder": "Used in Capgo web console areas: pages/app. Role: input placeholder. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "without-profile": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "write-key": "Used in Capgo web console. Role: short UI label about \"write key\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "wrong-key-bundles-count": "Used in Capgo web console areas: pages/settings/organization. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -3274,5 +3650,79 @@ "your-api-key": "Used in Capgo web console areas: services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "your-role-in-org": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "your-usage": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "zip-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged." -} + "zip-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-ai-action": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-ai-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-app-created": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-auto-progress": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-breadcrumb": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-channel-detection": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-command-location": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-complete-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-complete-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-copy-command": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-current-task": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-delegate-action": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-delegate-cta": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-delegate-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-delegate-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-description-add_app": "Used in Capgo web console. Role: UI sentence about \"setup checklist description add_app\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-description-add_channel": "Used in Capgo web console. Role: UI sentence about \"setup checklist description add_channel\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-description-add_code": "Used in Capgo web console. Role: UI sentence about \"setup checklist description add_code\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-description-add_code_change": "Used in Capgo web console. Role: UI sentence about \"setup checklist description add_code_change\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-description-add_encryption": "Used in Capgo web console. Role: UI sentence about \"setup checklist description add_encryption\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-description-add_updater": "Used in Capgo web console. Role: UI sentence about \"setup checklist description add_updater\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-description-build_project": "Used in Capgo web console. Role: UI sentence about \"setup checklist description build_project\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-description-completion": "Used in Capgo web console. Role: UI sentence about \"setup checklist description completion\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-description-login_cli_mcp": "Used in Capgo web console. Role: UI sentence about \"setup checklist description login_cli_mcp\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-description-run_device": "Used in Capgo web console. Role: UI sentence about \"setup checklist description run_device\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-description-select_platform": "Used in Capgo web console. Role: UI sentence about \"setup checklist description select_platform\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-description-test_update": "Used in Capgo web console. Role: UI sentence about \"setup checklist description test_update\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-description-upload_bundle": "Used in Capgo web console. Role: UI sentence about \"setup checklist description upload_bundle\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-explore-dashboard": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-follow-terminal": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-future-task": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-hide-description": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-list-subtitle": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-list-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-manual-guide": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-open-app": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-progress": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-refresh-error": "Used in Capgo web console areas: components/dashboard. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-retry": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-return-checklist": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-return-current": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-step-add_app": "Used in Capgo web console. Role: UI label about \"setup checklist step add_app\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-step-add_channel": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-step-add_code": "Used in Capgo web console. Role: UI label about \"setup checklist step add_code\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-step-add_code_change": "Used in Capgo web console. Role: UI label about \"setup checklist step add_code_change\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-step-add_encryption": "Used in Capgo web console. Role: UI label about \"setup checklist step add_encryption\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-step-add_updater": "Used in Capgo web console. Role: UI label about \"setup checklist step add_updater\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-step-build_project": "Used in Capgo web console. Role: UI label about \"setup checklist step build_project\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-step-completion": "Used in Capgo web console. Role: UI label about \"setup checklist step completion\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-step-login_cli_mcp": "Used in Capgo web console. Role: UI label about \"setup checklist step login_cli_mcp\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-step-run_device": "Used in Capgo web console. Role: UI label about \"setup checklist step run_device\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-step-select_platform": "Used in Capgo web console. Role: UI label about \"setup checklist step select_platform\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-step-test_update": "Used in Capgo web console. Role: UI label about \"setup checklist step test_update\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-step-upload_bundle": "Used in Capgo web console. Role: UI label about \"setup checklist step upload_bundle\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-subtitle": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-task-guide": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-task-number": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-view-task": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-waiting-add_channel": "Used in Capgo web console. Role: UI label about \"setup checklist waiting add_channel\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-waiting-channel": "Used in Capgo web console. Role: UI label about \"setup checklist waiting channel\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-waiting-run_device": "Used in Capgo web console. Role: UI label about \"setup checklist waiting run_device\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-waiting-start": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-waiting-step": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-waiting-test_update": "Used in Capgo web console. Role: UI sentence about \"setup checklist waiting test_update\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "setup-checklist-waiting-upload_bundle": "Used in Capgo web console. Role: UI sentence about \"setup checklist waiting upload_bundle\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "app-onboarding-exploration-reminder-title": "Title of a dialog shown after refreshing the dashboard during unfinished app setup. Offers staying in the dashboard or returning to setup.", + "app-onboarding-exploration-reminder-description": "Dialog body asking a user who previously explored the dashboard whether to continue exploring or return to unfinished app setup.", + "app-onboarding-exploration-reminder-continue": "Dialog button that closes the reminder and keeps the user exploring the app dashboard.", + "app-onboarding-exploration-reminder-setup": "Dialog button returning the user to the fullscreen app setup checklist.", + "app-onboarding-ai-help-prompt": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "app-onboarding-ai-help-status-existing": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "app-onboarding-ai-help-status-new": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "app-onboarding-v2-ai-help-status": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged." +} \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index eda2b1d88c..4ec1c97a24 100644 --- a/messages/en.json +++ b/messages/en.json @@ -87,6 +87,7 @@ "action-app-memory-warning": "iOS memory warning", "action-app-moved-to-background": "App moved to background", "action-app-moved-to-foreground": "App moved to foreground", + "action-app-nav": "Screen navigation", "action-backend-refusal": "Rejected - unsupported plugin version", "action-blocked-by-server-url": "Blocked by server URL policy", "action-cannot-get-bundle": "Unable to retrieve bundle", @@ -463,11 +464,7 @@ "app-onboarding-appid-taken-switched": "App ID {original} was already taken, so Capgo switched to {replacement}.", "app-onboarding-ai-help-button": "Copy AI instructions", "app-onboarding-ai-help-caption": "Let your AI assistant guide you through setting up Capgo. Copy the onboarding instructions to get started.", - "app-onboarding-ai-help-prompt": "I am setting up Capgo for my mobile app and I need step-by-step help with the CLI install.\n\nCapgo context:\n- App name: {appName}\n- App ID: {appId}\n- App status: {appStatus}\n- The Capgo app is already created in the dashboard.\n- Command:\n{command}\n\nPlease help me step by step.\n1. Tell me exactly where I should run this command.\n2. Ask me one question at a time if you need to know my stack (Capacitor, Ionic, React Native, native iOS/Android, etc.).\n3. Help me verify the installation succeeded.\n4. {apiKeyGuidance}", - "app-onboarding-ai-help-status-existing": "Existing published app", - "app-onboarding-ai-help-status-new": "New app not published yet", "app-onboarding-ai-help-title": "Need help from an AI assistant?", - "app-onboarding-ai-help-with-key": "I explicitly consented to include my Capgo API key in this message so you can use the command as provided. Continue without warning me about the pasted key, and do not repeat the API key in your response.", "app-onboarding-badge": "Create your app", "app-onboarding-choice-title": "Choose the best next step", "app-onboarding-choice-subtitle": "Install Capgo in the real project now, or add disposable demo data first and explore the dashboard.", @@ -517,13 +514,14 @@ "app-onboarding-install-subtitle": "Run the init command in the app project. The upcoming CLI change can detect this pending app and reuse it instead of creating a second app in Capgo.", "app-onboarding-install-title": "Finish setup in your app", "app-onboarding-cli-steps-title": "CLI setup steps", - "app-onboarding-cli-steps-subtitle": "Each step the CLI reports appears here as you run onboarding.", + "app-onboarding-cli-steps-subtitle": "Progress updates from CLI or AI assistant reports and channels detected for your app.", "app-onboarding-cli-steps-progress": "{done} of {total} steps", "app-onboarding-cli-step-pending": "Not started", "app-onboarding-cli-step-done": "Done", "app-onboarding-cli-step-skipped": "Skipped", "app-onboarding-cli-step-add_app": "Add your app", - "app-onboarding-cli-step-add_channel": "Create production channel", + "app-onboarding-cli-step-login_cli_mcp": "Log in to the Capgo CLI/MCP", + "app-onboarding-cli-step-add_channel": "Create a channel", "app-onboarding-cli-step-add_updater": "Install updater plugin", "app-onboarding-cli-step-add_code": "Add integration code", "app-onboarding-cli-step-add_encryption": "Setup encryption", @@ -534,6 +532,72 @@ "app-onboarding-cli-step-upload_bundle": "Upload bundle", "app-onboarding-cli-step-test_update": "Test update on device", "app-onboarding-cli-step-completion": "Completion", + "setup-checklist-app-created": "App created", + "setup-checklist-breadcrumb": "Set up your project", + "setup-checklist-title": "Set up your first live update", + "setup-checklist-subtitle": "Follow guided setup to install Capgo and test your first live update.", + "setup-checklist-progress": "{done} of {total} complete", + "setup-checklist-list-title": "Setup checklist", + "setup-checklist-list-subtitle": "Progress updates automatically.", + "setup-checklist-current-task": "Current task", + "setup-checklist-task-number": "Task {number} of {total}", + "setup-checklist-view-task": "View instructions: {task}. {status}.", + "setup-checklist-step-login_cli_mcp": "Start guided setup", + "setup-checklist-step-add_app": "Start guided setup", + "setup-checklist-step-add_channel": "Create a channel", + "setup-checklist-step-add_updater": "Install Capgo Updater", + "setup-checklist-step-add_code": "Add the app-ready code", + "setup-checklist-step-add_encryption": "Choose update encryption", + "setup-checklist-step-select_platform": "Choose iOS or Android for your test", + "setup-checklist-step-build_project": "Build your app for testing", + "setup-checklist-step-run_device": "Run your app on a device", + "setup-checklist-step-add_code_change": "Make a visible test change", + "setup-checklist-step-upload_bundle": "Publish your first update", + "setup-checklist-step-test_update": "Deliver an update to a device", + "setup-checklist-step-completion": "Finish guided setup", + "setup-checklist-description-login_cli_mcp": "Run the command in your app project, or give your AI assistant the setup prompt. Either option guides you through installing Capgo and testing your first live update.", + "setup-checklist-description-add_app": "Run the command in your app project, or give your AI assistant the setup prompt. Either option guides you through installing Capgo and testing your first live update.", + "setup-checklist-description-add_channel": "Create a channel to publish live updates for this app. Guided setup can create one for you.", + "setup-checklist-description-add_updater": "The CLI installs the Capgo Updater plugin in your project so your app can receive live updates.", + "setup-checklist-description-add_code": "Follow the CLI’s instructions to add the code that tells Capgo when your app is ready after an update.", + "setup-checklist-description-add_encryption": "The CLI explains the encryption options and helps you configure encryption for your updates.", + "setup-checklist-description-select_platform": "Choose iOS or Android when the CLI asks which platform to use for this test. Your app can still use Capgo on both platforms.", + "setup-checklist-description-build_project": "Follow the CLI’s instructions to build your web assets and prepare the native app with Capgo installed.", + "setup-checklist-description-run_device": "Choose iOS or Android, build your app with Capgo installed, then open it on a device or simulator so the updater can contact Capgo.", + "setup-checklist-description-add_code_change": "Change something visible in your app, such as a piece of text. You’ll use this change to check that live updates work.", + "setup-checklist-description-upload_bundle": "Make a visible change in your app, build it, and upload the bundle to Capgo. Guided setup walks you through these steps.", + "setup-checklist-description-test_update": "Assign your uploaded bundle to a channel, then reopen your app on a device or simulator and let Capgo download and apply the update.", + "setup-checklist-description-completion": "Finish the remaining prompts in the CLI to complete guided setup.", + "setup-checklist-command-location": "For terminal setup, open your Capacitor app folder, where package.json is located.", + "setup-checklist-copy-command": "Copy setup command", + "setup-checklist-follow-terminal": "Complete this task in your terminal by following the guided setup. Selecting a task here shows its instructions; it does not mark the task complete.", + "setup-checklist-waiting-start": "Waiting for CLI or AI assistant activity", + "setup-checklist-waiting-step": "Waiting for the CLI to report this task", + "setup-checklist-waiting-add_channel": "Waiting for a channel to be created", + "setup-checklist-waiting-run_device": "Waiting for your app to contact Capgo", + "setup-checklist-waiting-upload_bundle": "Waiting for your first update to be published", + "setup-checklist-waiting-test_update": "Waiting for a device to apply your update", + "setup-checklist-waiting-channel": "Waiting for a channel to be created", + "setup-checklist-channel-detection": "This task completes automatically when a channel exists for this app. We check on page load and every 10 seconds.", + "setup-checklist-future-task": "This task comes later in guided setup", + "setup-checklist-auto-progress": "Progress updates from guided setup and activity detected for this app.", + "setup-checklist-return-current": "Back to the current task", + "setup-checklist-return-checklist": "Back to setup checklist", + "setup-checklist-ai-action": "Copy AI setup prompt", + "setup-checklist-ai-description": "Paste the AI setup prompt into your coding assistant in your app project.", + "setup-checklist-delegate-title": "Have a teammate complete setup for you", + "setup-checklist-delegate-cta": "Send setup instructions", + "setup-checklist-manual-guide": "Manual setup guide", + "setup-checklist-task-guide": "Guide: {task}", + "setup-checklist-delegate-action": "Ask a teammate to finish setup", + "setup-checklist-delegate-description": "We’ll email them setup instructions. They’ll need to complete the setup for you.", + "setup-checklist-hide-description": "Hide setup and return to your dashboard. You can reopen setup later.", + "setup-checklist-explore-dashboard": "Explore dashboard", + "setup-checklist-refresh-error": "Couldn’t refresh setup progress. Your last known progress is shown.", + "setup-checklist-retry": "Refresh progress", + "setup-checklist-complete-title": "Guided setup complete", + "setup-checklist-complete-description": "You’ve finished guided setup. Open your app in Capgo to see its devices and live updates.", + "setup-checklist-open-app": "Open your app", "app-onboarding-mode-import": "Import from store", "app-onboarding-mode-import-helper": "Use an App Store or Google Play link to prefill details.", "app-onboarding-mode-manual": "Set up manually", @@ -561,6 +625,7 @@ "app-onboarding-store-help": "Paste your App Store or Google Play link and Capgo will import the app name, icon, and App ID when available.", "app-onboarding-store-import-button": "Import app details", "app-onboarding-store-imported-help": "Store import is done. Review the app name and App ID before continuing.", + "app-onboarding-store-imported-missing-app-id": "Store details were imported, but Apple did not return an App ID. Enter one manually or skip this step.", "app-onboarding-store-link-label": "App Store or Google Play link", "app-onboarding-store-link-placeholder": "https://apps.apple.com/... or https://play.google.com/store/apps/details?id=com.example.app", "app-onboarding-store-screenshot-alt": "Store screenshot preview", @@ -591,7 +656,6 @@ "app-onboarding-app-id-step-helper": "Use your bundle or package ID, import it from a store listing, or skip this step.", "app-onboarding-app-id-step-title": "Set your App ID", "app-onboarding-finish-details": "Create app", - "app-onboarding-v2-ai-help-status": "Existing mobile project; store publication is optional", "app-onboarding-v2-appid-dialog-description": "Capgo uses this App ID to identify the app in the dashboard and CLI. It is often the same as your native project ID, but it does not have to be. Changing it here does not change your iOS Bundle Identifier, Android Application ID, Capacitor appId, or store listing.", "app-onboarding-v2-appid-dialog-title": "How does the Capgo App ID work?", "app-onboarding-v2-appid-help": "Choose the ID Capgo should use for this app. It may differ from your native project IDs.", @@ -863,6 +927,8 @@ "captcha-fail": "Captcha validation failed", "captcha-not-available": "Captcha not available", "captcha-required": "Captcha is required for this action!", + "captcha-unavailable": "Captcha is unavailable. Retry the challenge to continue.", + "captcha-resend-unavailable": "Captcha is unavailable. You can still try to resend the confirmation email, or reload the page to try the challenge again.", "change": "Change", "change-api-key-name": "Change the apikey's name", "edit": "Edit", @@ -977,7 +1043,7 @@ "cli-login-ai-copied": "AI setup prompt copied.", "cli-login-ai-copy": "Copy AI setup prompt", "cli-login-ai-description": "Let your AI assistant guide you through setting up Capgo from start to finish.", - "cli-login-ai-security-warning": "This prompt contains your Capgo API key. Only paste it into an AI assistant you trust.", + "cli-login-ai-security-warning": "This prompt contains your Capgo API key. You can rotate it later if needed.", "cli-login-ai-title": "Set up Capgo with AI", "cli-login-copied": "API key copied.", "cli-login-copy-note": "Copying keeps the key hidden on this page.", @@ -996,8 +1062,16 @@ "cli-login-reveal-dialog-description": "Reveal this key only if your screen is private. You can copy it without showing it instead.", "cli-login-reveal-dialog-title": "Reveal this API key?", "cli-login-reveal-key": "Reveal API key", - "cli-login-security-warning": "Only paste this key into terminals and tools you trust.", - "cli-login-skipped-organizations": "Skipped organizations: {organizations}", + "cli-login-security-warning": "This is your Capgo API key. Paste it only into a trusted terminal or tool. You can rotate it later if needed.", + "cli-login-skipped-description": "This key gives no access to these organizations. You will not be able to set up an app for these organizations:", + "cli-login-skipped-description-empty": "No API key was created. These organizations cannot be set up from this login:", + "cli-login-skipped-hint": "Accept any pending invitation, ask an admin of the organization for the right role, or complete its security requirements, then", + "cli-login-skipped-reason-invite": "You have a pending invitation you have not accepted yet.", + "cli-login-skipped-reason-role": "Your role in the organization cannot manage API keys and member roles.", + "cli-login-skipped-reason-security": "The organization enforces 2FA or a password policy your account does not meet yet.", + "cli-login-skipped-reasons-intro": "Why were they left out? One of these applies:", + "cli-login-skipped-refresh": "refresh this page", + "cli-login-skipped-title": "Some organizations were not included", "cli-login-success-title": "CLI login successful", "cli-login-title": "Connect the Capgo CLI", "cli-login-waiting": "Waiting for the CLI to confirm login", @@ -1015,6 +1089,7 @@ "completed-at": "Completed at", "confirm": "confirm", "confirm-action": "Are you sure you want to set this bundle ?", + "confirm-email-send-failed": "Unable to send the confirmation email. Please try again.", "confirm-email-sent": "A confirmation email was sent click to link to confirm your email", "confirm-overwrite": "Confirm overwrite", "confirm-overwrite-msg": "You are about to create the following overwrite:\n\nDevice ID: $1\nChannel: $2\nChannel version: $3\n\nDoes that sound correct?", @@ -1423,11 +1498,21 @@ "email-not-verified": "Please verify your email before deleting your account.", "email-otp-2fa-description": "Verify your email with a one-time code before enabling 2FA. Verification expires after 1 hour.", "email-otp-2fa-title": "Email verification for 2FA", + "email-otp-back-to-code": "Back to code entry", + "email-otp-enter-description": "A code was sent to this address. Enter it below to continue.", + "email-otp-resend-code": "Send another code", + "email-otp-send-description": "Send a verification code to this address to continue.", + "email-verification-title": "Verify your email", "email-otp-code-required": "Enter the verification code", "email-otp-expired": "Verification expired, please verify again", "email-otp-not-verified": "Not verified", "email-otp-required": "Verify your email before enabling 2FA", "email-otp-send-code": "Send verification code", + "email-otp-send-failed": "Could not send the verification code. Please try again.", + "email-otp-send-wait": "Send again in {seconds}s", + "email-otp-rate-limit-countdown": "Wait {seconds} seconds before sending another code.", + "email-otp-rate-limit-wait": "You can request another code in {seconds} seconds.", + "email-otp-rate-limit-wait-unknown": "You are sending codes too quickly. Please wait a moment and try again.", "email-otp-sent": "Verification code sent", "email-otp-verified": "Email verified for 2FA", "email-otp-verified-until": "Verified until {time}", @@ -1778,6 +1863,12 @@ "name-contains-invalid-characters": "Name contains invalid characters", "name-length-error": "Name length error, please use name between 1 and 32 characters", "name-required": "name is required", + "native-active-devices-android": "Active Android devices", + "native-active-devices-help": "Distinct devices that reported app activity at least once during the period.", + "native-active-devices-ios": "Active iOS devices", + "native-active-devices-last-30-days": "Last 30 Days", + "native-active-devices-selected-period": "Selected period", + "native-active-devices-total": "Total active devices", "native-dependencies": "Native Dependencies", "native-dependencies-description": "Native packages and their versions included in this bundle", "need-more-contact-us": "Need more ? Contact us for tailored plan", @@ -1883,6 +1974,8 @@ "native-observe-volume-help": "Daily native events, launches, WebView loads, and issue counts across all reported app versions.", "native-observe-webview-loads": "WebView loads", "native-observe-webview-p90": "WebView P90", + "native-platform-active-trend": "Android vs iOS active devices", + "native-platform-active-trend-help": "Daily distinct devices that reported a native build, grouped by platform.", "no-native-dependencies-description": "This bundle does not contain any native package information. Native dependencies are captured when uploading with the CLI.", "no-organization-selected": "No organization selected", "no-permission": "Insufficient permissions", @@ -1941,6 +2034,14 @@ "notification-provider-config-help": "Android credentials require projectId. iOS credentials require teamId, keyId, and bundleId.", "notification-provider-secret-ref": "Secret env name", "notification-provider-secret-ref-help": "Capgo reads the private platform key from this secret name. Store the secret before marking the platform configured.", + "notification-provider-secret-ref-self-host-help": "Self-hosted deployments only. Set the worker environment variable name that holds the private key JSON or PEM.", + "notification-provider-secret-upload-help": "Upload your private key here for hosted Capgo. The key is encrypted at rest and never shown again after upload.", + "notification-provider-secret-file-ios": "APNs auth key (.p8)", + "notification-provider-secret-file-android": "FCM service account JSON", + "notification-provider-secret-selected": "Selected file: {file}", + "notification-provider-secret-on-file-ios": "APNs key on file", + "notification-provider-secret-on-file-android": "FCM service account on file", + "notification-provider-self-host-advanced": "Self-host / advanced", "notification-provider-setup": "Platform credentials", "notification-quick-send": "Test send", "notification-queue-unavailable": "Notification queue is not configured", @@ -2036,11 +2137,128 @@ "onboarding-welcome-description": "You’re a few steps away from the magic of over-the-air updates β€” and so much more.", "onboarding-welcome-eyebrow": "Good to have you here", "onboarding-welcome-title": "Welcome to Capgo", + "channel-routing-mockup-kicker": "Channels Β· Default routing", + "channel-routing-mockup-title": "Every device knows where to look.", + "channel-routing-mockup-description": "See how Capgo finds the correct update when a device has no channel assignment yet.", + "channel-routing-mockup-replay": "Replay animation", + "channel-routing-mockup-stage-label": "Animated diagram showing Capgo routing an unassigned device through the default production channel", + "channel-routing-mockup-device": "Device", + "channel-routing-mockup-device-request": "Device request", + "channel-routing-mockup-update-question": "Is an update available?", + "channel-routing-mockup-device-id": "Device ID", + "channel-routing-mockup-capgo-response": "Capgo response", + "channel-routing-mockup-update-available": "Update available", + "channel-routing-mockup-checking": "Checking channel assignment…", + "channel-routing-mockup-not-assigned": "No channel assigned.", + "channel-routing-mockup-using-default": "Using the default channel.", + "channel-routing-mockup-channels-label": "Available channels", + "channel-routing-mockup-default": "Default", + "channel-routing-mockup-takeaway-title": "No assignment needed.", + "channel-routing-mockup-takeaway-description": "Capgo automatically serves updates from your default channel.", + "channel-onboarding-default-kicker": "Channels Β· 1 of 4", + "channel-onboarding-default-title": "How does a device find its first update?", + "channel-onboarding-default-description": "When a device has no channel assignment yet, Capgo automatically checks your default channel.", + "channel-self-assign-kicker": "Channels Β· 2 of 4", + "channel-self-assign-title": "Self-assign devices to a channel", + "channel-self-assign-description": "A user chooses beta inside your app. Then see what the plugin and Capgo do behind the scenes.", + "channel-self-assign-replay": "Replay animation", + "channel-self-assign-stage-label": "Animated iPhone demonstration showing a user opting into a beta update channel", + "channel-self-assign-app-name": "Acme", + "channel-self-assign-demo-date": "Tuesday, August 23", + "channel-self-assign-demo-temperature": "24Β°", + "channel-self-assign-home-title": "Your investments", + "channel-self-assign-home-balance": "$24,680.40", + "channel-self-assign-portfolio-label": "Portfolio value", + "channel-self-assign-portfolio-performance": "+8.4% this month", + "channel-self-assign-home-tab": "Home", + "channel-self-assign-discover-tab": "Discover", + "channel-self-assign-settings": "Settings", + "channel-self-assign-updates": "App updates", + "channel-self-assign-ota-caption": "Over-the-air updates", + "channel-self-assign-current-channel": "Update channel", + "channel-self-assign-production": "Production", + "channel-self-assign-current-version": "Current version", + "channel-self-assign-beta-action": "Become a beta tester", + "channel-self-assign-beta-caption": "Get new features before everyone else", + "channel-self-assign-dialog-title": "Join the beta channel?", + "channel-self-assign-dialog-description": "Beta updates might include our latest features before everyone else. They may be less stable and can contain bugs.", + "channel-self-assign-dialog-confirm": "Okay, become a beta tester", + "channel-self-assign-dialog-cancel": "Not now", + "channel-self-assign-beta-enabled": "Beta tester", + "channel-self-assign-beta-channel": "Beta", + "channel-self-assign-success": "Beta channel enabled", + "channel-self-assign-code-label": "What your app calls", + "channel-self-assign-code-description": "This asks Capgo to validate beta before the plugin remembers the choice locally.", + "channel-self-assign-behind-scenes": "Behind setChannel()", + "channel-self-assign-validation-request": "Can this device self-assign to beta?", + "channel-self-assign-validation-checking": "Checking whether beta allows device self-assignment…", + "channel-self-assign-capgo-validator": "Validating policy", + "channel-self-assign-channel-node": "Requested channel", + "channel-self-assign-channels-label": "Channel policies", + "channel-self-assign-validation-check": "Device self-assignment is enabled", + "channel-self-assign-validation-allowed": "Allowed", + "channel-self-assign-response-title": "Self-assignment allowed", + "channel-self-assign-device-node": "This device", + "channel-self-assign-device-storage-title": "Saved on this device", + "channel-self-assign-device-storage-value": "Assigned channel: beta", + "channel-self-assign-device-storage-caption": "The plugin keeps this choice locally on this device.", + "channel-self-assign-takeaway-title": "Capgo validates. The device remembers.", + "channel-self-assign-takeaway-description": "Capgo approves the channel policy; the plugin stores beta locally for future update checks.", + "channel-console-assign-kicker": "Channels Β· 3 of 4", + "channel-console-assign-title": "Assign a device from the Capgo console", + "channel-console-assign-description": "Open any device and choose a channel override. Capgo will route that device through the selected channel.", + "channel-console-assign-replay": "Replay animation", + "channel-console-assign-stage-label": "Animated Capgo console walkthrough assigning device abc-123 to the dev channel", + "channel-console-assign-org": "Acme organization", + "channel-console-assign-plan-active": "Plan active", + "channel-console-assign-dashboard-heading": "Organization overview", + "channel-console-assign-dashboard-description": "Monitor apps, devices, and update activity across Acme.", + "channel-console-assign-activity": "Update activity", + "channel-console-assign-activity-caption": "Successful updates during the last 30 days", + "channel-console-assign-apps-description": "Select an app to manage its releases, channels, and devices.", + "channel-console-assign-app-dashboard-heading": "App overview", + "channel-console-assign-release-health": "Release health", + "channel-console-assign-release-caption": "Production is serving bundle 1.1.0", + "channel-console-assign-healthy": "Healthy", + "channel-console-assign-devices-description": "Devices that recently contacted Capgo for an update.", + "channel-console-assign-success": "Channel override set", + "channel-console-assign-takeaway": "Device abc-123 now follows the dev channel.", + "channel-create-onboarding-kicker": "Channels Β· 4 of 4", + "channel-create-onboarding-title": "Create your first channel", + "channel-create-onboarding-description": "Put what you just learned into practice by creating a real release route for your app.", + "channel-create-onboarding-loading": "Checking your channel setup…", + "channel-create-onboarding-preview-label": "Your routing setup", + "channel-create-onboarding-preview-placeholder": "your-channel", + "channel-create-onboarding-capgo": "Capgo", + "channel-create-onboarding-default-title": "Default channel", + "channel-create-onboarding-default-description": "Devices without an assignment will automatically check this channel.", + "channel-create-onboarding-self-assign-title": "Allow device self-assignment", + "channel-create-onboarding-self-assign-description": "Optionally let users choose this channel from inside your app.", + "channel-create-onboarding-self-assign-disallow-title": "Disallow device self-assignment", + "channel-create-onboarding-self-assign-disallow-description": "This channel will not allow users to assign their devices to it.", + "channel-create-onboarding-self-assign-short": "Self-assignment", + "channel-create-onboarding-toggle-description": "The plugin can request this channel with setChannel(). You can change this later.", + "channel-create-onboarding-form-title": "Choose your release route", + "channel-create-onboarding-name-placeholder": "production", + "channel-create-onboarding-name-help": "Use letters, numbers, dots, dashes, or underscores.", + "channel-create-onboarding-name-required": "Enter a channel name.", + "channel-create-onboarding-name-invalid": "Use only letters, numbers, dots, dashes, or underscores.", + "channel-create-onboarding-name-taken": "This channel name is already in use. Choose another name.", + "channel-create-onboarding-submit": "Create channel", + "channel-create-onboarding-load-error": "We could not load your channel setup. Please try again.", + "channel-create-onboarding-submit-error": "We could not create this channel. Check the name and try again.", + "channel-create-onboarding-created-label": "Channel created", + "channel-create-onboarding-existing-label": "Channel found", + "channel-create-onboarding-success-title": "Your first channel is ready", + "channel-create-onboarding-success-description": "Capgo now has a route ready for your first over-the-air update.", + "channel-create-onboarding-channel-label": "Release channel", + "channel-create-onboarding-continue": "Continue to install Capgo", "onboarding-wizard-abandoned": "Abandoned", "onboarding-wizard-completed": "Completed", "onboarding-wizard-dropoff": "Onboarding wizard drop-off", "onboarding-wizard-dropoff-description": "Where self-serve users last stopped in the create-app wizard", "onboarding-wizard-not-started": "Not started", + "onboarding-wizard-publish-app-question": "Build and publish", "normal-registration": "Normal registration", "organization-invite": "Organization invite", "org-joins-existing-account": "Org joins from existing accounts", @@ -2048,6 +2266,17 @@ "invite-registrations": "Invite registrations", "invite-join-trend-description": "Daily invite signups and org joins from new invite accounts versus existing accounts", "invite-join-trend": "Invite Join Trend", + "registration-comparison-title": "Capgo registrations by signup method β€” monthly comparison", + "registration-comparison-description": "Current month and four previous months, compared through the same day and time in Warsaw.", + "registration-comparison-cutoff": "Each month: 1st through day {day} at {time} Warsaw time. Five-month comparison, independent of the page date filter.", + "registration-comparison-month": "Month", + "registration-comparison-self-signup": "Self-signup", + "registration-comparison-unknown-other": "Unknown/other", + "registration-comparison-total-tracked": "Total accounts", + "registration-comparison-full-month": "Full month", + "registration-comparison-not-tracked": "Not separately tracked", + "registration-comparison-load-error": "Supabase registration data is temporarily unavailable. Retry to load the comparison.", + "registration-comparison-tracking-note": "Supabase account records, counted once by their creation date, not tracking events. Signup method uses the stored invitation flag: true means organization invitation; false means self-signup. *Unknown/other counts records without a source flag. The flag currently defaults to false, so historical classifications are not independently verified. Existing users joining an organization and invitations awaiting acceptance are not new registrations. Deleted accounts are excluded. Shorter months are shown in full.", "registrations-by-source": "Registrations by source", "registrations-by-source-description": "New authentication accounts grouped by their profile creation state", "apps-onboarding-by-method": "App onboarding by method", @@ -2137,6 +2366,16 @@ "organization-onboarding-invite-subtitle": "Invite teammates now or finish onboarding and do it later from the members page.", "organization-onboarding-invite-success-state": "Invitations sent. You can keep inviting teammates or create your first app.", "organization-onboarding-invite-title": "Invite teammates", + "organization-onboarding-development-environment-option-ai_assistant-desc": "Cursor, Claude, Copilot, Windsurf…", + "organization-onboarding-development-environment-option-ai_assistant-label": "AI coding assistant", + "organization-onboarding-development-environment-option-hand_coded-desc": "VS Code, Neovim, JetBrains…", + "organization-onboarding-development-environment-option-hand_coded-label": "I write the code myself", + "organization-onboarding-development-environment-option-hosted_builder-desc": "Lovable, Bolt, Replit, v0…", + "organization-onboarding-development-environment-option-hosted_builder-label": "Hosted AI builder", + "organization-onboarding-development-environment-option-other-label": "Other", + "organization-onboarding-development-environment-question": "What do you use to build your app?", + "organization-onboarding-development-environment-required": "Pick the tool you use to build your app.", + "organization-onboarding-development-environment-skip": "Skip", "organization-onboarding-existing-users-helper": "Pick the closest current active-user tier. Capgo will start this organization on the matching plan limit.", "organization-onboarding-existing-users-label": "How many active users do you already have?", "organization-onboarding-intent-step": "Your goal", @@ -2150,6 +2389,8 @@ "organization-onboarding-intent-option-both-desc": "I want live updates and cloud native builds.", "organization-onboarding-intent-option-exploring-label": "Just exploring", "organization-onboarding-intent-option-exploring-desc": "I'm taking a look around for now.", + "organization-onboarding-intent-option-publish-label": "Convert my webapp to a mobile app", + "organization-onboarding-intent-option-publish-desc": "Turn my existing website into an iOS and Android app.", "organization-onboarding-intent-required": "Pick what you'd like to do first.", "organization-onboarding-logo-helper": "Recommended: square image, 256x256 or larger", "organization-onboarding-logo-alt": "{name} logo", @@ -2199,6 +2440,10 @@ "organization-onboarding-website-label": "Website", "organization-onboarding-website-name-helper": "Imported from your website. You can still edit the organization name before continuing.", "organization-onboarding-website-name-helper-empty": "Run the website import first, then review the generated organization name here.", + "organization-onboarding-webnative-check-website": "Check WebNativeApp", + "organization-onboarding-webnative-continue-capgo": "Continue with Capgo", + "organization-onboarding-webnative-description": "If your web app is already live at a public URL, WebNativeApp can package it for iOS and Android and keep it updated from your website - without setting up the Capgo CLI.", + "organization-onboarding-webnative-title": "WebNativeApp may be a better fit", "organization-onboarding-delete-imported-details": "Delete imported details", "organization-onboarding-what-next": "What happens next", "organizations": "Organizations", @@ -2268,6 +2513,7 @@ "pending-invite-title": "Join your organization", "pending-invite-title-multiple": "Choose an organization", "plan": "Plan", + "plan-active": "Plan active", "plan-bandwidth": "GB Bandwidth", "plan-desc": "Start building for free, then add a plan to go live.", "plan-distribution": "Plan Distribution", @@ -2284,6 +2530,7 @@ "plan-feature-soc2-certified": "SOC 2 certified", "plan-full-comparison-link": "See the full plan comparison for details.", "plan-inactive": "Plan inactive", + "plan-limit-reached": "Plan limit reached", "plan-maker": "Best for small business owners", "plan-native-build-concurrency": "{count} concurrent native builds", "plan-page-warn": "Buying a plan will ONLY affect the \"%ORG_NAME%\" organization.", @@ -2610,7 +2857,7 @@ "sending-invitation": "Sending...", "session_key": "IvSessionKey", "set-bundle": "Set bundle to channel", - "set-even-not-compatible": "This bundle is not compatible with the channel. Would you like to set it anyway? Open the Dependencies page to see which native packages changed.", + "set-even-not-compatible": "This bundle's native dependencies don't match the channel. If your app already handles missing native plugins at runtime, you can accept this incompatibility and set it anyway.", "set-expiration-date": "Set expiration date", "setting-change-details": "Setting Change Details", "setting-changed": "Setting Changed", @@ -2803,6 +3050,7 @@ "transfer-app-ownership-too-soon": "You can only transfer apps every 32 days", "trial-banner-cta": "View plans", "trial-banner-message": "Enjoying your Capgo trial? Subscribe to a plan.", + "trial-days-left": "{count} days left", "trial-end-date": "Trial End Date", "trial-ended-description": "Choose a plan to continue using Capgo updates and other features. If you didn’t get a chance to explore Capgo during your trial, email {supportEmail} to request an extension.", "trial-ended-plans-description": "Choose a plan to continue using Capgo.", @@ -2813,6 +3061,7 @@ "extended-trial-subscriptions": "Extended Trial Subscriptions", "trial-left": "days left", "trial-organizations-list": "Trial Organizations List", + "trial-over": "Trial over", "trial-plan-expired": "Trial Expired", "try-a-different-search-term": "Try a different search term", "type": "Type", @@ -2844,14 +3093,22 @@ "unsafe": "Unsafe", "update": "Update", "update-password-now": "Update Password Now", - "update-package": "Update package", - "update-package-all": "Zip and delta", + "update-package": "Download format", + "update-package-all": "Zip + delta (default)", + "update-package-all-description": "Devices that support delta get changed files only; others download the full zip.", "update-package-delta": "Delta only", - "update-package-delta-from-builtin": "Delta only from builtin", + "update-package-delta-description": "Only changed files are served. Devices without delta support cannot update until you switch to a format that serves a full zip.", + "update-package-delta-from-builtin": "Delta from store build only", + "update-package-delta-from-builtin-description": "While the device still matches the native store version, serve delta only. Other devices keep zip + delta.", "update-package-delta-required": "This channel requires a delta package, but the bundle has no delta files. Upload with delta enabled, or switch the channel to zip only or zip and delta.", - "update-package-help": "Choose whether devices download a full zip, a delta of changed files, or both. Restrict zip-only or delta-only delivery to devices still on the store builtin version if needed.", - "update-package-zip": "Zip only", - "update-package-zip-from-builtin": "Zip only from builtin", + "update-package-help": "Controls what each device downloads on update check. Delta requires manifests from uploads with --delta. Store-only modes apply only while the device still matches the native app store version.", + "update-package-info": "Download format information", + "update-package-zip": "Full zip only", + "update-package-zip-description": "Every device downloads the whole bundle zip, even when a delta manifest exists.", + "update-package-zip-from-builtin": "Full zip from store build only", + "update-package-zip-from-builtin-description": "While the device still matches the native store version, serve the full zip. Other devices keep zip + delta.", + "confirm-update-package-title": "Change download format?", + "confirm-update-package-description": "Change from {current} to {next}. Devices may receive a different package type on their next update check.", "update-package-zip-required": "This channel requires a zip package, but the bundle has no zip. Upload a full zip without --delta-only, or switch the channel to delta only or zip and delta.", "update-delivery-devices": "Devices measured", "update-delivery-latency": "Time to deliver an update", @@ -2888,6 +3145,7 @@ "upload-img-error": "Cannot upload picture, please check console", "url": "URL", "usage": "Usage", + "using-credits": "Using credits", "usage-success": "Your plan has been set up. This page will update in a few minutes", "use-capgo": "Use Capgo", "used-in-period": "Used in period", @@ -3020,7 +3278,8 @@ "rollout-target": "Target", "stable-fallback": "Stable fallback", "channel-version-with-rollout": "{fallback} Β· serving {target}", - "rollout-settings-help": "Selected devices receive this bundle. Everyone else stays on the stable fallback. Linking a new channel bundle resets this rollout unless you set a new target at the same time.", + "rollout-settings-info": "Progressive rollout information", + "rollout-settings-help": "Stable fallback stays for most devices. A random, sticky share receives the rollout target until you promote or roll back. Rollout decisions are cached for the TTL below.", "rollout-delivery-all-title": "All devices receive {target}", "rollout-delivery-split-title": "{percent} of devices receive {target}", "rollout-delivery-fallback-title": "Devices receive {fallback}", @@ -3028,8 +3287,51 @@ "rollout-delivery-paused-hint": "Rollout is paused, so target {target} is not served.", "rollout-delivery-zero-hint": "Rollout is 0%, so target {target} is not served.", "rollout-percentage": "Rollout percentage", + "rollout-percentage-help": "Share of eligible devices in the sticky rollout cohort. Increasing keeps existing selections and adds more; decreasing randomly removes some. At 0%, no new devices enter while rollout stays enabled.", + "apply-rollout-percentage": "Apply percentage", + "rollout-percentage-placeholder": "0–100, sticky share of devices", + "cache-ttl-placeholder": "Seconds Capgo caches the rollout decision", + "cache-ttl-help": "Rollout selection is cached server-side for this TTL. Shorter values pick up percentage changes faster; longer values reduce backend evaluation work.", + "invalid-rollout-cache-ttl": "Cache TTL must be between 60 and 31536000 seconds", + "rollout-settings-unchanged": "Rollout percentage and cache TTL are already set to these values", + "confirm-rollout-settings-title": "Update rollout settings?", + "confirm-rollout-settings-description": "Apply {percent} rollout and a cache TTL of {ttl}.", + "cache-ttl-display": "{human} ({seconds}s)", + "hour": "hour", + "hours": "hours", + "minute": "minute", + "minutes": "minutes", + "second": "second", + "seconds": "seconds", + "auto-pause-info": "Auto-pause information", + "auto-pause-help": "When failure rate in the window exceeds the threshold with enough attempts, Capgo pauses, rolls back, or notifies. Cooldown prevents repeat triggers.", + "auto-pause-settings-unchanged": "Auto-pause settings are already set to these values", + "confirm-auto-pause-settings-title": "Update auto-pause?", + "confirm-auto-pause-settings-description": "Save auto-pause rules. Devices already on the rollout target stay until pause, rollback, or promote.", + "failure-rate-placeholder": "Threshold in basis points (100 = 1%)", + "auto-pause-action-placeholder": "What to do when the threshold trips", + "window-minutes-placeholder": "Lookback window for the failure rate", + "confidence-placeholder": "Statistical confidence, between 0 and 1", + "min-attempts-placeholder": "Minimum update attempts before acting", + "min-failures-placeholder": "Minimum failures before acting", + "cooldown-minutes-placeholder": "Wait time after an auto-pause", "rollout-target-linked": "Rollout target linked", "invalid-rollout-percentage": "Rollout percentage must be between 0 and 100", + "rollout-percentage-unchanged": "Rollout percentage is already {percent}", + "confirm-rollout-percentage-title": "Apply rollout percentage?", + "confirm-rollout-percentage-description": "Change from {current} to {next}. Capgo adjusts the sticky cohort using increase or decrease rules.", + "confirm-enable-rollout-title": "Enable progressive rollout?", + "confirm-enable-rollout-description": "Eligible devices may receive {target} on their next update check. Stable fallback {fallback} remains for devices outside the {percent} cohort.", + "confirm-pause-rollout-title": "Pause rollout?", + "confirm-pause-rollout-description": "No new devices enter the cohort. Devices already on {target} keep it until you resume or roll back.", + "confirm-resume-rollout-title": "Resume rollout?", + "confirm-resume-rollout-description": "New eligible devices can enter the {percent} cohort again. Devices already selected stay on {target}.", + "confirm-promote-rollout-title": "Complete progressive rollout?", + "confirm-promote-rollout-description": "{target} becomes stable fallback for everyone. The previous stable bundle is replaced and rollout settings reset.", + "confirm-rollback-rollout-title": "Roll back progressive rollout?", + "confirm-rollback-rollout-description": "Before: devices in the rollout cohort can receive {target} (stable fallback stays {fallback}). After: rollout turns off, percentage resets to 0%, and every device receives only stable {fallback} on the next update check.", + "confirm-set-rollout-target-title": "Change rollout target?", + "confirm-set-rollout-target-description": "Before: rollout target is {current} at {percent} of devices (stable fallback {fallback}). After: target becomes {next}. Devices in the cohort will receive {next} on their next update check; others keep stable {fallback}.", "manual-rollout-pause": "Paused manually", "not-configured": "Not configured", "pause": "Pause", @@ -3040,7 +3342,7 @@ "failure-rate-bps": "Failure bps", "current-rollout-target": "Current rollout target", "rollout-policy": "Rollout policy", - "cache-ttl-seconds": "Cache TTL seconds", + "cache-ttl-seconds": "Cache TTL", "auto-pause-action": "Action", "window-minutes": "Window minutes", "confidence": "Confidence", @@ -3089,6 +3391,7 @@ "app-onboarding-continue-to-org": "Continue to organization", "unified-onboarding-step-intent": "Your goal", "unified-onboarding-step-organization": "Organization", + "unified-onboarding-step-channel": "Channel", "unified-onboarding-step-setup": "Next step", "unified-onboarding-setup-builder-title": "Finish setup with Capgo Builder", "unified-onboarding-setup-builder-subtitle": "Run the builder onboarding command in your app project to set up cloud native builds.", @@ -3247,6 +3550,14 @@ "frontend-onboarding-setup-cli-outcomes-v2-v3": "Setup β†’ CLI outcomes (v2 and v3)", "frontend-onboarding-setup-cli-outcomes-v2-v4": "Setup β†’ CLI outcomes (v2–v4)", "frontend-onboarding-setup-cli-outcomes-description": "Unique people who reached setup, grouped by their CLI and copied AI instruction activity within 24 hours", + "frontend-onboarding-cli-checklist-coverage-v4": "CLI checklist coverage after web onboarding v4+", + "frontend-onboarding-cli-checklist-coverage-description": "Current checklist state for active apps created through web onboarding v4+ with the selected checklist version. Bars show the percentage marked done; skipped steps are listed separately.", + "frontend-onboarding-cli-checklist-version": "Checklist version", + "frontend-onboarding-cli-checklist-version-1": "v1 Β· Add your app", + "frontend-onboarding-cli-checklist-version-2": "v2 Β· CLI/MCP login", + "frontend-onboarding-cli-checklist-step-detail": "{count} skipped", + "frontend-onboarding-cli-checklist-unavailable": "{count} linked apps are no longer available and are excluded.", + "frontend-onboarding-apps": "apps", "frontend-onboarding-setup-cli-only": "Started CLI", "frontend-onboarding-setup-cli-and-ai": "Started CLI + AI instructions", "frontend-onboarding-setup-no-cli": "Didn't start CLI", @@ -3275,5 +3586,144 @@ "frontend-onboarding-daily-setup-cli-no-action": "No action", "frontend-onboarding-people": "people", "frontend-onboarding-no-dropoff": "No drop-off", - "frontend-onboarding-transition": "{from} β†’ {to}" -} + "frontend-onboarding-transition": "{from} β†’ {to}", + "admin-ab-tests": "A/B Tests", + "admin-ab-tests-description": "Assignment distribution across every experiment.", + "admin-ab-tests-total": "{tests} tests Β· {assignments} assignments", + "admin-ab-tests-experiment": "Experiment", + "admin-ab-tests-treatment": "Treatment", + "admin-ab-tests-control": "Control", + "admin-ab-tests-assignments": "{count} assignments", + "admin-ab-tests-channel-animation-description": "Behavior within each onboarding animation (treatment only).", + "admin-ab-tests-channel-animation-progress": "Animation progress", + "admin-ab-tests-channel-animation-title": "Animation diagnostics", + "admin-ab-tests-channel-cohort": "Run or accessibility path", + "admin-ab-tests-channel-cohort-automatic": "Automatic first run", + "admin-ab-tests-channel-cohort-reduced-motion": "Reduced motion", + "admin-ab-tests-channel-cohort-replay": "Replay viewers", + "admin-ab-tests-channel-cohort-title": "Behavior breakdown β€” {stage}", + "admin-ab-tests-channel-cohort-unavailable": "Animation unavailable", + "admin-ab-tests-channel-complete-short": "Complete {percent}", + "admin-ab-tests-channel-completed": "Completed first run", + "admin-ab-tests-channel-completion": "Completed", + "admin-ab-tests-channel-continued": "Continued to next step", + "admin-ab-tests-channel-continued-short": "Continued", + "admin-ab-tests-channel-description": "Channel creation within {hours} hours of assignment, measured across the full guided flow.", + "admin-ab-tests-channel-diagnostic-note": "Stage diagnostics explain behavior; only treatment versus control measures experiment impact.", + "admin-ab-tests-channel-difference": "Difference", + "admin-ab-tests-channel-interrupted": "Interrupted", + "admin-ab-tests-channel-median-skip": "Median skip point", + "admin-ab-tests-channel-median-watch": "Median watch", + "admin-ab-tests-channel-minutes-seconds": "{minutes}m {seconds}s", + "admin-ab-tests-channel-pending": "{count} still inside the {hours}-hour outcome window", + "admin-ab-tests-channel-posthog-unavailable": "PostHog animation events are temporarily unavailable. The treatment versus control outcome above still comes from database audit data.", + "admin-ab-tests-channel-posthog-unconfigured": "PostHog read access is not configured. The treatment versus control outcome above still comes from database audit data.", + "admin-ab-tests-channel-reached-short": "Reached {count}", + "admin-ab-tests-channel-replays": "Replay requests", + "admin-ab-tests-channel-retention-aria": "First-run viewers remaining by animation progress, with skip markers", + "admin-ab-tests-channel-retention-description": "First automatic run. Orange markers group skips into 25% progress ranges.", + "admin-ab-tests-channel-retention-title": "Viewers remaining by animation progress", + "admin-ab-tests-channel-seconds": "{count}s", + "admin-ab-tests-channel-skip-bucket": "{count} skipped between {from}% and {to}%", + "admin-ab-tests-channel-skip-short": "Skip {percent}", + "admin-ab-tests-channel-skipped": "Skipped first run", + "admin-ab-tests-channel-skips": "Skips", + "admin-ab-tests-channel-small-sample": "Small samples can move sharply. Use stage behavior to find friction, not to declare an animation winner.", + "admin-ab-tests-channel-stage-console-assign": "Console assignment", + "admin-ab-tests-channel-stage-metrics": "Stage metrics β€” {stage}", + "admin-ab-tests-channel-stage-routing": "Default routing", + "admin-ab-tests-channel-stage-selector": "Select an onboarding animation", + "admin-ab-tests-channel-stage-self-assign": "Device self-assignment", + "admin-ab-tests-channel-started": "Started", + "admin-ab-tests-channel-status-collecting": "Too early to call", + "admin-ab-tests-channel-status-collecting-description": "Collecting data until both branches have at least {count} eligible people.", + "admin-ab-tests-channel-status-confidence": "Result status uses the 95% confidence interval; {confidence} confidence comes from the pooled two-proportion z-test.", + "admin-ab-tests-channel-status-control-ahead": "Control ahead", + "admin-ab-tests-channel-status-inconclusive": "No clear difference", + "admin-ab-tests-channel-status-treatment-ahead": "Treatment ahead", + "admin-ab-tests-channel-title": "Channel creation β€” overall result", + "admin-ab-tests-channel-updated": "Updated {date}", + "admin-ab-tests-channel-users": "Users", + "admin-ab-tests-channel-viewer-count": "{count} viewers remaining", + "admin-ab-tests-channel-viewers": "Viewers remaining", + "admin-ab-tests-publish-outcome-title": "New Publish intent outcome", + "admin-ab-tests-publish-outcome-description": "Unique people who saw the new Publish intent through either experiment.", + "admin-ab-tests-publish-outcome-cohort": "Publish intent treatment or Development environment treatment Β· duplicates counted once", + "admin-ab-tests-publish-outcome-exposed": "unique people exposed", + "admin-ab-tests-publish-outcome-selected-publish": "Selected Publish", + "admin-ab-tests-publish-outcome-selected-another-intent": "Selected another intent", + "admin-ab-tests-publish-outcome-no-selection-yet": "No selection yet", + "admin-ab-tests-publish-outcome-inferred-note": "Some intent selections were inferred from organization data because an earlier onboarding issue removed them from user onboarding ({count} inferred).", + "admin-ab-tests-development-environment-title": "What do you use to build your app?", + "admin-ab-tests-development-environment-description": "Saved development environment answers among people assigned to the treatment cohort.", + "admin-ab-tests-development-environment-cohort": "Development environment treatment C / 5.C Β· assignment cohort, not necessarily reached the question Β· later onboarding versions with the same C assignment included", + "admin-ab-tests-development-environment-assigned": "unique people assigned", + "admin-ab-tests-development-environment-ai-assistant": "AI assistant", + "admin-ab-tests-development-environment-hosted-builder": "Hosted AI builder", + "admin-ab-tests-development-environment-other": "Other", + "admin-ab-tests-development-environment-hand-coded": "I write code by hand", + "admin-ab-tests-development-environment-no-selection-yet": "Did not select yet", + "admin-ab-tests-development-environment-ai-assistant-note": "AI assistant includes Claude, Cursor, Codex and other AI coding assistants.", + "admin-ab-tests-development-environment-no-selection-note": "Did not select yet includes missing, unavailable or skipped answers and unsupported legacy values.", + "admin-ab-tests-development-environment-empty": "No people have been assigned to this cohort yet.", + "admin-ab-question-flow-title": "App-building question flow", + "admin-ab-question-flow-description": "Reached β€œWhat do you use to build your app?” β†’ answered or skipped β†’ continued or did not continue.", + "admin-ab-question-flow-reached": "Reached β€œWhat do you use to build your app?”", + "admin-ab-question-flow-answered": "Answered", + "admin-ab-question-flow-skipped": "Skipped", + "admin-ab-question-flow-no-answer": "No answer recorded", + "admin-ab-question-flow-continued": "Continued", + "admin-ab-question-flow-did-not-continue": "No recorded continuation", + "admin-ab-question-flow-percent-reached": "{percent}% of question viewers", + "admin-ab-question-flow-percent-group": "{percent}% of this group", + "admin-ab-question-flow-excluded-note": "{count} recorded question views were excluded because person or visit tracking was incomplete. Graph counts cover views with complete tracking.", + "admin-ab-question-flow-empty": "No question views with complete tracking in this period.", + "admin-ab-question-flow-unavailable": "Question flow is unavailable. Refresh to try again; unavailable tracking is not counted as zero.", + "admin-ab-question-flow-counting-note": "Counts use recorded production question views, not experiment assignments. Each person is counted once: their first recorded continuation, or their latest question attempt if none continued. Answered includes selecting an option before leaving. Skip moves to the next step immediately.", + "admin-ab-question-flow-continuation-note": "Continued means a recorded completion or a view of the next step directly from this question in the same attempt and period. No recorded continuation may include people who return later or whose tracking is missing.", + "admin-ab-tests-environment-intent-title": "{group}: selected intent", + "admin-ab-tests-environment-intent-description": "Saved intent among people in the selected app-building group.", + "admin-ab-tests-environment-intent-group": "App-building tool", + "admin-ab-tests-environment-intent-people": "unique people Β· {group}", + "admin-ab-tests-environment-intent-note": "Counts and percentages use the selected group only. Intent is read from each person's saved onboarding answer, not inferred from organizations. Did not select yet includes missing, skipped and unsupported values.", + "admin-ab-tests-environment-intent-empty": "No people in this cohort belong to the {group} group yet.", + "admin-ab-tests-load-error": "Unable to load A/B test data. Please try again.", + "admin-ab-tests-empty": "No A/B tests are configured.", + "onboarding-payment-cohorts-title": "Self-Serve Signup β†’ First Payment Conversion", + "onboarding-payment-cohorts-scope": "Current UTC month and the three previous months, newest first. Independent of chart date, app and organization filters.", + "onboarding-payment-cohorts-refresh": "Refresh payment cohorts", + "onboarding-payment-cohorts-retry": "Retry payment cohorts", + "onboarding-payment-cohorts-loading": "Loading payment cohorts…", + "onboarding-payment-cohorts-error": "Payment cohorts are unavailable. A required source failed or returned incomplete data. Please retry.", + "onboarding-payment-cohorts-month": "Signup month (UTC)", + "onboarding-payment-cohorts-signups": "Included self-serve signups", + "onboarding-payment-cohorts-no-public-row": "Missing public row excluded", + "onboarding-payment-cohorts-invites": "Organization invites excluded", + "onboarding-payment-cohorts-3-days": "Within 3 days", + "onboarding-payment-cohorts-7-days": "Within 7 days", + "onboarding-payment-cohorts-14-days": "Within 14 days", + "onboarding-payment-cohorts-ever": "Ever paid", + "onboarding-payment-cohorts-unavailable": "Unavailable", + "onboarding-payment-cohorts-cutoff": "Observation cutoff: {cutoff} UTC (exclusive, start of the current UTC day).", + "onboarding-payment-cohorts-last-sync": "Last invoice sync: {synced} (UTC when available).", + "onboarding-payment-cohorts-sync-mode": "Sync mode: {mode}.", + "onboarding-payment-cohorts-fallbacks": "Credit timestamp fallbacks: {count} confirmed credit grants without an exact matching invoice used their confirmed grant timestamp.", + "onboarding-payment-cohorts-append-warning": "Append or incremental invoice sync may miss later updates to existing invoices, including a later paid status. Conversion can therefore be undercounted even after a recent sync.", + "onboarding-payment-cohorts-methodology": "Methodology and source limitations", + "onboarding-payment-cohorts-cell-note": "Conversion cells show paid / eligible users and the percentage. Unavailable means no users are eligible, not 0% conversion.", + "onboarding-payment-cohorts-signup-note": "Signup time is auth.users.created_at. First exclude users without a current public.users row, then exclude public.users.created_via_invite. Payments count only for organizations owned through orgs.created_by, not organization membership alone.", + "onboarding-payment-cohorts-window-note": "The full 3, 7 or 14 elapsed 24-hour days must have passed for both numerator and denominator, even for users who already paid. Payment must occur strictly before the window deadline; all observations are strictly before the UTC cutoff.", + "onboarding-payment-cohorts-source-note": "Sources: Supabase signup and ownership records, confirmed positive stripe_top_up credit purchases, and PostHog-synced live paid positive Stripe invoices using actual paid_at. Subscription flags (stripe_info) are not payment evidence.", + "onboarding-payment-cohorts-credit-note": "An exact matching paid credit invoice supplies the payment timestamp even when it is outside the conversion range. Only an individually missing matching invoice falls back to the confirmed credit grant timestamp.", + "onboarding-payment-cohorts-ever-note": "Ever paid is gross first-payment conversion observed through the cutoff, with unequal follow-up across cohorts. It is not net revenue or current-payer status; conversion remains counted after later refunds or cancellations.", + "onboarding-payment-cohorts-snapshot-note": "Invoice data is a synced snapshot, not a guarantee of complete current Stripe data. Freshness metadata may be unavailable without invalidating payments; failure of the required invoice source makes the entire table unavailable.", + "app-onboarding-exploration-reminder-title": "Continue exploring or return to setup?", + "app-onboarding-exploration-reminder-description": "It looks like you have been exploring the dashboard. Would you like to continue exploring or come back to the setup?", + "app-onboarding-exploration-reminder-continue": "Continue exploring", + "app-onboarding-exploration-reminder-setup": "Come back to the setup", + "app-onboarding-ai-help-prompt": "I am setting up Capgo for my mobile app and I need step-by-step help with the CLI install.\n\nCapgo context:\n- App name: {appName}\n- App ID: {appId}\n- App status: {appStatus}\n- The Capgo app is already created in the dashboard.\n- Command:\n{command}\n\nPlease help me step by step.\n1. Tell me exactly where I should run this command.\n2. Ask me one question at a time if you need to know my stack (Capacitor, Ionic, React Native, native iOS/Android, etc.).\n3. Help me verify the installation succeeded.\n4. {apiKeyGuidance}", + "app-onboarding-ai-help-status-existing": "Existing published app", + "app-onboarding-ai-help-status-new": "New app not published yet", + "app-onboarding-ai-help-with-key": "I explicitly consented to include my Capgo API key in this message so you can use the command as provided. Continue without warning me about the pasted key, and do not repeat the API key in your response.", + "app-onboarding-v2-ai-help-status": "Existing mobile project; store publication is optional" +} \ No newline at end of file diff --git a/package.json b/package.json index 8e623f88b2..b8b9c71482 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "capgo-app", "type": "module", - "version": "12.266.4", + "version": "12.292.1", "private": true, "workspaces": [ "cli", @@ -85,6 +85,8 @@ "check:plugin-runtime": "bun scripts/check_plugin_runtime_isolation.mjs", "check:plugin-worker-size": "bun scripts/check_plugin_worker_size.ts", "test:unit": "vitest run tests/*.unit.test.ts", + "security:sync-headers": "bun scripts/sync-console-headers.ts", + "security:compute-integrity": "bun scripts/compute-external-integrity.ts", "test:db": "bash scripts/test-db-tinbase.sh", "test:backend:integration": "bun run supabase:with-env -- bunx vitest run --exclude=tests/*.unit.test.ts --exclude=tests/cli*", "test:cloudflare:integration": "vitest run --exclude=tests/cli* --exclude=tests/*.unit.test.ts --config vitest.config.cloudflare.ts", @@ -406,8 +408,9 @@ "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" }, diff --git a/packages/capacitor-notifications/package.json b/packages/capacitor-notifications/package.json index 1f3a855e64..b57187fdd0 100644 --- a/packages/capacitor-notifications/package.json +++ b/packages/capacitor-notifications/package.json @@ -1,6 +1,6 @@ { "name": "@capgo/capacitor-notifications", - "version": "0.1.13", + "version": "0.1.15", "description": "Capgo native push notifications, badge, and live-update trigger plugin for Capacitor apps.", "license": "AGPL-3.0-only", "type": "module", @@ -20,6 +20,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", + "check:cap9-deprecated": "node ../../scripts/check-cap9-deprecated.mjs packages/capacitor-notifications", "prepublishOnly": "bun run typecheck && bun run build" }, "peerDependencies": { diff --git a/packages/capacitor-notifications/src/version.ts b/packages/capacitor-notifications/src/version.ts index 4531fd8b88..d98ac28d9e 100644 --- a/packages/capacitor-notifications/src/version.ts +++ b/packages/capacitor-notifications/src/version.ts @@ -1 +1 @@ -export const PLUGIN_VERSION = '0.1.13' +export const PLUGIN_VERSION = '0.1.15' diff --git a/playwright/e2e/app-dashboard-tabs.spec.ts b/playwright/e2e/app-dashboard-tabs.spec.ts index be786b6410..0492aa4901 100644 --- a/playwright/e2e/app-dashboard-tabs.spec.ts +++ b/playwright/e2e/app-dashboard-tabs.spec.ts @@ -32,6 +32,10 @@ test.describe('App dashboard sections', () => { await expect(page).toHaveURL(/\/app\/com\.demo\.app\/native(?:\?|$)/) await expect(nativeTab).toHaveAttribute('aria-current', 'page') await expect(page.getByRole('heading', { name: 'Native build by platform' })).toBeVisible() + await expect(page.getByText('Active Android devices').first()).toBeVisible() + await expect(page.getByText('Active iOS devices').first()).toBeVisible() + await expect(page.getByText('Total active devices').first()).toBeVisible() + await expect(page.getByText('Android vs iOS active devices')).toBeVisible() await expect(page.locator('[data-testid="bundle-install-stats"]')).toHaveCount(0) await installsTab.click() diff --git a/playwright/e2e/channel-rollout-ux-screenshots.spec.ts b/playwright/e2e/channel-rollout-ux-screenshots.spec.ts new file mode 100644 index 0000000000..89df51af29 --- /dev/null +++ b/playwright/e2e/channel-rollout-ux-screenshots.spec.ts @@ -0,0 +1,154 @@ +import { mkdirSync } from 'node:fs' +import { resolve } from 'node:path' +import { expect, test } from '../support/commands' +import { dismissSupportPrompt } from '../support/dismissSupportPrompt' + +const screenshotDir = resolve(process.cwd(), 'docs/pr-screenshots/pr-3340') + +async function dismissOptionalPrompts(page: import('@playwright/test').Page) { + await dismissSupportPrompt(page) + const remindLater = page.getByRole('button', { name: 'Remind me later', exact: true }) + if (await remindLater.isVisible().catch(() => false)) + await remindLater.click() +} + +// Local-only: CAPGO_PR_SCREENSHOTS=1 bunx playwright test playwright/e2e/channel-rollout-ux-screenshots.spec.ts +test.describe('PR 3340 channel UX screenshots', () => { + test.skip(!process.env.CAPGO_PR_SCREENSHOTS, 'Set CAPGO_PR_SCREENSHOTS=1 for local PR screenshot capture') + + test.beforeAll(() => { + mkdirSync(screenshotDir, { recursive: true }) + }) + + test('capture download format and rollout confirmation UI', async ({ page }) => { + await page.route('**/private/sso/check-enforcement', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ allowed: true }), + }) + }) + await page.login('test@capgo.app', 'testtest') + await page.goto('/app/com.demo.app/channel/1') + await dismissOptionalPrompts(page) + await expect(page.getByText('Download format', { exact: false }).first()).toBeVisible({ timeout: 30000 }) + + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) + const editSettings = page.locator('[data-test="rollout-settings-edit"]') + await expect(editSettings).toBeEnabled({ timeout: 60000 }) + await editSettings.scrollIntoViewIfNeeded() + const infoButton = page.getByRole('button', { name: 'Progressive rollout information' }) + await expect(infoButton).toBeVisible() + await page.screenshot({ + path: resolve(screenshotDir, '05-rollout-apply-controls.png'), + fullPage: false, + }) + await infoButton.click() + await expect(page.locator('h3').filter({ hasText: 'Progressive rollout' })).toBeVisible() + await expect(page.getByText('Stable fallback stays for most devices')).toBeVisible() + await page.screenshot({ + path: resolve(screenshotDir, '08-rollout-settings-info-modal.png'), + }) + await page.getByRole('button', { name: 'Close', exact: true }).click() + await editSettings.click() + const percentInput = page.locator('#rollout-percentage-input') + await expect(percentInput).toBeVisible() + await percentInput.fill('25', { force: true }) + const applyPercent = page.getByRole('button', { name: 'Apply', exact: true }).first() + await expect(applyPercent).toBeEnabled({ timeout: 15000 }) + await applyPercent.click() + await expect(page.locator('h3').filter({ hasText: 'Update rollout settings?' })).toBeVisible() + await page.screenshot({ + path: resolve(screenshotDir, '04-rollout-percentage-confirm.png'), + }) + await page.getByRole('button', { name: 'Cancel', exact: true }).click() + + await page.evaluate(() => window.scrollTo(0, 0)) + await page.screenshot({ + path: resolve(screenshotDir, '01-download-format-section.png'), + fullPage: false, + }) + + const formatInfo = page.getByRole('button', { name: 'Download format information' }) + await expect(formatInfo).toBeVisible() + await formatInfo.click() + await expect(page.locator('h3').filter({ hasText: 'Download format' })).toBeVisible() + await expect(page.getByText('Controls what each device downloads on update check')).toBeVisible() + await page.screenshot({ + path: resolve(screenshotDir, '09-download-format-info-modal.png'), + }) + await page.getByRole('button', { name: 'Close', exact: true }).click() + + const formatSummary = page.locator('summary').filter({ hasText: /Zip \+ delta|Full zip|Delta only/ }).first() + await formatSummary.click() + await expect(page.getByText('Delta only', { exact: true }).first()).toBeVisible() + await page.screenshot({ + path: resolve(screenshotDir, '02-download-format-dropdown.png'), + }) + + await page.getByRole('button', { name: /^Delta only Only changed/ }).click() + await expect(page.locator('h3').filter({ hasText: 'Change download format?' })).toBeVisible({ timeout: 15000 }) + await page.screenshot({ + path: resolve(screenshotDir, '03-download-format-confirm.png'), + }) + await page.getByRole('button', { name: 'Cancel', exact: true }).click() + }) + + test('capture bordered rollout actions and rollback confirm', async ({ page }) => { + await page.route('**/private/sso/check-enforcement', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ allowed: true }), + }) + }) + await page.login('test@capgo.app', 'testtest') + await page.goto('/app/com.demo.app/channel/1') + await dismissOptionalPrompts(page) + await expect(page.getByText('Progressive rollout', { exact: false }).first()).toBeVisible({ timeout: 30000 }) + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) + await expect(page.getByRole('button', { name: 'Promote', exact: true })).toBeVisible({ timeout: 60000 }) + + const actionRow = page.locator('button', { hasText: 'Promote' }).locator('xpath=ancestor::div[contains(@class,"flex-wrap")][1]') + await actionRow.scrollIntoViewIfNeeded() + await page.screenshot({ + path: resolve(screenshotDir, '06-rollout-action-buttons-bordered.png'), + fullPage: false, + }) + + await page.getByRole('button', { name: 'rollback', exact: true }).click() + await expect(page.locator('h3').filter({ hasText: 'Roll back progressive rollout?' })).toBeVisible({ timeout: 15000 }) + await page.screenshot({ + path: resolve(screenshotDir, '07-rollout-rollback-confirm.png'), + }) + await page.getByRole('button', { name: 'Cancel', exact: true }).click() + }) + + test('capture rollout settings info icon and modal', async ({ page }) => { + await page.route('**/private/sso/check-enforcement', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ allowed: true }), + }) + }) + await page.login('test@capgo.app', 'testtest') + await page.goto('/app/com.demo.app/channel/1') + await dismissOptionalPrompts(page) + await expect(page.getByText('Progressive rollout', { exact: false }).first()).toBeVisible({ timeout: 30000 }) + const infoButton = page.getByRole('button', { name: 'Progressive rollout information' }) + await expect(infoButton).toBeVisible({ timeout: 60000 }) + await infoButton.scrollIntoViewIfNeeded() + await page.screenshot({ + path: resolve(screenshotDir, '08-rollout-settings-info-icon.png'), + fullPage: false, + }) + await infoButton.click() + await expect(page.locator('h3').filter({ hasText: 'Progressive rollout' })).toBeVisible() + await expect(page.getByText('Stable fallback stays for most devices')).toBeVisible() + await page.screenshot({ + path: resolve(screenshotDir, '08-rollout-settings-info-modal.png'), + fullPage: false, + }) + }) +}) diff --git a/playwright/e2e/confirmation-redirect.spec.ts b/playwright/e2e/confirmation-redirect.spec.ts new file mode 100644 index 0000000000..95990010f7 --- /dev/null +++ b/playwright/e2e/confirmation-redirect.spec.ts @@ -0,0 +1,128 @@ +import type { Route } from '@playwright/test' +import { expect, test } from '../support/commands' + +test.describe('Auth email confirmation redirects', () => { + test.beforeEach(async ({ page }) => { + await page.route('**/auth/v1/verify?**', route => route.fulfill({ + contentType: 'text/html', + body: '

Verification endpoint

', + })) + }) + + test('restores recovery parameters from a tracked email link', async ({ page }) => { + const verificationUrl = 'https://127.0.0.1/auth/v1/verify?token=password-reset-regression' + const redirectTo = new URL('/forgot_password?step=2', test.info().project.use.baseURL).href + const query = new URLSearchParams({ + confirmation_url: verificationUrl, + type: 'recovery', + redirect_to: redirectTo, + utm_source: 'bento', + }) + const requestPromise = page.waitForRequest(request => request.isNavigationRequest() && new URL(request.url()).pathname === '/auth/v1/verify') + + await page.goto(`/confirm-signup?${query}`) + const request = await requestPromise + const target = new URL(request.url()) + + expect(target.searchParams.get('token')).toBe('password-reset-regression') + expect(target.searchParams.get('type')).toBe('recovery') + expect(target.searchParams.get('redirect_to')).toBe(redirectTo) + expect(target.searchParams.has('utm_source')).toBe(false) + await expect(page.getByText('Verification endpoint')).toBeVisible() + }) + + test('preserves query parameters inside a fully encoded recovery link', async ({ page }) => { + const redirectTo = new URL('/forgot_password?step=2&source=email', test.info().project.use.baseURL).href + const verificationUrl = new URL('https://127.0.0.1/auth/v1/verify') + verificationUrl.search = new URLSearchParams({ token: 'password-reset-regression', type: 'recovery', redirect_to: redirectTo }).toString() + const query = new URLSearchParams({ confirmation_url: verificationUrl.href }) + const requestPromise = page.waitForRequest(request => request.isNavigationRequest() && new URL(request.url()).pathname === '/auth/v1/verify') + + await page.goto(`/confirm-signup?${query}`) + const request = await requestPromise + + expect(new URL(request.url()).searchParams.get('redirect_to')).toBe(redirectTo) + await expect(page.getByText('Verification endpoint')).toBeVisible() + }) + + test('keeps verification navigation when a background import is cancelled', async ({ page }) => { + const navigations: string[] = [] + page.on('request', (request) => { + if (request.isNavigationRequest()) + navigations.push(new URL(request.url()).pathname) + }) + let reportImportError!: (state: { reloadTimestamp: string | null, loaderVisible: boolean, confirmationRendered: boolean }) => void + const importError = new Promise[0]>((resolve) => { + reportImportError = resolve + }) + await page.exposeFunction('reportCancelledImport', reportImportError) + await page.addInitScript(() => { + window.addEventListener('unhandledrejection', () => { + queueMicrotask(() => { + const loader = document.querySelector('#app-loader') + const report = Reflect.get(window, 'reportCancelledImport') + void report({ + reloadTimestamp: sessionStorage.getItem('capgo_chunk_reload_timestamp'), + loaderVisible: !!loader && getComputedStyle(loader).visibility === 'visible', + confirmationRendered: !!document.querySelector('h2'), + }).catch(() => {}) + }) + }, { capture: true, once: true }) + const modulePath = '/cancelled-background-import.js' + void import(modulePath) + }) + let captureBackgroundRequest!: (route: Route) => void + const backgroundRequest = new Promise((resolve) => { + captureBackgroundRequest = resolve + }) + await page.route('**/cancelled-background-import.js', captureBackgroundRequest) + let finishVerification!: () => void + const verificationPending = new Promise((resolve) => { + finishVerification = resolve + }) + await page.route('**/auth/v1/verify?**', async (route) => { + const background = await backgroundRequest + await background.abort('aborted').catch(() => {}) + await verificationPending + await route.fulfill({ contentType: 'text/html', body: '

Verification endpoint

' }).catch(() => {}) + }) + const query = new URLSearchParams({ + confirmation_url: 'https://127.0.0.1/auth/v1/verify?token=cancelled-import-regression', + type: 'recovery', + }) + const verificationRequest = page.waitForRequest(request => new URL(request.url()).pathname === '/auth/v1/verify') + await page.goto(`/confirm-signup?${query}`, { waitUntil: 'commit' }) + await verificationRequest + + try { + const state = await importError + expect(state.reloadTimestamp).toBeNull() + expect(state.loaderVisible).toBe(true) + expect(state.confirmationRendered).toBe(false) + } + finally { + finishVerification() + } + await expect(page.getByText('Verification endpoint')).toBeVisible() + expect(navigations).toEqual(['/confirm-signup', '/auth/v1/verify']) + }) + + test('keeps invalid link errors and genuine stale asset recovery working', async ({ page }) => { + const query = new URLSearchParams({ confirmation_url: 'https://untrusted.example/auth/v1/verify?token=invalid-link-regression' }) + await page.goto(`/confirm-signup?${query}`) + await expect(page.getByText('Invalid confirmation URL. Please check your email link.')).toBeVisible() + await page.route('**/missing-background-import.js', route => route.fulfill({ + status: 404, + contentType: 'text/html', + body: '

Old asset removed

', + })) + const reload = page.waitForRequest(request => request.isNavigationRequest() && new URL(request.url()).pathname === '/confirm-signup') + await page.evaluate(() => { + const modulePath = '/missing-background-import.js' + void import(modulePath) + }) + await reload + await expect(page.getByText('Invalid confirmation URL. Please check your email link.')).toBeVisible() + await expect(page.getByText('App updated! Page was refreshed to load the latest version.')).toBeVisible() + }) +}) diff --git a/playwright/e2e/devices-empty-state.spec.ts b/playwright/e2e/devices-empty-state.spec.ts index e6e782c08b..7c03c09d31 100644 --- a/playwright/e2e/devices-empty-state.spec.ts +++ b/playwright/e2e/devices-empty-state.spec.ts @@ -20,13 +20,20 @@ async function mockEmptyDevices(page: Page, requests: Record[]) }) } -async function expectRequestCountToRemain(requests: Record[], expected: number) { - const stableAfter = Date.now() + 1100 +async function expectRequestCountToStabilize(requests: Record[]) { + let lastCount = -1 + let stableSince = 0 await expect.poll(() => { - if (requests.length !== expected) - return `unexpected:${requests.length}` - return Date.now() >= stableAfter ? 'stable' : 'waiting' - }, { timeout: 2000, intervals: [100] }).toBe('stable') + const count = requests.length + if (count !== lastCount) { + lastCount = count + stableSince = Date.now() + return 'waiting' + } + if (Date.now() - stableSince < 1500) + return 'waiting' + return 'stable' + }, { timeout: 5000, intervals: [100] }).toBe('stable') } test.describe('Devices empty state', () => { @@ -72,11 +79,11 @@ test.describe('Devices empty state', () => { const search = page.getByPlaceholder('Search by device ID or Custom ID') await search.fill('missing-device') await expect.poll(() => requests.at(-1)?.search).toBe('missing-device') - await expectRequestCountToRemain(requests, requests.length) + await expectRequestCountToStabilize(requests) const requestCountBeforeRefresh = requests.length await emptyState.getByRole('button', { name: 'Refresh devices' }).click() - await expect.poll(() => requests.length).toBe(requestCountBeforeRefresh + 3) - await expectRequestCountToRemain(requests, requestCountBeforeRefresh + 3) + await expect.poll(() => requests.length).toBeGreaterThan(requestCountBeforeRefresh) + await expectRequestCountToStabilize(requests) await expect.poll(() => requests.at(-1)).toMatchObject({ appId: APP_ID, search: 'missing-device', @@ -110,8 +117,8 @@ test.describe('Devices empty state', () => { await expect(search).toHaveValue('') await expect(emptyState.getByText('Search or filters are hiding it.')).toHaveCount(0) - await expect.poll(() => requests.length).toBe(requestCountBeforeClear + 3) - await expectRequestCountToRemain(requests, requestCountBeforeClear + 3) + await expect.poll(() => requests.length).toBeGreaterThan(requestCountBeforeClear) + await expectRequestCountToStabilize(requests) await expect.poll(() => requests.at(-1)).toMatchObject({ appId: APP_ID, customIdMode: false, diff --git a/playwright/e2e/email-verification.spec.ts b/playwright/e2e/email-verification.spec.ts new file mode 100644 index 0000000000..d55eb658b6 --- /dev/null +++ b/playwright/e2e/email-verification.spec.ts @@ -0,0 +1,92 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' + +const fixture = '/playwright/fixtures/email-verification.html?reason=email_not_verified&return_to=/settings/account' + +async function sendCode(page: Page) { + const captcha = page.getByRole('checkbox', { name: 'Verify you are human' }) + // CI disables CAPTCHA; local runs also exercise the fixture widget when enabled. + if (await captcha.count()) + await captcha.check() + await page.getByRole('button', { name: 'Send verification code', exact: true }).click() +} + +test.describe('Progressive email verification', () => { + test.beforeEach(async ({ page }) => { + await page.goto(fixture) + await expect(page.getByRole('heading', { name: 'Verify your email', exact: true })).toBeVisible() + }) + + test('reveals and focuses code entry only after a successful send, then verifies with Enter', async ({ page }) => { + const code = page.getByLabel('Enter the verification code', { exact: true }) + await expect(code).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Validate email and continue' })).toHaveCount(0) + await page.evaluate(() => (window as any).emailVerificationPreview.sendDelayMs = 500) + await sendCode(page) + await expect(code).toHaveCount(0) + await expect(code).toBeVisible() + await expect(code).toBeFocused() + await expect(page.getByRole('button', { name: 'Send verification code', exact: true })).toHaveCount(0) + await expect(page.getByRole('checkbox', { name: 'Verify you are human' })).toHaveCount(0) + + await page.evaluate(() => (window as any).emailVerificationPreview.verificationError = true) + await code.fill('000000') + await code.press('Enter') + await expect(page.getByText('Verification failed', { exact: true })).toBeVisible() + await expect(code).toBeVisible() + await page.evaluate(() => (window as any).emailVerificationPreview.verificationError = false) + await code.fill('123456') + await code.press('Enter') + await expect(page).toHaveURL('/settings/account') + }) + + test('keeps the send step on failure and permits retrying', async ({ page }) => { + await page.evaluate(() => { + (window as any).emailVerificationPreview.sendError = { code: 'unexpected_failure', message: 'Unable to send', status: 500 } + }) + await sendCode(page) + await expect(page.getByRole('alert')).toHaveText('Could not send the verification code. Please try again.') + await expect(page.getByLabel('Enter the verification code', { exact: true })).toHaveCount(0) + await page.evaluate(() => (window as any).emailVerificationPreview.sendError = null) + await sendCode(page) + await expect(page.getByLabel('Enter the verification code', { exact: true })).toBeVisible() + expect(await page.evaluate(() => (window as any).emailVerificationPreview.sends.length)).toBe(2) + }) + + for (const event of ['error', 'unsupported']) { + test(`recovers from CAPTCHA ${event} without sending before a fresh challenge succeeds`, async ({ page }) => { + const captcha = page.getByRole('checkbox', { name: 'Verify you are human' }) + test.skip(!await captcha.count(), 'CAPTCHA is disabled in this environment') + await captcha.check() + await page.evaluate(event => (window as any).emailVerificationPreview.captchaCallbacks[event](), event) + const send = page.getByRole('button', { name: 'Send verification code', exact: true }) + await expect(send).toBeDisabled() + await expect(page.getByText('Captcha is unavailable. Retry the challenge to continue.', { exact: true })).toBeVisible() + await expect(page.getByLabel('Enter the verification code', { exact: true })).toHaveCount(0) + await page.getByRole('button', { name: 'Retry', exact: true }).click() + await expect(captcha).not.toBeChecked() + await expect(send).toBeDisabled() + expect(await page.evaluate(() => (window as any).emailVerificationPreview.sends.length)).toBe(0) + await sendCode(page) + await expect(page.getByLabel('Enter the verification code', { exact: true })).toBeVisible() + }) + } + + test('returns to the CAPTCHA step to resend and allows returning to the existing code', async ({ page }) => { + await sendCode(page) + const code = page.getByLabel('Enter the verification code', { exact: true }) + await code.fill('123456') + await page.getByRole('button', { name: 'Send another code', exact: true }).click() + await expect(code).toBeHidden() + const captcha = page.getByRole('checkbox', { name: 'Verify you are human' }) + if (await captcha.count()) { + await expect(captcha).not.toBeChecked() + await expect(page.getByRole('button', { name: 'Send verification code', exact: true })).toBeDisabled() + } + await page.getByRole('button', { name: 'Back to code entry', exact: true }).click() + await expect(code).toHaveValue('123456') + await page.getByRole('button', { name: 'Send another code', exact: true }).click() + await sendCode(page) + await expect(code).toHaveValue('') + }) +}) diff --git a/playwright/e2e/observe-tabs.spec.ts b/playwright/e2e/observe-tabs.spec.ts index 1636240e9b..7e16bf8437 100644 --- a/playwright/e2e/observe-tabs.spec.ts +++ b/playwright/e2e/observe-tabs.spec.ts @@ -181,8 +181,12 @@ test.describe('Observe sections', () => { const compositeVersion = '2.36.2+8ebc69:assets/index-8ebc69aabbcc.js' await expect(longVersionRow.locator('[data-test="log-row-version"]')).toHaveText(longVersion) await expect(longVersionRow.locator('[data-test="log-row-version"]')).toHaveAttribute('title', longVersion) - await expect(longVersionRow.locator('[data-test="log-row-action"]')).toHaveText('Update process failed') + await expect(longVersionRow.locator('[data-test="log-row-action-name"]')).toHaveText('Update process failed') + await expect(longVersionRow.locator('[data-test="log-row-action-key"]')).toBeHidden() await expect(longVersionRow.locator('[data-test="log-row-metadata"]')).toHaveCount(0) + await longVersionRow.locator('[data-test="log-row-action"]').hover() + await expect(longVersionRow.locator('[data-test="log-row-action-name"]')).toBeHidden() + await expect(longVersionRow.locator('[data-test="log-row-action-key"]')).toHaveText('update_fail') const versionBox = await longVersionRow.locator('[data-test="log-row-version"]').boundingBox() const actionBox = await longVersionRow.locator('[data-test="log-row-action"]').boundingBox() @@ -207,4 +211,47 @@ test.describe('Observe sections', () => { await expect(popover).toContainText(/"filename"/) await expect(popover).toContainText('assets/index-8ebc69aabbcc.js') }) + + test('shows original error text under the translated action label', async ({ page }) => { + const now = new Date().toISOString() + const originalError = 'Uncaught ReferenceError: foo is not defined' + await page.route('**/private/stats', async (route) => { + if (route.request().method() !== 'POST') { + await route.continue() + return + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + app_id: 'com.demo.app', + device_id: '44444444-4444-4444-4444-444444444444', + action: 'webview_javascript_error', + version_name: '1.0.0', + created_at: now, + metadata: { + error_type: 'javascript_error', + message: originalError, + href: 'capacitor://localhost/index.html', + }, + }, + ]), + }) + }) + + await page.goto('/app/com.demo.app/observe/logs') + const row = page.locator('#custom_table tbody tr', { hasText: '44444444' }) + const action = row.locator('[data-test="log-row-action"]') + await expect(action.locator('[data-test="log-row-action-name"]')).toHaveText('WebView JavaScript error') + await expect(action.locator('[data-test="log-row-action-key"]')).toBeHidden() + await expect(action).toHaveAttribute('title', 'webview_javascript_error') + await expect(row.locator('[data-test="log-row-original-error"]')).toHaveText(originalError) + await expect(row.locator('[data-test="log-row-metadata"]')).toHaveCount(1) + + await action.hover() + await expect(action.locator('[data-test="log-row-action-name"]')).toBeHidden() + await expect(action.locator('[data-test="log-row-action-key"]')).toHaveText('webview_javascript_error') + await expect(row.locator('[data-test="log-row-original-error"]')).toHaveText(originalError) + }) }) diff --git a/playwright/e2e/onboarding-setup.spec.ts b/playwright/e2e/onboarding-setup.spec.ts new file mode 100644 index 0000000000..d4f3e2fc0e --- /dev/null +++ b/playwright/e2e/onboarding-setup.spec.ts @@ -0,0 +1,685 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' + +const fixture = '/playwright/fixtures/onboarding-setup.html' +const instructions = '[data-test="setup-checklist-instructions"]' + +async function advanceProgressPolls(page: Page, count: number) { + for (let i = 0; i < count; i++) { + const requests = await page.evaluate(() => (window as any).onboardingSetupPreview.state.requests) + await page.clock.runFor(2000) + // Let response parsing finish between ticks, as it does with the real clock. + await expect.poll(() => page.evaluate(() => (window as any).onboardingSetupPreview.state.requests)).toBe(requests + 1) + } +} + +test.describe('Actionable onboarding setup checklist', () => { + test.beforeEach(async ({ page }) => { + await page.goto(fixture) + await expect(page.getByRole('heading', { name: 'Set up your first live update' })).toBeVisible() + }) + + test('keeps all tasks visible and distinguishes inspecting a task from completing it', async ({ page }) => { + await expect(page.locator('[data-test^="app-onboarding-cli-step-"]')).toHaveCount(7) + await expect(page.locator('[data-test^="app-onboarding-cli-step-"] button')).toContainText([ + 'Start guided setup Current task', + 'Create a channel', + 'Install Capgo Updater', + 'Add the app-ready code', + 'Run your app on a device', + 'Publish your first update', + 'Deliver an update to a device', + ]) + await expect(page.getByRole('button', { name: 'Explore dashboard', exact: true })).toBeVisible() + await expect(page.locator(instructions).getByRole('button', { name: 'Explore dashboard', exact: true })).toHaveCount(0) + await expect(page.locator(instructions).getByRole('heading', { name: 'Start guided setup' })).toBeVisible() + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('0 of 7 complete') + await expect(page.locator(instructions).getByRole('button', { name: 'Copy AI setup prompt', exact: true })).toBeVisible() + await expect(page.locator(instructions).getByRole('link', { name: 'Manual setup guide', exact: false })).toBeVisible() + await expect(page.getByRole('link', { name: 'Manual setup guide', exact: false })).toHaveCount(1) + await expect(page.locator(instructions).getByRole('button', { name: 'Send setup instructions', exact: true })).toHaveCount(0) + + await page.locator('[data-test="app-onboarding-cli-step-run_device"] button').click() + await expect(page.locator(instructions).getByRole('heading', { name: 'Run your app on a device' })).toBeVisible() + await expect(page.locator(instructions)).toContainText('Choose iOS or Android, build your app with Capgo installed') + await expect(page.locator('[data-test="setup-checklist-start-actions"]')).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Copy AI setup prompt', exact: true })).toHaveCount(0) + await expect(page.locator('[data-test="app-onboarding-cli-step-run_device"]')).toHaveAttribute('data-status', 'pending') + await page.getByRole('button', { name: 'Back to the current task' }).click() + await expect(page.locator(instructions).getByRole('heading', { name: 'Start guided setup' })).toBeVisible() + + await page.locator('[data-test="setup-checklist-copy-command"]').click() + await expect.poll(() => page.evaluate(() => (window as any).onboardingSetupPreview.events)).toContain('copy-command') + await page.locator('[data-test="setup-checklist-copy-ai"]').click() + await expect.poll(() => page.evaluate(() => (window as any).onboardingSetupPreview.events)).toContain('copy-ai') + await expect(page.getByText('They’ll need to complete the setup for you.', { exact: false })).toBeVisible() + await page.getByRole('button', { name: 'Send setup instructions', exact: true }).click() + await expect(page.locator('#invite-email')).toBeVisible() + await page.getByRole('button', { name: 'Cancel', exact: true }).click() + await page.getByRole('button', { name: 'Explore dashboard', exact: true }).click() + await expect.poll(() => page.evaluate(() => (window as any).onboardingSetupPreview.events)).toContain('explore') + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('0 of 7 complete') + await page.getByRole('button', { name: 'Don\'t show this again' }).click() + await expect.poll(() => page.evaluate(() => (window as any).onboardingSetupPreview.events)).toContain('hide') + }) + + test('follows persisted progress, counts skipped tasks, and stops polling on completion', async ({ page }) => { + const time = new Date('2026-09-16T12:00:00Z') + await page.clock.install({ time }) + await page.clock.pauseAt(time) + await page.goto(fixture) + await page.evaluate(() => { + (window as any).onboardingSetupPreview.state.steps = { + login_cli_mcp: { status: 'done' }, + add_code: { status: 'skipped' }, + add_encryption: { status: 'skipped' }, + select_platform: { status: 'done' }, + build_project: { status: 'done' }, + add_code_change: { status: 'done' }, + completion: { status: 'done' }, + } + }) + await advanceProgressPolls(page, 1) + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('2 of 7 complete') + await expect(page.locator(instructions).getByRole('heading', { name: 'Create a channel' })).toBeVisible() + await expect(page.locator('[data-test="setup-checklist-start-actions"]')).toHaveCount(0) + await expect(page.locator('[data-test="app-onboarding-cli-step-add_code"]')).toHaveAttribute('data-status', 'skipped') + for (const id of ['add_encryption', 'select_platform', 'build_project', 'add_code_change', 'completion']) + await expect(page.locator(`[data-test="app-onboarding-cli-step-${id}"]`)).toHaveCount(0) + expect(await page.evaluate(() => (window as any).onboardingSetupPreview.state.version)).toBe(3) + expect(await page.evaluate(() => (window as any).onboardingSetupPreview.state.steps.build_project)).toEqual({ status: 'done' }) + + // Browsing a future task should survive background progress updates. + await page.locator('[data-test="app-onboarding-cli-step-run_device"] button').click() + await page.evaluate(() => { + (window as any).onboardingSetupPreview.state.steps.add_updater = { status: 'done' } + }) + await advanceProgressPolls(page, 1) + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('3 of 7 complete') + await expect(page.locator(instructions).getByRole('heading', { name: 'Run your app on a device' })).toBeVisible() + + await page.evaluate(() => { + Object.assign((window as any).onboardingSetupPreview.state.steps, { + run_device: { status: 'done' }, + upload_bundle: { status: 'done' }, + test_update: { status: 'done' }, + }) + }) + await advanceProgressPolls(page, 1) + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('6 of 7 complete') + for (const id of ['run_device', 'upload_bundle', 'test_update']) + await expect(page.locator(`[data-test="app-onboarding-cli-step-${id}"]`)).toHaveAttribute('data-status', 'done') + + await page.evaluate(() => { + (window as any).onboardingSetupPreview.state.outcome = 'completed' + }) + await advanceProgressPolls(page, 1) + await expect(page.getByRole('heading', { name: 'Guided setup complete' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Explore dashboard', exact: true })).toHaveCount(0) + const requests = await page.evaluate(() => (window as any).onboardingSetupPreview.state.requests) + const channelRequests = await page.evaluate(() => (window as any).onboardingSetupPreview.state.channelRequests) + await page.clock.runFor(10_000) + expect(await page.evaluate(() => (window as any).onboardingSetupPreview.state.requests)).toBe(requests) + expect(await page.evaluate(() => (window as any).onboardingSetupPreview.state.channelRequests)).toBe(channelRequests) + await page.getByRole('button', { name: 'Open your app', exact: true }).click() + await expect.poll(() => page.evaluate(() => (window as any).onboardingSetupPreview.events)).toContain('complete') + }) + + test('polls one endpoint every two seconds with consecutive N and the app id on every request', async ({ page }) => { + const time = new Date('2026-09-16T12:00:00Z') + await page.clock.install({ time }) + await page.clock.pauseAt(time) + await page.goto(fixture) + await expect.poll(() => page.evaluate(() => (window as any).onboardingSetupPreview.state.polls.length)).toBe(1) + await page.clock.runFor(10_000) + const polls = await page.evaluate(() => (window as any).onboardingSetupPreview.state.polls) + expect(polls.map((poll: any) => poll.N)).toEqual([0, 1, 2, 3, 4, 5]) + expect(polls.every((poll: any) => poll.appId === 'com.example.onboarding-preview')).toBe(true) + expect(polls.map((poll: any) => poll.initial)).toEqual([true, false, false, false, false, false]) + }) + + test('keeps twelve goals for v1/v2 controls and seven for v3 in the compact checklist', async ({ page }) => { + for (const version of [1, 2, 3]) { + await page.goto(`${fixture}?version=${version}&view=compact`) + const checklist = page.locator('[data-test="app-onboarding-cli-steps"]') + await checklist.getByRole('button').click() + await expect(checklist.locator('li')).toHaveCount(version === 3 ? 7 : 12) + await expect(checklist).toContainText('Upload bundle') + await expect(checklist).toContainText(`0 of ${version === 3 ? 7 : 12} steps`) + expect(await page.evaluate(() => (window as any).onboardingSetupPreview.state.version)).toBe(version) + } + }) + + test('shows a relevant guide for every selected task in both checklist versions', async ({ page, context }) => { + const onboarding = 'https://capgo.app/docs/getting-started/onboarding/' + const guides: Record = { + add_app: onboarding, + login_cli_mcp: onboarding, + add_channel: `${onboarding}#step-3-create-production-channel`, + add_updater: `${onboarding}#step-4-install-updater-plugin`, + add_code: 'https://capgo.app/docs/plugins/updater/notify-app-ready/', + run_device: `${onboarding}#step-9-run-on-device`, + upload_bundle: 'https://capgo.app/docs/getting-started/deploy/#uploading-a-bundle', + test_update: 'https://capgo.app/docs/getting-started/deploy/#receiving-an-update-on-a-device', + } + await page.setViewportSize({ width: 1190, height: 1322 }) + for (const version of [1, 2]) { + await page.goto(`${fixture}?version=${version}`) + for (const [id, href] of Object.entries(guides)) { + if (id === (version === 1 ? 'login_cli_mcp' : 'add_app')) + continue + await page.locator(`[data-test="app-onboarding-cli-step-${id}"] button`).click() + const title = await page.locator(instructions).getByRole('heading').textContent() + const guide = page.locator('[data-test="setup-checklist-manual-guide"]') + await expect(guide).toHaveAttribute('href', href) + await expect(guide).toHaveText(id === 'add_app' || id === 'login_cli_mcp' ? 'Manual setup guide' : `Guide: ${title}`) + await expect(guide).toHaveAttribute('target', '_blank') + await expect(guide).toHaveAttribute('rel', 'noopener noreferrer') + } + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('0 of 7 complete') + } + + // Verify the link actually navigates without sending a request to production docs. + await context.route('https://capgo.app/docs/**', route => route.fulfill({ contentType: 'text/html', body: 'Documentation preview' })) + await page.locator('[data-test="app-onboarding-cli-step-upload_bundle"] button').click() + const popupPromise = page.waitForEvent('popup') + await page.locator('[data-test="setup-checklist-manual-guide"]').click() + const popup = await popupPromise + await popup.waitForLoadState() + expect(popup.url()).toBe(guides.upload_bundle) + await popup.close() + await expect(page.locator(instructions).getByRole('heading', { name: 'Publish your first update' })).toBeVisible() + await page.screenshot({ path: '.context/onboarding-design/task-guides-desktop.png', fullPage: true }) + + await page.setViewportSize({ width: 390, height: 844 }) + await page.locator('[data-test="setup-checklist-mobile-toggle"]').click() + await page.locator('[data-test="app-onboarding-cli-step-test_update"] button').click() + const mobileGuide = page.locator('[data-test="setup-checklist-manual-guide"]') + await expect(mobileGuide).toHaveAttribute('href', guides.test_update) + await mobileGuide.scrollIntoViewIfNeeded() + const panel = await page.locator(instructions).boundingBox() + const guide = await mobileGuide.boundingBox() + expect(guide!.x).toBeGreaterThanOrEqual(panel!.x) + expect(guide!.x + guide!.width).toBeLessThanOrEqual(panel!.x + panel!.width) + await page.screenshot({ path: '.context/onboarding-design/task-guides-mobile.png' }) + }) + + test('creates a channel through the existing animated flow and tags every milestone as todo-list activity', async ({ page }) => { + await page.setViewportSize({ width: 1190, height: 1322 }) + await page.locator('[data-test="app-onboarding-cli-step-add_channel"] button').click() + await expect(page.locator(instructions)).toContainText('Waiting for a channel to be created') + await page.screenshot({ path: '.context/onboarding-design/setup-channel-action.png', fullPage: true }) + await page.locator('[data-test="setup-checklist-create-channel"]').click() + const dialog = page.getByRole('dialog', { name: 'Create a channel', exact: true }) + await expect(dialog).toBeVisible() + await expect(dialog.locator('[data-test="channel-default-routing-animation"]')).toBeVisible() + await expect.poll(() => page.evaluate(() => (window as any).onboardingSetupPreview.channelEvents.map((event: any) => event.event))).toContain('onboarding_channel_animation_started') + await dialog.getByRole('button', { name: /replay/i }).click() + await page.screenshot({ path: '.context/onboarding-design/setup-channel-flow-routing.png', fullPage: true }) + await dialog.locator('[data-test="channel-default-routing-continue"]').click() + await expect(dialog.locator('[data-test="channel-self-assign-stage"]')).toBeVisible() + await dialog.locator('[data-test="channel-self-assign-back"]').click() + await expect(dialog.locator('[data-test="channel-default-routing-animation"]')).toBeVisible() + await dialog.locator('[data-test="channel-default-routing-continue"]').click() + await dialog.locator('[data-test="channel-self-assign-continue"]').click() + await expect(dialog.locator('[data-test="channel-console-assign-stage"]')).toBeVisible() + await dialog.locator('[data-test="channel-console-assign-continue"]').click() + await expect(dialog.locator('[data-test="channel-create-name"]')).toBeVisible() + await expect(dialog.locator('[data-test="channel-create-submit"]')).toBeDisabled() + await dialog.locator('[data-test="channel-create-name"]').fill('bad channel') + await dialog.locator('[data-test="channel-create-name"]').blur() + await expect(dialog).toContainText('Use only letters, numbers, dots, dashes, or underscores.') + await dialog.getByRole('button', { name: /production.*recommended/i }).click() + await page.evaluate(() => { + (window as any).onboardingSetupPreview.state.channelInsertError = true + }) + await dialog.locator('[data-test="channel-create-submit"]').click() + await expect(dialog.getByRole('alert')).toContainText('We could not create this channel.') + await expect(page.locator('[data-test="app-onboarding-cli-step-add_channel"]')).toHaveAttribute('data-status', 'pending') + await page.evaluate(() => { + const state = (window as any).onboardingSetupPreview.state + state.channelInsertError = false + state.channelInsertDelayMs = 1000 + }) + await page.screenshot({ path: '.context/onboarding-design/setup-channel-flow-create.png', fullPage: true }) + await dialog.locator('[data-test="channel-create-submit"]').click() + await expect(dialog.locator('[data-test="setup-checklist-channel-close"]')).toBeDisabled() + await page.keyboard.press('Escape') + await expect(dialog).toBeVisible() + await expect(dialog.locator('[data-test="channel-create-success"]')).toBeVisible() + await dialog.locator('[data-test="channel-create-continue"]').click() + await expect(dialog).toHaveCount(0) + await expect(page.locator('[data-test="app-onboarding-cli-step-add_channel"]')).toHaveAttribute('data-status', 'done') + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('1 of 7 complete') + const result = await page.evaluate(() => { + const preview = (window as any).onboardingSetupPreview + return { events: preview.channelEvents, inserts: preview.state.channelInserts, steps: preview.state.steps } + }) + expect(result.steps).toEqual({}) + expect(result.inserts.at(-1)).toMatchObject({ + app_id: 'com.example.onboarding-preview', + name: 'production', + public: true, + allow_device_self_set: true, + owner_org: '00000000-0000-4000-8000-000000000002', + created_by: '00000000-0000-4000-8000-000000000001', + }) + expect(result.events.every((event: any) => event.properties.channel_flow_origin === 'todo_list')).toBe(true) + for (const event of ['onboarding_channel_flow_opened', 'onboarding_channel_animation_replayed', 'onboarding_channel_stage_backed', 'onboarding_channel_name_validation_failed', 'onboarding_channel_create_failed', 'onboarding_channel_create_submitted', 'onboarding_channel_create_succeeded', 'onboarding_channel_create_continued']) + expect(result.events.map((entry: any) => entry.event)).toContain(event) + expect(result.events.at(-1)).toMatchObject({ event: 'onboarding_channel_flow_closed', properties: { flow_exit_action: 'completed' } }) + }) + + test('returns to the selected task on Escape and preserves origin on animation disposal', async ({ page }) => { + await page.locator('[data-test="app-onboarding-cli-step-add_channel"] button').click() + const create = page.locator('[data-test="setup-checklist-create-channel"]') + await create.click() + await expect(page.locator('[data-test="setup-checklist-channel-dialog"]')).toBeVisible() + await expect.poll(() => page.evaluate(() => (window as any).onboardingSetupPreview.channelEvents.map((event: any) => event.event))).toContain('onboarding_channel_animation_started') + await page.keyboard.press('Escape') + await expect(page.getByRole('dialog')).toHaveCount(0) + await expect(create).toBeFocused() + await expect(page.locator(instructions).getByRole('heading', { name: 'Create a channel' })).toBeVisible() + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('0 of 7 complete') + await expect.poll(() => page.evaluate(() => (window as any).onboardingSetupPreview.channelEvents.map((event: any) => event.event))).toContain('onboarding_channel_animation_interrupted') + await create.click() + await expect(page.locator('[data-test="channel-default-routing-animation"]')).toBeVisible() + await page.locator('[data-test="setup-checklist-channel-close"]').click() + const events = await page.evaluate(() => (window as any).onboardingSetupPreview.channelEvents) + expect(events.filter((entry: any) => entry.event === 'onboarding_channel_flow_opened')).toHaveLength(2) + expect(events.filter((entry: any) => entry.event === 'onboarding_channel_flow_closed').every((entry: any) => entry.properties.flow_exit_action === 'closed')).toBe(true) + expect(events.every((entry: any) => entry.properties.channel_flow_origin === 'todo_list')).toBe(true) + }) + + test('supports reduced motion on mobile and respects channel creation permissions', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.evaluate(() => { + (window as any).onboardingSetupPreview.state.channelPermissions = false + }) + await page.locator('[data-test="setup-checklist-mobile-toggle"]').click() + await page.locator('[data-test="app-onboarding-cli-step-add_channel"] button').click() + await page.locator('[data-test="setup-checklist-create-channel"]').click() + await page.screenshot({ path: '.context/onboarding-design/setup-channel-flow-mobile.png', fullPage: true }) + const routingBounds = await page.locator('[data-test="channel-default-routing-animation"]').evaluate((stage) => { + const bounds = stage.getBoundingClientRect() + return Array.from(stage.querySelectorAll('.cr-channel-card, .cr-capgo-state')).every((element) => { + const rect = element.getBoundingClientRect() + return rect.left >= bounds.left && rect.right <= bounds.right + }) + }) + expect(routingBounds).toBe(true) + for (const stage of ['default-routing', 'self-assign', 'console-assign']) { + if (stage !== 'default-routing') { + await page.screenshot({ path: `.context/onboarding-design/setup-channel-flow-mobile-${stage}.png`, fullPage: true }) + if (stage === 'self-assign') { + const bounds = await page.locator('[data-test="channel-self-assign-stage"]').evaluate((stage) => { + const bounds = stage.getBoundingClientRect() + return Array.from(stage.querySelectorAll('.csa-routing-channel-card, .csa-capgo-state')).every((element) => { + const rect = element.getBoundingClientRect() + return rect.left >= bounds.left && rect.right <= bounds.right + }) + }) + expect(bounds).toBe(true) + const spacing = await page.locator('[data-test="channel-self-assign-stage"]').evaluate((stage) => { + const label = stage.querySelector('.csa-channels-label')!.getBoundingClientRect() + const cards = Array.from(stage.querySelectorAll('.csa-routing-channel-card')).map(element => element.getBoundingClientRect()) + const validation = stage.querySelector('.csa-validation-check')!.getBoundingClientRect() + return { labelTop: label.top, labelBottom: label.bottom, firstCardTop: Math.min(...cards.map(card => card.top)), validationBottom: validation.bottom } + }) + expect(spacing.firstCardTop).toBeGreaterThanOrEqual(spacing.labelBottom) + expect(spacing.labelTop).toBeGreaterThanOrEqual(spacing.validationBottom) + } + } + await page.locator(`[data-test="channel-${stage}-continue"]`).click() + } + await expect(page.locator('[data-test="channel-create-name"]')).toBeVisible() + await page.locator('[data-test="channel-create-name"]').fill('production') + await expect(page.locator('[data-test="channel-create-submit"]')).toBeDisabled() + const result = await page.evaluate(() => ({ + events: (window as any).onboardingSetupPreview.channelEvents, + inserts: (window as any).onboardingSetupPreview.state.channelInserts, + overflow: document.querySelector('dialog')!.scrollWidth > document.querySelector('dialog')!.clientWidth, + })) + expect(result.overflow).toBe(false) + expect(result.inserts).toEqual([]) + expect(result.events.filter((entry: any) => entry.event === 'onboarding_channel_reduced_motion_shown')).toHaveLength(3) + expect(result.events).toContainEqual(expect.objectContaining({ event: 'onboarding_channel_create_loaded', properties: expect.objectContaining({ permission_state: 'denied', channel_flow_origin: 'todo_list' }) })) + await page.emulateMedia({ colorScheme: 'dark' }) + await page.locator('[data-test="channel-create-submit"]').scrollIntoViewIfNeeded() + await page.screenshot({ path: '.context/onboarding-design/setup-channel-flow-mobile-dark.png', fullPage: true }) + }) + + test('reuses a channel created while explanations are open without inserting another one in either checklist version', async ({ page }) => { + for (const version of [1, 2]) { + await page.goto(`${fixture}?version=${version}`) + await page.locator('[data-test="app-onboarding-cli-step-add_channel"] button').click() + await page.locator('[data-test="setup-checklist-create-channel"]').click() + await page.evaluate(() => { + const preview = (window as any).onboardingSetupPreview + preview.state.channels = [{ id: 9, app_id: preview.appId.value, name: 'existing', public: true, allow_device_self_set: false }] + }) + for (const stage of ['default-routing', 'self-assign', 'console-assign']) + await page.locator(`[data-test="channel-${stage}-continue"]`).click() + await expect(page.locator('[data-test="channel-create-success"]')).toBeVisible() + await expect(page.locator('[data-test="channel-create-success"]')).toContainText('existing') + await page.locator('[data-test="channel-create-continue"]').click() + await expect(page.getByRole('dialog')).toHaveCount(0) + await expect(page.locator('[data-test="app-onboarding-cli-step-add_channel"]')).toHaveAttribute('data-status', 'done') + const result = await page.evaluate(() => ({ events: (window as any).onboardingSetupPreview.channelEvents, inserts: (window as any).onboardingSetupPreview.state.channelInserts })) + expect(result.inserts).toEqual([]) + expect(result.events).toContainEqual(expect.objectContaining({ event: 'onboarding_channel_create_existing_detected', properties: expect.objectContaining({ found_existing_channel: true, created_in_onboarding: false, channel_flow_origin: 'todo_list' }) })) + } + }) + + test('retains last known progress when refresh fails and recovers', async ({ page }) => { + await page.evaluate(() => { + (window as any).onboardingSetupPreview.state.error = true + }) + await expect(page.locator(instructions)).toContainText('Couldn’t refresh setup progress.') + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('0 of 7 complete') + await page.evaluate(() => { + const state = (window as any).onboardingSetupPreview.state + state.error = false + state.steps.login_cli_mcp = { status: 'done' } + }) + await page.getByRole('button', { name: 'Refresh progress' }).click() + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('1 of 7 complete') + await expect(page.locator(instructions).getByRole('heading', { name: 'Create a channel' })).toBeVisible() + await expect(page.locator(instructions)).toContainText('Waiting for a channel to be created') + }) + + test('completes the channel task on load for any app channel in both checklist versions', async ({ page }) => { + for (const version of [1, 2]) { + await page.goto(`${fixture}?version=${version}&channel=1`) + await expect(page.locator('[data-test="app-onboarding-cli-step-add_channel"]')).toHaveAttribute('data-status', 'done') + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('1 of 7 complete') + await expect(page.locator(instructions).getByRole('heading', { name: 'Start guided setup' })).toBeVisible() + expect(await page.evaluate(() => (window as any).onboardingSetupPreview.state.steps)).toEqual({}) + expect(await page.evaluate(() => (window as any).onboardingSetupPreview.state.channelQueries)).toEqual(['eq.com.example.onboarding-preview']) + await page.locator('[data-test="app-onboarding-cli-step-add_channel"] button').click() + await expect(page.locator(instructions).getByRole('heading', { name: 'Create a channel' })).toBeVisible() + await expect(page.locator(instructions)).not.toContainText('We check on page load and every 10 seconds.') + } + await page.screenshot({ path: '.context/onboarding-design/setup-channel-detected.png' }) + }) + + test('confirmed channel absence overrides saved done and skipped statuses in both checklist versions', async ({ page }) => { + for (const version of [1, 2]) { + for (const status of ['done', 'skipped']) { + await page.goto(`${fixture}?version=${version}&channelStatus=${status}`) + await expect.poll(() => page.evaluate(() => (window as any).onboardingSetupPreview.state.channelRequests)).toBe(1) + await expect(page.locator('[data-test="app-onboarding-cli-step-add_channel"]')).toHaveAttribute('data-status', 'pending') + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('0 of 7 complete') + expect(await page.evaluate(() => (window as any).onboardingSetupPreview.state.steps.add_channel.status)).toBe(status) + } + } + }) + + test('unchecks a deleted channel despite saved completion and rechecks a recreated channel', async ({ page }) => { + const time = new Date('2026-09-16T12:00:00Z') + await page.clock.install({ time }) + await page.clock.pauseAt(time) + await page.goto(`${fixture}?channel=1&channelStatus=done`) + await expect.poll(() => page.evaluate(() => (window as any).onboardingSetupPreview.state.channelRequests)).toBe(1) + await expect(page.locator('[data-test="app-onboarding-cli-step-add_channel"]')).toHaveAttribute('data-status', 'done') + await page.evaluate(() => { + (window as any).onboardingSetupPreview.state.channels = [] + }) + await page.clock.runFor(9999) + await expect(page.locator('[data-test="app-onboarding-cli-step-add_channel"]')).toHaveAttribute('data-status', 'done') + await page.clock.runFor(1) + await expect(page.locator('[data-test="app-onboarding-cli-step-add_channel"]')).toHaveAttribute('data-status', 'pending') + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('0 of 7 complete') + expect(await page.evaluate(() => (window as any).onboardingSetupPreview.state.steps.add_channel.status)).toBe('done') + + // Subsequent saved progress must not restore the channel's old done state. + await page.evaluate(() => { + (window as any).onboardingSetupPreview.state.steps.login_cli_mcp = { status: 'done' } + }) + await page.clock.runFor(2000) + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('1 of 7 complete') + await expect(page.locator('[data-test="app-onboarding-cli-step-add_channel"]')).toHaveAttribute('data-status', 'pending') + await expect(page.locator(instructions).getByRole('heading', { name: 'Create a channel' })).toBeVisible() + await page.screenshot({ path: '.context/onboarding-design/setup-channel-removed.png' }) + + await page.evaluate(() => { + (window as any).onboardingSetupPreview.state.channels.push({ id: 2, app_id: 'com.example.onboarding-preview', name: 'production' }) + }) + await advanceProgressPolls(page, 4) + await expect(page.locator('[data-test="app-onboarding-cli-step-add_channel"]')).toHaveAttribute('data-status', 'done') + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('2 of 7 complete') + await expect(page.locator(instructions).getByRole('heading', { name: 'Install Capgo Updater' })).toBeVisible() + }) + + test('detects a new channel at ten seconds and retains it through progress and channel refresh failures', async ({ page }) => { + const time = new Date('2026-09-16T12:00:00Z') + await page.clock.install({ time }) + await page.clock.pauseAt(time) + await page.goto(fixture) + await expect.poll(() => page.evaluate(() => (window as any).onboardingSetupPreview.state.channelRequests)).toBe(1) + await expect(page.locator('[data-test="app-onboarding-cli-step-add_channel"]')).toHaveAttribute('data-status', 'pending') + await page.evaluate(() => { + (window as any).onboardingSetupPreview.state.channels.push({ id: 1, app_id: 'com.example.onboarding-preview', name: 'staging' }) + }) + await page.clock.runFor(9999) + expect(await page.evaluate(() => (window as any).onboardingSetupPreview.state.channelRequests)).toBe(1) + await expect(page.locator('[data-test="app-onboarding-cli-step-add_channel"]')).toHaveAttribute('data-status', 'pending') + await page.clock.runFor(1) + await expect(page.locator('[data-test="app-onboarding-cli-step-add_channel"]')).toHaveAttribute('data-status', 'done') + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('1 of 7 complete') + + await page.evaluate(() => { + const state = (window as any).onboardingSetupPreview.state + state.steps.login_cli_mcp = { status: 'done' } + state.channelError = true + }) + await advanceProgressPolls(page, 5) + await expect.poll(() => page.evaluate(() => (window as any).onboardingSetupPreview.state.channelRequests)).toBe(3) + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('2 of 7 complete') + await expect(page.locator(instructions).getByRole('heading', { name: 'Install Capgo Updater' })).toBeVisible() + // Let the client's retry backoff run while exercising failed requests. + await page.clock.resume() + await page.evaluate(() => { + (window as any).onboardingSetupPreview.state.error = true + }) + await expect(page.locator(instructions)).toContainText('Couldn’t refresh setup progress.') + await expect(page.locator('[data-test="app-onboarding-cli-step-add_channel"]')).toHaveAttribute('data-status', 'done') + + await page.evaluate(() => { + const state = (window as any).onboardingSetupPreview.state + state.error = false + state.channelError = false + }) + await page.getByRole('button', { name: 'Refresh progress' }).click() + await expect(page.locator(instructions)).not.toContainText('Couldn’t refresh setup progress.') + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('2 of 7 complete') + + await page.evaluate(() => { + (window as any).onboardingSetupPreview.state.outcome = 'completed' + }) + await page.clock.runFor(2000) + await expect(page.getByRole('heading', { name: 'Guided setup complete' })).toBeVisible() + const requests = await page.evaluate(() => { + const state = (window as any).onboardingSetupPreview.state + return [state.requests, state.channelRequests] + }) + await page.clock.runFor(10_000) + expect(await page.evaluate(() => { + const state = (window as any).onboardingSetupPreview.state + return [state.requests, state.channelRequests] + })).toEqual(requests) + }) + + test('ignores delayed channel results from the previous app and polls the new app', async ({ page }) => { + const time = new Date('2026-09-16T12:00:00Z') + await page.clock.install({ time }) + await page.clock.pauseAt(time) + await page.goto(`${fixture}?channel=1&channelDelay=4000`) + await expect.poll(() => page.evaluate(() => (window as any).onboardingSetupPreview.state.channelRequests)).toBe(1) + await page.evaluate(() => { + const preview = (window as any).onboardingSetupPreview + preview.state.channelDelayMs = 0 + preview.appId.value = 'com.example.other-app' + }) + await expect.poll(() => page.evaluate(() => (window as any).onboardingSetupPreview.state.channelQueries)).toEqual([ + 'eq.com.example.onboarding-preview', + 'eq.com.example.other-app', + ]) + await page.clock.runFor(4000) + await expect(page.locator('[data-test="app-onboarding-cli-step-add_channel"]')).toHaveAttribute('data-status', 'pending') + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('0 of 7 complete') + await page.evaluate(() => { + (window as any).onboardingSetupPreview.state.channels.push({ id: 2, app_id: 'com.example.other-app', name: 'production' }) + }) + await advanceProgressPolls(page, 3) + await expect(page.locator('[data-test="app-onboarding-cli-step-add_channel"]')).toHaveAttribute('data-status', 'done') + await expect(page.locator('[data-test="setup-checklist-progress"]')).toHaveText('1 of 7 complete') + }) + + test('supports legacy checklist data and a mobile task picker without horizontal overflow', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto(`${fixture}?version=1`) + await expect(page.locator(instructions).getByRole('heading', { name: 'Start guided setup' })).toBeVisible() + const toggle = page.locator('[data-test="setup-checklist-mobile-toggle"]') + await toggle.click() + await expect(page.locator('[data-test="app-onboarding-cli-step-test_update"]')).toBeVisible() + await page.locator('[data-test="app-onboarding-cli-step-run_device"] button').click() + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + await expect(page.locator(instructions).getByRole('heading', { name: 'Run your app on a device' })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390) + }) + + test('renders desktop, mobile, dark and loading states without console errors', async ({ page }) => { + const errors: string[] = [] + page.on('pageerror', error => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') + errors.push(message.text()) + }) + await page.setViewportSize({ width: 1190, height: 1322 }) + await page.goto(fixture) + await expect(page.locator('[data-test="app-onboarding-cli-step-test_update"]')).toBeVisible() + const commandButton = await page.locator('[data-test="setup-checklist-copy-command"]').boundingBox() + const aiButton = await page.locator('[data-test="setup-checklist-copy-ai"]').boundingBox() + expect(commandButton).not.toBeNull() + expect(aiButton).not.toBeNull() + expect(Math.abs(commandButton!.y - aiButton!.y)).toBeLessThan(1) + const panel = await page.locator(instructions).boundingBox() + const manualGuide = await page.locator('[data-test="setup-checklist-manual-guide"]').boundingBox() + expect(panel).not.toBeNull() + expect(manualGuide).not.toBeNull() + expect(panel!.y + panel!.height - manualGuide!.y - manualGuide!.height).toBeLessThanOrEqual(33) + const dismissal = await page.getByRole('button', { name: 'Don\'t show this again' }).boundingBox() + expect(dismissal).not.toBeNull() + expect(dismissal!.y + dismissal!.height).toBeLessThanOrEqual(1322) + await page.screenshot({ path: '.context/onboarding-design/setup-desktop.png' }) + await page.setViewportSize({ width: 390, height: 844 }) + await page.screenshot({ path: '.context/onboarding-design/setup-mobile.png' }) + await page.locator('[data-test="setup-checklist-copy-command"]').scrollIntoViewIfNeeded() + await page.screenshot({ path: '.context/onboarding-design/setup-mobile-action.png' }) + await page.getByRole('button', { name: 'Explore dashboard', exact: true }).scrollIntoViewIfNeeded() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390) + await page.screenshot({ path: '.context/onboarding-design/setup-mobile-footer.png' }) + await page.setViewportSize({ width: 1190, height: 1322 }) + await page.emulateMedia({ colorScheme: 'dark' }) + await page.evaluate(() => { + document.documentElement.classList.add('dark') + document.documentElement.dataset.theme = 'capgodark' + }) + await page.screenshot({ path: '.context/onboarding-design/setup-dark.png' }) + expect(await page.locator(instructions).getByRole('heading', { name: 'Start guided setup' }).evaluate(element => getComputedStyle(element).color)).toBe('rgb(255, 255, 255)') + await page.evaluate(() => { + (window as any).onboardingSetupPreview.command.value = '' + }) + await expect(page.locator('[data-test="setup-checklist-copy-command"]')).toBeDisabled() + await expect(page.getByText('Creating your secure API key…')).toBeVisible() + expect(errors).toEqual([]) + }) +}) + +test.describe('Dashboard exploration and returning to v3 setup', () => { + test.beforeEach(async ({ page }) => { + const response = await page.request.get(fixture) + const html = (await response.text()).replace('src="./onboarding-setup.ts', 'src="/playwright/fixtures/onboarding-setup.ts') + await page.route('**/*', async (route) => { + const path = new URL(route.request().url()).pathname + if (route.request().resourceType() === 'document' && (path.startsWith('/app/') || path === '/onboarding/app')) + await route.fulfill({ body: html, contentType: 'text/html' }) + else + await route.continue() + }) + }) + + const appId = 'com.example.onboarding-preview' + const setup = `/onboarding/app?resume=${appId}&step=setup` + + test('lands on the app dashboard, prompts on refresh, and returns to fullscreen setup', async ({ page }) => { + await page.goto(setup) + await expect(page.locator('[data-test="onboarding-setup-cli"]')).toBeVisible() + await page.getByRole('button', { name: 'Explore dashboard', exact: true }).click() + await expect(page).toHaveURL(new RegExp(`/app/${appId}$`)) + await expect(page.getByRole('heading', { name: 'App dashboard', exact: true })).toBeVisible() + await expect(page.getByText('Continue exploring or return to setup?', { exact: true })).toHaveCount(0) + await page.reload() + await expect(page.getByText('It looks like you have been exploring the dashboard.', { exact: false })).toBeVisible() + await page.getByRole('button', { name: 'Continue exploring', exact: true }).click() + await expect(page.getByText('Continue exploring or return to setup?', { exact: true })).toHaveCount(0) + await page.reload() + await page.getByRole('button', { name: 'Come back to the setup', exact: true }).click() + await expect(page).toHaveURL(new RegExp(`/onboarding/app\\?resume=${appId}&step=setup$`)) + await expect(page.locator('[data-test="onboarding-setup-cli"]')).toBeVisible() + await expect(page.locator('[data-test="getting-started-page"]')).toHaveCount(0) + }) + + test('resumes the app in its owning organization after exploring another organization', async ({ page }) => { + await page.goto(`${setup}&wrongOrg=1`) + await expect(page.locator('[data-test="onboarding-setup-cli"]')).toBeVisible() + expect(await page.evaluate(() => (window as any).onboardingSetupPreview.selectedOrgId.value)).toBe('00000000-0000-4000-8000-000000000002') + await expect(page.locator('[data-test^="app-onboarding-cli-step-"]')).toHaveCount(7) + }) + + test('stores permanent browser dismissal without hiding onboarding or removing the return button', async ({ page }) => { + await page.goto(setup) + await page.getByRole('button', { name: 'Explore dashboard', exact: true }).click() + await expect(page.locator('[data-test="preview-app-dashboard"]')).toBeVisible() + await page.reload() + await page.getByRole('button', { name: 'Don\'t show this again', exact: true }).click() + expect(await page.evaluate(() => localStorage.getItem('capgo:onboarding-exploration-reminder-dismissed:00000000-0000-4000-8000-000000000001'))).toBe('true') + await page.reload() + await expect(page.locator('[data-test="preview-app-dashboard"]')).toBeVisible() + await expect(page.getByText('Continue exploring or return to setup?', { exact: true })).toHaveCount(0) + await page.getByRole('button', { name: 'Continue with setup', exact: true }).click() + await expect(page.locator('[data-test="onboarding-setup-cli"]')).toBeVisible() + }) + + test('shows v3 getting-started page and keeps v2 getting-started', async ({ page }) => { + await page.goto(`/app/${appId}/getting-started?version=3`) + await expect(page.locator('[data-test="getting-started-page"]')).toBeVisible() + await expect(page.locator('[data-test="getting-started-cli-panel"]')).toBeVisible() + await expect(page).toHaveURL(new RegExp(`/app/${appId}/getting-started\\?version=3$`)) + await expect(page.locator('[data-test="onboarding-setup-cli"]')).toHaveCount(0) + expect(await page.evaluate(() => (window as any).onboardingSetupPreview.events)).toContain('getting-started-mounted') + await page.goto(`/app/${appId}/getting-started?version=2`) + await expect(page.locator('[data-test="getting-started-page"]')).toBeVisible() + await expect(page).toHaveURL(new RegExp(`/app/${appId}/getting-started\\?version=2$`)) + await expect(page.locator('[data-test="onboarding-setup-cli"]')).toHaveCount(0) + expect(await page.evaluate(() => (window as any).onboardingSetupPreview.events)).toContain('getting-started-mounted') + }) + + test('links Getting started to the getting-started page for v3 and v2', async ({ page }) => { + await page.goto(`/app/${appId}?version=3`) + const link = page.locator('[data-test="getting-started-nav-link"]') + await expect(link).toHaveAttribute('href', `/app/${appId}/getting-started`) + await link.click() + await expect(page.locator('[data-test="getting-started-page"]')).toBeVisible() + await expect(page.locator('[data-test="getting-started-cli-panel"]')).toBeVisible() + expect(await page.evaluate(() => (window as any).onboardingSetupPreview.events)).toContain('getting-started-mounted') + + await page.goto(`/app/${appId}?version=2`) + await expect(link).toHaveAttribute('href', `/app/${appId}/getting-started`) + await link.click() + await expect(page.locator('[data-test="getting-started-page"]')).toBeVisible() + expect(await page.evaluate(() => (window as any).onboardingSetupPreview.events)).toContain('getting-started-mounted') + }) +}) diff --git a/playwright/e2e/register.spec.ts b/playwright/e2e/register.spec.ts index 69e49108a2..1bf6f2cf07 100644 --- a/playwright/e2e/register.spec.ts +++ b/playwright/e2e/register.spec.ts @@ -1,6 +1,13 @@ import type { Page } from '@playwright/test' +import { env } from 'node:process' +import { createClient } from '@supabase/supabase-js' +import { getSupabaseWorktreeConfig } from '../../scripts/supabase-worktree-config' import { expect, test } from '../support/commands' +const { ports: supabasePorts } = getSupabaseWorktreeConfig() +const localSupabaseUrl = `http://127.0.0.1:${supabasePorts.api}` +const localSupabaseAnonKey = env.SUPABASE_ANON_KEY || 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' + async function loginToOnboarding(page: Page, email: string, password: string) { await page.login(email, password, /\/onboarding\/app/) } @@ -11,6 +18,84 @@ async function continuePastWelcome(page: Page) { await continueButton.click() } +async function continuePastDevelopmentEnvironmentIfShown(page: Page) { + const assistantOption = page.locator('[data-test="onboarding-development-environment-ai_assistant"]') + const appNameInput = page.locator('[data-test="app-onboarding-name"]') + await expect(assistantOption.or(appNameInput)).toBeVisible() + if (await assistantOption.isVisible()) { + await assistantOption.click() + await page.click('[data-test="app-onboarding-continue-development-environment"]') + } +} + +async function continuePastChannelOnboardingIfShown(page: Page) { + const cliSetup = page.locator('[data-test="onboarding-setup-cli"]') + const routingContinue = page.locator('[data-test="channel-default-routing-continue"]') + await expect(routingContinue.or(cliSetup)).toBeVisible({ timeout: 60000 }) + if (!await routingContinue.isVisible()) + return + + await routingContinue.click() + await page.locator('[data-test="channel-self-assign-continue"]').click() + await page.locator('[data-test="channel-console-assign-continue"]').click() + + const createChannel = page.locator('[data-test="channel-create-submit"]') + const continueAfterChannel = page.locator('[data-test="channel-create-continue"]') + await expect(createChannel.or(continueAfterChannel)).toBeVisible() + if (await createChannel.isVisible()) { + await page.locator('[data-test="channel-create-name"]').fill('production') + await createChannel.click() + await expect(continueAfterChannel).toBeVisible() + } + await continueAfterChannel.click() + await expect(cliSetup).toBeVisible() +} + +async function forceWebNativeOnboardingTreatments(email: string, password: string) { + const supabase = createClient(localSupabaseUrl, localSupabaseAnonKey) + const { data: sessionData, error: signInError } = await supabase.auth.signInWithPassword({ email, password }) + if (signInError || !sessionData.user) + throw signInError ?? new Error('Cannot sign in treatment user') + + const { data: profile, error: profileError } = await supabase + .from('users') + .select('onboarding') + .eq('id', sessionData.user.id) + .single() + if (profileError) + throw profileError + + const onboarding = profile.onboarding && typeof profile.onboarding === 'object' && !Array.isArray(profile.onboarding) + ? profile.onboarding + : {} + const abtests = onboarding.abtests && typeof onboarding.abtests === 'object' && !Array.isArray(onboarding.abtests) + ? onboarding.abtests + : {} + const { error: updateError } = await supabase + .from('users') + .update({ + onboarding: { + ...onboarding, + abtests: { + ...abtests, + webnativeapp_publish_intent: { + assigned_at: new Date().toISOString(), + branch: 'A', + }, + webnativeapp_development_environment: { + assigned_at: new Date().toISOString(), + branch: 'C', + }, + }, + }, + }) + .eq('id', sessionData.user.id) + if (updateError) + throw updateError + + await supabase.auth.signOut() +} + async function expectProtectedRouteRedirect(page: Page, targetPath: string, expectedUrl: RegExp, expectedSelector: string) { const redirectedPage = await page.context().newPage() @@ -69,6 +154,7 @@ test.describe('Registration', () => { await continuePastWelcome(page) await page.click('[data-test="onboarding-intent-ota"]') await page.click('[data-test="app-onboarding-continue-intent"]') + await continuePastDevelopmentEnvironmentIfShown(page) await expect(page.locator('[data-test="app-onboarding-existing-yes"]')).toHaveCount(0) await expect(page.locator('[data-test="app-onboarding-existing-no"]')).toHaveCount(0) @@ -122,6 +208,7 @@ test.describe('Registration', () => { await continuePastWelcome(page) await page.click('[data-test="onboarding-intent-ota"]') await page.click('[data-test="app-onboarding-continue-intent"]') + await continuePastDevelopmentEnvironmentIfShown(page) await page.fill('[data-test="app-onboarding-name"]', appName) await continueFromAppNameToIcon(page) await Promise.all([ @@ -152,6 +239,50 @@ test.describe('Registration', () => { await expect(page.locator('[data-test="onboarding-org-name"]')).toHaveCount(0) }) + test('should recommend WebNativeApp to treatment users publishing without existing users', async ({ page }) => { + const uniqueSuffix = Date.now() + const email = `webnative-treatment-e2e-${uniqueSuffix}@example.com` + const password = 'Password123!' + const appName = `WebNative Treatment ${uniqueSuffix}` + + await page.fill('[data-test="email"]', email) + await page.fill('[data-test="first_name"]', 'WebNative') + await page.fill('[data-test="last_name"]', 'Treatment') + await page.fill('[data-test="password"]', password) + await page.fill('[data-test="confirm-password"]', password) + await page.click('[data-test="submit"]') + + await page.waitForURL(/\/onboarding\/app/) + await expect(page.locator('[data-test="onboarding-welcome-continue"]')).toBeVisible() + await forceWebNativeOnboardingTreatments(email, password) + await page.reload() + await expect(page.locator('[data-test="onboarding-resume-continue"]')).toBeVisible() + await page.locator('[data-test="onboarding-resume-continue"]').click() + + await expect(page.locator('[data-test="onboarding-intent-publish"]')).toBeVisible() + await expect(page.locator('[data-test="onboarding-development-environment-hosted_builder"]')).toHaveCount(0) + await page.click('[data-test="onboarding-intent-publish"]') + await page.click('[data-test="app-onboarding-continue-intent"]') + await expect(page.locator('[data-test="onboarding-development-environment-hosted_builder"]')).toBeVisible() + await expect(page.locator('[data-test="onboarding-intent-publish"]')).toHaveCount(0) + await expect(page.locator('[data-test="app-onboarding-skip-development-environment"]')).toBeVisible() + await expect(page.locator('[data-test="app-onboarding-continue-development-environment"]')).toHaveCount(0) + await page.click('[data-test="onboarding-development-environment-hosted_builder"]') + await expect(page.locator('[data-test="app-onboarding-continue-development-environment"]')).toBeVisible() + await page.click('[data-test="app-onboarding-continue-development-environment"]') + await page.fill('[data-test="app-onboarding-name"]', appName) + await continueFromAppNameToOrganization(page) + + await page.locator('[data-test="onboarding-starting-out"]').click() + await expect(page.locator('[data-test="onboarding-webnative-recommendation"]')).toBeVisible() + await expect(page.locator('[data-test="onboarding-webnative-check-website"]')).toHaveAttribute('href', 'https://webnativeapp.com/?ref=capgo') + await expect(page.locator('[data-test="onboarding-create-org"]')).toHaveCount(0) + + await page.click('[data-test="onboarding-webnative-continue-capgo"]') + await expect(page.locator('[data-test="onboarding-webnative-recommendation"]')).toHaveCount(0) + await expect(page.locator('[data-test="onboarding-create-org"]')).toBeEnabled() + }) + test('should allow new users to log out from org onboarding', async ({ page }) => { const uniqueSuffix = Date.now() const email = `no-org-logout-e2e-${uniqueSuffix}@example.com` diff --git a/playwright/e2e/second-app-getting-started-screenshot.spec.ts b/playwright/e2e/second-app-getting-started-screenshot.spec.ts new file mode 100644 index 0000000000..1e2712b636 --- /dev/null +++ b/playwright/e2e/second-app-getting-started-screenshot.spec.ts @@ -0,0 +1,53 @@ +import { mkdirSync } from 'node:fs' +import { resolve } from 'node:path' +import { spawnSync } from 'node:child_process' +import { expect, test } from '../support/commands' +import { dismissSupportPrompt } from '../support/dismissSupportPrompt' +import { setupTinbaseEdgeStubs } from '../support/tinbaseEdgeStubs' + +const screenshotDir = '/opt/cursor/artifacts/screenshots' + +test.describe('PR 3390 second app getting started screenshot', () => { + test.use({ baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:5175/' }) + test.skip(!process.env.CAPGO_PR_SCREENSHOTS, 'Set CAPGO_PR_SCREENSHOTS=1 for local PR screenshot capture') + + test.beforeAll(() => { + mkdirSync(screenshotDir, { recursive: true }) + }) + + test('capture getting started after creating a second app', async ({ page }) => { + const suffix = Date.now() + const appName = `Second App ${suffix}` + const appId = `com.test.secondapp.${suffix}` + + await setupTinbaseEdgeStubs(page) + + await page.login('test@capgo.app', 'testtest') + await dismissSupportPrompt(page) + + await page.goto('/app/new?onboarding=false') + await page.waitForSelector('[data-test="app-onboarding-existing-no"]', { timeout: 120000 }) + await page.click('[data-test="app-onboarding-existing-no"]') + await page.fill('[data-test="app-onboarding-name"]', appName) + await page.click('[data-test="app-onboarding-continue"]') + await page.waitForSelector('#app-onboarding-app-id', { timeout: 60000 }) + await page.fill('#app-onboarding-app-id', appId) + await page.click('[data-test="app-onboarding-continue"]') + await page.getByRole('heading', { name: 'Choose an icon' }).waitFor({ timeout: 60000 }) + await page.click('[data-test="app-onboarding-continue"]') + + await expect(page).toHaveURL(new RegExp(`/app/${appId.replace(/\./g, '\\.')}/getting-started`), { timeout: 120000 }) + await expect(page.locator('[data-test="dashboard-shell"]')).toBeVisible({ timeout: 60000 }) + await expect(page.locator('[data-test="getting-started-cli-panel"]')).toBeVisible({ timeout: 120000 }) + await expect(page.locator('[data-test="getting-started-cli-command-copy"], [data-test="getting-started-cli-command-loading"]')).toBeVisible({ timeout: 120000 }) + + const screenshotPath = resolve(screenshotDir, 'pr-3390-second-app-getting-started.webp') + const pngPath = `${screenshotPath}.png` + await page.screenshot({ path: pngPath, type: 'png', fullPage: false }) + const convert = spawnSync('ffmpeg', ['-y', '-i', pngPath, screenshotPath], { stdio: 'inherit' }) + if ((convert.status ?? 1) !== 0) + throw new Error(`Failed to convert screenshot to webp (exit ${convert.status ?? 1})`) + console.log(`SCREENSHOT=${screenshotPath}`) + console.log(`APP_ID=${appId}`) + }) +}) diff --git a/playwright/e2e/startup-loader.spec.ts b/playwright/e2e/startup-loader.spec.ts new file mode 100644 index 0000000000..f827a55fb2 --- /dev/null +++ b/playwright/e2e/startup-loader.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from '../support/commands' + +for (const colorScheme of ['light', 'dark'] as const) { + test(`startup loader matches ${colorScheme} mode before app scripts load`, async ({ page }) => { + await page.emulateMedia({ colorScheme }) + await page.addInitScript(theme => localStorage.setItem('theme', theme), colorScheme) + await page.route('**/*', (route) => { + if (route.request().resourceType() === 'script' && new URL(route.request().url()).pathname !== '/theme-bootstrap.js') + return route.abort() + return route.continue() + }) + await page.goto('/', { waitUntil: 'domcontentloaded' }) + const expectedBackground = colorScheme === 'light' ? 'rgb(248, 250, 252)' : 'rgb(15, 23, 42)' + await expect(page.locator('#app-loader')).toBeVisible() + await expect(page.locator('#app-loader')).toHaveCSS('background-color', expectedBackground) + await expect(page.locator('body')).toHaveCSS('background-color', expectedBackground) + await expect(page.locator('#app-loader img')).toBeVisible() + await expect(page.locator('#app-loader img')).toHaveCSS('filter', colorScheme === 'light' ? 'invert(1)' : 'invert(0)') + }) +} diff --git a/playwright/fixtures/email-verification.html b/playwright/fixtures/email-verification.html new file mode 100644 index 0000000000..bb6f5671d0 --- /dev/null +++ b/playwright/fixtures/email-verification.html @@ -0,0 +1,12 @@ + + + + + + Email verification preview + + +
+ + + diff --git a/playwright/fixtures/email-verification.ts b/playwright/fixtures/email-verification.ts new file mode 100644 index 0000000000..3b4f642381 --- /dev/null +++ b/playwright/fixtures/email-verification.ts @@ -0,0 +1,106 @@ +import type { Session } from '@supabase/supabase-js' +import { defaultConfig, plugin } from '@formkit/vue' +import { createPinia } from 'pinia' +import { createApp, defineComponent, h } from 'vue' +import { createRouter, createWebHistory, RouterView } from 'vue-router' +import { Toaster } from 'vue-sonner' +import { i18n } from '../../src/modules/i18n' +import { useSupabase } from '../../src/services/supabase' +import '../../src/styles/style.css' + +const state = { + sendError: null as { code: string, message: string, status: number } | null, + sendDelayMs: 0, + sends: [] as string[], + verificationError: false, + verifications: [] as string[], + captchaCallbacks: null as { error: () => void, unsupported: () => void } | null, +} +Object.assign(window, { emailVerificationPreview: state }) + +// Isolate the real page from external services; no emails or API writes occur. +window.fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + const verifying = url.includes('/verify_email_otp') + if (verifying) + state.verifications.push(JSON.parse(String(init?.body)).token) + const failed = verifying && state.verificationError + return new Response(JSON.stringify(verifying + ? failed ? { message: 'Invalid verification code' } : { verified_at: new Date().toISOString() } + : { email_otp_verified_at: null }), { + status: failed ? 400 : 200, + headers: { 'Content-Type': 'application/json' }, + }) +} +const supabase = useSupabase() +supabase.auth.getSession = async () => ({ data: { session: { + access_token: 'fixture-token', + user: { id: '00000000-0000-4000-8000-000000000001', email: 'verification-preview@example.com' }, +} as Session }, error: null }) +supabase.auth.signInWithOtp = async () => { + state.sends.push('send') + if (state.sendDelayMs) + await new Promise(resolve => setTimeout(resolve, state.sendDelayMs)) + return { data: { user: null, session: null }, error: state.sendError as any } +} +supabase.functions.invoke = async (_, options) => { + state.verifications.push((options?.body as { token: string }).token) + return state.verificationError + ? { data: null, error: new Error('Invalid verification code') } + : { data: { verified_at: new Date().toISOString() }, error: null } +} + +// A deterministic widget exercises the page's CAPTCHA transitions in the browser. +const widgets = new Map() +Object.assign(window, { turnstile: { + render(element: HTMLElement | null, options: { 'callback': (token: string) => void, 'error-callback': (code: string) => void, 'unsupported-callback': () => void }) { + if (!element) + return 'unmounted-fixture-widget' + state.captchaCallbacks = { + error: () => options['error-callback']('fixture-error'), + unsupported: () => options['unsupported-callback'](), + } + const id = `fixture-widget-${widgets.size}` + const label = document.createElement('label') + label.className = 'flex items-center gap-3 rounded border border-slate-300 bg-slate-50 p-4 text-slate-900' + const input = document.createElement('input') + input.type = 'checkbox' + input.setAttribute('aria-label', 'Verify you are human') + input.addEventListener('change', () => { + if (input.checked) + options.callback('fixture-captcha-token') + }) + label.append(input, 'Verify you are human') + element.append(label) + widgets.set(id, { element, input }) + return id + }, + reset() { + widgets.forEach(widget => widget.input.checked = false) + }, + remove(id: string) { + widgets.get(id)?.element.replaceChildren() + widgets.delete(id) + }, +} }) + +async function mountPreview() { + const { default: ResendEmailPage } = await import('../../src/pages/resend_email.vue') + const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/playwright/fixtures/email-verification.html', component: ResendEmailPage }, + { path: '/settings/account', component: { render: () => h('h1', 'Account settings') } }, + { path: '/login', component: { render: () => h('h1', 'Login') } }, + ], + }) + const app = createApp(defineComponent({ setup: () => () => [h(RouterView), h(Toaster)] })) + app.use(createPinia()) + app.use(plugin, defaultConfig()) + app.use(i18n) + app.use(router) + await router.isReady() + app.mount('#app') +} + +void mountPreview() diff --git a/playwright/fixtures/onboarding-setup.html b/playwright/fixtures/onboarding-setup.html new file mode 100644 index 0000000000..988f56fd44 --- /dev/null +++ b/playwright/fixtures/onboarding-setup.html @@ -0,0 +1,12 @@ + + + + + + Capgo setup preview + + +
+ + + diff --git a/playwright/fixtures/onboarding-setup.ts b/playwright/fixtures/onboarding-setup.ts new file mode 100644 index 0000000000..698b840dfc --- /dev/null +++ b/playwright/fixtures/onboarding-setup.ts @@ -0,0 +1,217 @@ +import type { OnboardingChannelEvent, OnboardingChannelEventProperties } from '../../src/utils/onboardingChannelAnalytics' +import { createPinia } from 'pinia' +import { createApp, defineComponent, h, onMounted, ref } from 'vue' +import { createRouter, createWebHistory, RouterView, useRoute, useRouter } from 'vue-router' +import AppOnboardingCliSteps from '../../src/components/dashboard/AppOnboardingCliSteps.vue' +import AppOnboardingFlow from '../../src/components/dashboard/AppOnboardingFlow.vue' +import AppOnboardingSetupChecklist from '../../src/components/dashboard/AppOnboardingSetupChecklist.vue' +import GettingStartedNav from '../../src/components/dashboard/GettingStartedNav.vue' +import OnboardingExploreBanner from '../../src/components/dashboard/OnboardingExploreBanner.vue' +import OnboardingExploreReminder from '../../src/components/dashboard/OnboardingExploreReminder.vue' +import DialogV2 from '../../src/components/DialogV2.vue' +import { i18n } from '../../src/modules/i18n' +import { install as installOnboardingSetupNavigation } from '../../src/modules/onboarding-setup' +import { allowOnboardingDashboardExploration } from '../../src/utils/onboardingRedirect' +import GettingStartedPage from '../../src/pages/app/[app].getting-started.vue' +import { useSupabase } from '../../src/services/supabase' +import { useMainStore } from '../../src/stores/main' +import { useOrganizationStore } from '../../src/stores/organization' +import '../../src/styles/style.css' + +const params = new URLSearchParams(location.search) +const navigationView = params.get('view') === 'navigation' || location.pathname.startsWith('/app/') || location.pathname === '/onboarding/app' +const previewAppId = 'com.example.onboarding-preview' +const savedChannelStatus = params.get('channelStatus') +const state = { + version: Number(params.get('version') ?? 3), + steps: (savedChannelStatus === 'done' || savedChannelStatus === 'skipped' + ? { add_channel: { status: savedChannelStatus } } + : {}) as Record, + outcome: 'in_progress', + error: false, + requests: 0, + polls: [] as Array<{ appId: string, N: number, initial: boolean }>, + channels: (params.get('channel') === '1' ? [{ id: 1, app_id: previewAppId, name: 'staging', public: true, allow_device_self_set: true }] : []) as Array<{ id: number, app_id: string, name: string, public?: boolean, allow_device_self_set?: boolean }>, + channelError: false, + channelRequests: 0, + channelQueries: [] as string[], + channelDelayMs: Number(params.get('channelDelay') ?? 0), + channelPermissions: true, + channelInsertError: false, + channelInsertDelayMs: 0, + channelInserts: [] as Record[], +} +const events: string[] = [] +const channelEvents: Array<{ event: OnboardingChannelEvent, properties: OnboardingChannelEventProperties }> = [] +const preview = { state, events, channelEvents, appId: ref(previewAppId), command: ref('npx @capgo/cli@latest i [API_KEY]'), hiding: ref(false), selectedOrgId: ref('') } +Object.assign(window, { onboardingSetupPreview: preview }) + +// This isolated component fixture never sends requests to production. +window.fetch = async (input, init) => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url, location.origin) + if (url.pathname.endsWith('/onboarding_progress')) { + const body = JSON.parse(String(init?.body)) + state.polls.push(body) + state.requests += 1 + const checkChannel = body.initial || body.N % 5 === 0 + const hasChannel = state.channels.some(channel => channel.app_id === body.appId) + const channelError = state.channelError + if (checkChannel) { + state.channelRequests += 1 + state.channelQueries.push(`eq.${body.appId}`) + if (state.channelDelayMs) + await new Promise(resolve => setTimeout(resolve, state.channelDelayMs)) + } + return new Response(JSON.stringify(state.error + ? { message: 'Progress unavailable' } + : { + onboarding: { setup: { todo_list_version: state.version, steps: state.steps, outcome: state.outcome } }, + ...(checkChannel && !channelError ? { hasChannel } : {}), + checkErrors: checkChannel && channelError ? ['add_channel'] : [], + }), { status: state.error ? 503 : 200, headers: { 'Content-Type': 'application/json' } }) + } + if (url.pathname.includes('/rpc/')) { + return new Response(JSON.stringify(state.channelPermissions), { headers: { 'Content-Type': 'application/json' } }) + } + if (url.pathname.endsWith('/channels')) { + if (init?.method === 'POST') { + const insert = JSON.parse(String(init.body)) + state.channelInserts.push(insert) + if (state.channelInsertDelayMs) + await new Promise(resolve => setTimeout(resolve, state.channelInsertDelayMs)) + if (state.channelInsertError) + return new Response(JSON.stringify({ message: 'Insert unavailable' }), { status: 400, headers: { 'Content-Type': 'application/json' } }) + state.channels.push({ id: state.channels.length + 1, ...insert }) + return new Response(null, { status: 201 }) + } + state.channelRequests += 1 + const filter = url.searchParams.get('app_id') ?? '' + state.channelQueries.push(filter) + const rows = state.channels.filter(channel => ( + (!filter || filter === `eq.${channel.app_id}`) + && (!url.searchParams.get('public') || url.searchParams.get('public') === `eq.${channel.public}`) + && (!url.searchParams.get('name') || url.searchParams.get('name') === `eq.${channel.name}`) + )).slice(0, Number(url.searchParams.get('limit') ?? state.channels.length)) + const error = state.channelError + if (state.channelDelayMs > 0) + await new Promise(resolve => setTimeout(resolve, state.channelDelayMs)) + return new Response(JSON.stringify(error ? { message: 'Channels unavailable' } : rows), { + status: error ? 503 : 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + if (params.get('view') === 'flow' || navigationView) { + const app = { id: '00000000-0000-4000-8000-000000000003', app_id: previewAppId, name: 'My Capacitor app', icon_url: '', owner_org: '00000000-0000-4000-8000-000000000002', need_onboarding: true, onboarding: { setup: { todo_list_version: state.version, steps: state.steps, outcome: state.outcome } } } + const user = { id: '00000000-0000-4000-8000-000000000001', email: 'preview@example.com', onboarding: { intent: 'ota', status: 'in_progress', step: 'setup', flow: 'app', setup_stage: 'cli', app_id: previewAppId } } + const rows = url.pathname.endsWith('/apps') ? (!url.searchParams.get('owner_org') || url.searchParams.get('owner_org') === `eq.${app.owner_org}` ? [app] : []) : url.pathname.endsWith('/users') ? [user] : url.pathname.endsWith('/apikeys') ? [{ key: '00000000-0000-4000-8000-000000000004', rbac_id: '00000000-0000-4000-8000-000000000005', expires_at: null }] : url.pathname.endsWith('/role_bindings') ? [{ principal_id: '00000000-0000-4000-8000-000000000005', scope_type: 'org', roles: { name: 'org_super_admin' } }] : [] + const single = new Headers(init?.headers).get('Accept')?.includes('object') + return new Response(JSON.stringify(single ? rows[0] ?? {} : rows), { headers: { 'Content-Type': 'application/json' } }) + } + state.requests += 1 + return new Response(JSON.stringify(state.error + ? { message: 'Progress unavailable' } + : [{ + onboarding: { setup: { todo_list_version: state.version, steps: state.steps, outcome: state.outcome } }, + }]), { + status: state.error ? 503 : 200, + headers: { 'Content-Type': 'application/json' }, + }) +} + +// Supply a fixture-only session. All fetch calls above are intercepted. +useSupabase().auth.getSession = async () => ({ data: { session: { access_token: 'fixture-token' } as any }, error: null }) + +function createSetupChecklistProps(onExplore: () => void) { + return { + appId: preview.appId.value, + command: preview.command.value, + hiding: preview.hiding.value, + leaving: false, + initialOnboarding: { setup: { todo_list_version: state.version, steps: state.steps, outcome: state.outcome } }, + onCopyCommand: async () => { + events.push('copy-command') + await navigator.clipboard.writeText(preview.command.value) + }, + onCopyAi: () => events.push('copy-ai'), + onHide: () => events.push('hide'), + onExplore, + onComplete: () => events.push('complete'), + onInviteOpened: () => events.push('invite-opened'), + onChannelAnalytics: (event: OnboardingChannelEvent, properties: OnboardingChannelEventProperties) => channelEvents.push({ event, properties }), + } +} + +const app = createApp(defineComponent({ + setup() { + return () => h('main', { + class: 'min-h-screen bg-slate-50 px-4 py-8 sm:px-6 lg:px-8 dark:bg-slate-950', + }, [ + h('div', { class: 'mx-auto max-w-6xl' }, [ + navigationView + ? h(RouterView) + : params.get('view') === 'flow' + ? h(AppOnboardingFlow, { onboarding: true }) + : params.get('view') === 'compact' + ? h(AppOnboardingCliSteps, { + appId: preview.appId.value, + initialOnboarding: { setup: { todo_list_version: state.version, steps: state.steps, outcome: state.outcome } }, + }) + : h(AppOnboardingSetupChecklist, createSetupChecklistProps(() => events.push('explore'))), + ]), + h(DialogV2), + ]) + }, +})) +const pinia = createPinia() +app.use(pinia) +// Supply identity to the real channel form without starting dashboard store watchers. +Object.defineProperty(useMainStore(pinia), 'user', { value: { id: '00000000-0000-4000-8000-000000000001', email: 'preview@example.com', onboarding: { intent: 'ota', status: 'in_progress', step: 'setup', flow: 'app', setup_stage: 'cli', app_id: previewAppId } } }) +Object.defineProperty(useMainStore(pinia), 'auth', { value: { id: '00000000-0000-4000-8000-000000000001' } }) +useMainStore(pinia).awaitInitialLoad = async () => true +const organization = useOrganizationStore(pinia) +const previewOrganization = { gid: '00000000-0000-4000-8000-000000000002' } +const selectedOrganization = ref(params.get('wrongOrg') === '1' ? { gid: '00000000-0000-4000-8000-000000000099' } : previewOrganization) +preview.selectedOrgId.value = selectedOrganization.value.gid +Object.defineProperty(organization, 'currentOrganization', { get: () => selectedOrganization.value }) +organization.getOrgByAppId = appId => appId === previewAppId ? previewOrganization as any : undefined +organization.setCurrentOrganization = (orgId) => { + if (orgId === previewOrganization.gid) + selectedOrganization.value = previewOrganization + preview.selectedOrgId.value = selectedOrganization.value.gid +} +organization.awaitInitialLoad = async () => true +organization.getAppsByOrgId = orgId => orgId === previewOrganization.gid ? [{ app_id: previewAppId, owner_org: orgId, name: 'My Capacitor app', icon_url: '', need_onboarding: true, onboarding: { setup: { todo_list_version: state.version, steps: state.steps, outcome: state.outcome } } }] : [] +app.use(i18n) +const router = createRouter({ + history: createWebHistory(), + routes: navigationView + ? [ + { path: '/app/:app', component: defineComponent({ setup: () => () => h('section', { 'data-test': 'preview-app-dashboard' }, [h('h1', 'App dashboard'), h(GettingStartedNav), h(OnboardingExploreBanner, { appId: previewAppId }), h(OnboardingExploreReminder, { appId: previewAppId })]) }) }, + { path: '/app/:app/getting-started', name: '/app/[app].getting-started', component: defineComponent({ + setup() { + onMounted(() => events.push('getting-started-mounted')) + return () => h(GettingStartedPage) + }, + }) }, + { path: '/onboarding/app', component: defineComponent({ + setup() { + const router = useRouter() + const route = useRoute() + onMounted(() => { + if (typeof route.query.resume === 'string') + organization.setCurrentOrganization(previewOrganization.gid) + }) + return () => h(AppOnboardingSetupChecklist, createSetupChecklistProps(() => { + events.push('explore') + allowOnboardingDashboardExploration('00000000-0000-4000-8000-000000000001', preview.appId.value) + void router.push(`/app/${preview.appId.value}`) + })) + }, + }) }, + { path: '/:pathMatch(.*)*', component: defineComponent({ setup: () => () => h(AppOnboardingFlow, { onboarding: true }) }) }, + ] + : [{ path: '/:pathMatch(.*)*', component: { render: () => null } }], +}) +installOnboardingSetupNavigation({ app, router, routes: router.options.routes }) +app.use(router) +void router.isReady().then(() => app.mount('#app')) diff --git a/playwright/support/tinbaseEdgeStubs.ts b/playwright/support/tinbaseEdgeStubs.ts new file mode 100644 index 0000000000..bebe3434a5 --- /dev/null +++ b/playwright/support/tinbaseEdgeStubs.ts @@ -0,0 +1,134 @@ +import type { Page } from '@playwright/test' + +const TINBASE_URL = process.env.TINBASE_URL || process.env.SUPABASE_URL || 'http://127.0.0.1:55321' +const SERVICE_KEY = process.env.TINBASE_SERVICE_KEY + || process.env.SUPABASE_SERVICE_ROLE_KEY + || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InRpbmJhc2UiLCJyb2xlIjoic2VydmljZV9yb2xlIiwiaWF0IjoxNzg5ODQ3NDYxLCJleHAiOjIxMDUyMDc0NjF9.o8a_69Jdjk-yG5ksvmS9vRWXUqCWGwz1AnCSqt4cXZA' +const TEST_USER_ID = '6aa76066-55ef-4238-ade6-0b32334a4097' +const ORG_ADMIN_ROLE_ID = '39eabda5-1db9-497a-93e9-cd28d19275cd' + +function serviceHeaders() { + return { + apikey: SERVICE_KEY, + Authorization: `Bearer ${SERVICE_KEY}`, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + } +} + +async function servicePost(path: string, body: unknown): Promise<{ ok: boolean, status: number, data: T }> { + const response = await fetch(`${TINBASE_URL}/rest/v1/${path}`, { + method: 'POST', + headers: serviceHeaders(), + body: JSON.stringify(body), + }) + const data = await response.json().catch(() => null) as T + return { ok: response.ok, status: response.status, data } +} + +export async function setupTinbaseEdgeStubs(page: Page) { + await page.route('**/private/sso/check-enforcement', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ allowed: true }), + }) + }) + + await page.route('**/functions/v1/statistics/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ global: [], byApp: [] }), + }) + }) + + await page.route('**/functions/v1/app', async (route) => { + if (route.request().method() !== 'POST') + return route.continue() + + const body = route.request().postDataJSON() as { + app_id: string + name: string + owner_org: string + need_onboarding?: boolean + existing_app?: boolean + ios_store_url?: string | null + android_store_url?: string | null + } + + const result = await servicePost>>('apps', { + app_id: body.app_id, + name: body.name, + owner_org: body.owner_org, + user_id: TEST_USER_ID, + icon_url: '', + need_onboarding: body.need_onboarding ?? true, + existing_app: body.existing_app ?? false, + created_from_onboarding: true, + ios_store_url: body.ios_store_url ?? null, + android_store_url: body.android_store_url ?? null, + }) + + if (!result.ok) { + await route.fulfill({ + status: result.status === 409 ? 409 : 500, + contentType: 'application/json', + body: JSON.stringify(result.data), + }) + return + } + + const app = Array.isArray(result.data) ? result.data[0] : result.data + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(app), + }) + }) + + await page.route('**/functions/v1/apikey', async (route) => { + if (route.request().method() !== 'POST') + return route.continue() + + const body = route.request().postDataJSON() as { + name?: string + bindings?: Array<{ org_id: string, scope_type: string, role_name?: string, app_id?: string }> + } + + const orgId = body.bindings?.[0]?.org_id + if (!orgId) { + await route.fulfill({ status: 400, body: JSON.stringify({ error: 'missing org' }) }) + return + } + + const plainKey = `capgo_${crypto.randomUUID().replace(/-/g, '')}` + const keyResult = await servicePost>>('apikeys', { + user_id: TEST_USER_ID, + key: plainKey, + name: body.name || 'api-key', + }) + + if (!keyResult.ok || !Array.isArray(keyResult.data) || !keyResult.data[0]) { + await route.fulfill({ status: 500, body: JSON.stringify(keyResult.data) }) + return + } + + const apikey = keyResult.data[0] + const rbacId = apikey.rbac_id as string + await servicePost('role_bindings', { + principal_type: 'apikey', + principal_id: rbacId, + role_id: ORG_ADMIN_ROLE_ID, + scope_type: 'org', + org_id: orgId, + granted_by: TEST_USER_ID, + }) + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ...apikey, key: plainKey, global_permissions: [] }), + }) + }) +} diff --git a/playwright/visual-diff.config.ts b/playwright/visual-diff.config.ts index daf25a5757..6035201cde 100644 --- a/playwright/visual-diff.config.ts +++ b/playwright/visual-diff.config.ts @@ -1,5 +1,4 @@ import type { Page } from '@playwright/test' -import { dismissSupportPrompt } from './support/dismissSupportPrompt' export interface VisualDiffRoute { slug: string @@ -18,6 +17,29 @@ export const visualDiffRoutes: VisualDiffRoute[] = [ { slug: 'login', path: '/login/', auth: false }, { slug: 'dashboard', path: '/dashboard', auth: true }, { slug: 'account-settings', path: '/settings/account', auth: true }, + { + slug: 'email-verification-send', + path: '/resend_email', + auth: true, + prepare: async (page) => { + await page.route('**/rest/v1/user_security?*', route => route.fulfill({ json: { email_otp_verified_at: null } })) + await page.goto('/resend_email?reason=email_not_verified&return_to=/settings/account') + await page.getByRole('button', { name: 'Send verification code', exact: true }).waitFor() + }, + }, + { + slug: 'email-verification-code', + path: '/resend_email', + auth: true, + prepare: async (page) => { + // Keep screenshots deterministic and never send a real verification email. + await page.route('**/rest/v1/user_security?*', route => route.fulfill({ json: { email_otp_verified_at: null } })) + await page.route('**/auth/v1/otp', route => route.fulfill({ json: { user: null, session: null } })) + await page.goto('/resend_email?reason=email_not_verified&return_to=/settings/account') + await page.getByRole('button', { name: 'Send verification code', exact: true }).click() + await page.getByLabel('Enter the verification code', { exact: true }).waitFor() + }, + }, { slug: 'organization-credits', path: '/settings/organization/credits', auth: true }, { slug: 'apps', path: '/apps', auth: true }, { @@ -25,7 +47,6 @@ export const visualDiffRoutes: VisualDiffRoute[] = [ path: '/apps', auth: true, prepare: async (page) => { - await dismissSupportPrompt(page) const toggle = page.locator('[data-test="sidebar-collapse-toggle"]') if (!(await toggle.count())) return @@ -44,6 +65,26 @@ export const visualDiffRoutes: VisualDiffRoute[] = [ { slug: 'app-dashboard-native', path: '/app/com.demo.app/native', auth: true }, { slug: 'app-dashboard-installs', path: '/app/com.demo.app/installs', auth: true }, { slug: 'app-dashboard-active-bundle', path: '/app/com.demo.app/active-bundle', auth: true }, + { + slug: 'onboarding-setup-v3', + path: '/apps', + auth: true, + prepare: async (page) => { + // Read-only response fixtures let both base and head render the same app. + // The base ignores version 3; the head shows the experiment treatment. + const onboarding = { setup: { todo_list_version: 3, source: 'manual', outcome: 'in_progress', steps: {} } } + await page.route('**/rest/v1/apps?*', async (route) => { + const response = await route.fetch() + const json = await response.json().catch(() => null) + const override = (row: any) => row?.app_id === 'com.demo.app' ? { ...row, need_onboarding: true, onboarding } : row + await route.fulfill({ response, json: Array.isArray(json) ? json.map(override) : override(json) }) + }) + await page.route('**/rpc/verify_getting_started', route => route.fulfill({ json: onboarding })) + await page.route('**/private/onboarding_progress', route => route.fulfill({ json: { onboarding, hasChannel: false, checkErrors: [] } })) + await page.goto('/app/new?resume=com.demo.app&step=setup') + await page.getByRole('heading', { name: /Start guided setup|Finish setup in your app/ }).waitFor() + }, + }, { slug: 'app-getting-started', path: '/app/com.demo.app/getting-started', auth: true }, { slug: 'app-settings', path: '/app/com.demo.app/settings', auth: true }, { slug: 'app-settings-access', path: '/app/com.demo.app/settings/access', auth: true }, diff --git a/private/cli-mcp-tests b/private/cli-mcp-tests index 774693c28e..a1a3e74785 160000 --- a/private/cli-mcp-tests +++ b/private/cli-mcp-tests @@ -1 +1 @@ -Subproject commit 774693c28ed3e3dab47e26377a426e98e057fcbe +Subproject commit a1a3e74785e7f9834ee197659e89d8cea98de7d2 diff --git a/public/_headers b/public/_headers index b1e0a726e7..5c708424bd 100644 --- a/public/_headers +++ b/public/_headers @@ -6,7 +6,7 @@ X-Frame-Options: DENY X-XSS-Protection: 1; mode=block Referrer-Policy: strict-origin - Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://static.cloudflareinsights.com https://psthg.capgo.app; style-src 'self' 'unsafe-inline' https://fonts.bunny.net; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.bunny.net; connect-src 'self' blob: https: wss:; media-src 'self' data: blob: https:; frame-src 'self' https://challenges.cloudflare.com https:; worker-src 'self' blob:; manifest-src 'self'; upgrade-insecure-requests + Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self' https://checkout.stripe.com https://billing.stripe.com; script-src 'self' https://challenges.cloudflare.com https://static.cloudflareinsights.com https://psthg.capgo.app; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' blob: https://*.supabase.co wss://*.supabase.co https://psthg.capgo.app https://eu.posthog.com https://eu.i.posthog.com https://challenges.cloudflare.com https://api.github.com https://registry.npmjs.org https://*.preview.capgo.app https://*.preview.preprod.capgo.app https://*.preview.dev.capgo.app https://*.preview.development.capgo.app https://sb.capgo.app wss://sb.capgo.app https://api.capgo.app; media-src 'self' data: blob: https:; frame-src 'self' https://challenges.cloudflare.com https://checkout.stripe.com https://billing.stripe.com https://js.stripe.com https://*.preview.capgo.app https://*.preview.preprod.capgo.app https://*.preview.dev.capgo.app https://*.preview.development.capgo.app; worker-src 'self' blob:; manifest-src 'self'; upgrade-insecure-requests /api/* cache-control: public, s-max-age=60 /.well-known/change-password diff --git a/public/_redirects b/public/_redirects index a9ff606f57..b8080a8fc4 100644 --- a/public/_redirects +++ b/public/_redirects @@ -3,7 +3,6 @@ # https://w3c.github.io/webappsec-change-password-url/ /.well-known/change-password /settings/account/change-password 302 # Chrome probes this reserved path so the SPA fallback is not treated as a real change-password page. -/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200 /404 404 /.well-known/* /deepLink/:splat 200 /dashboard/settings/plans /settings/organization/plans diff --git a/public/theme-bootstrap.js b/public/theme-bootstrap.js new file mode 100644 index 0000000000..c850544101 --- /dev/null +++ b/public/theme-bootstrap.js @@ -0,0 +1,44 @@ +// Initialize theme before Vue app loads to prevent flash +(function () { + function applyTheme(isDark) { + const html = document.documentElement + if (isDark) { + html.classList.add('dark') + html.dataset.theme = 'capgodark' + } + else { + html.classList.remove('dark') + html.dataset.theme = 'capgolight' + } + } + + const savedTheme = localStorage.getItem('theme') + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + + let isDark + if (savedTheme === 'dark') { + isDark = true + } + else if (savedTheme === 'light') { + isDark = false + } + else { + isDark = mediaQuery.matches + } + + applyTheme(isDark) + + function onSystemThemeChange(event) { + const currentSavedTheme = localStorage.getItem('theme') + if (!currentSavedTheme || currentSavedTheme === 'auto') { + applyTheme(event.matches) + } + } + + if (typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener('change', onSystemThemeChange) + } + else if (typeof mediaQuery.addListener === 'function') { + mediaQuery.addListener(onSystemThemeChange) + } +})() diff --git a/read_replicate/schema_additive_sync.ts b/read_replicate/schema_additive_sync.ts index 808d713883..d4b56ef016 100644 --- a/read_replicate/schema_additive_sync.ts +++ b/read_replicate/schema_additive_sync.ts @@ -85,7 +85,9 @@ interface SchemaCatalog { type SyncStatementKind = | 'column' + | 'check_constraint' | 'constraint' + | 'drop_check_constraint' | 'function' | 'index' | 'invalid_index' @@ -407,10 +409,46 @@ export function planReadReplicaSchemaSync( }) } for (const constraint of expectedCatalog.constraints ?? []) { - // Publisher CHECK constraints do not need to exist on a read-only logical - // subscriber. Creating them here can only add avoidable replica writes. - if (constraint.type === 'c') + if (constraint.type === 'c') { + const actualConstraint = actualConstraintsByKey.get( + constraintKey(constraint), + ) + // Missing publisher CHECK constraints stay optional on read-only subscribers. + if (!actualConstraint || constraintMatches(constraint, actualConstraint)) + continue + + const dropSql = buildDropCheckConstraintStatement( + constraint, + actualTables, + ) + const addSql = buildAddCheckConstraintStatement( + constraint, + actualTables, + ) + if (!dropSql || !addSql) { + skipped.push({ + kind: 'constraint', + table: constraint.table, + name: constraint.name, + reason: 'check_constraint_conflict', + }) + continue + } + + statements.push({ + kind: 'drop_check_constraint', + table: constraint.table, + name: constraint.name, + sql: dropSql, + }) + statements.push({ + kind: 'check_constraint', + table: constraint.table, + name: constraint.name, + sql: addSql, + }) continue + } const actualConstraint = actualConstraintsByKey.get( constraintKey(constraint), @@ -1175,6 +1213,35 @@ function buildCreateIndexStatement( return `CREATE ${unique}INDEX CONCURRENTLY IF NOT EXISTS ${quoteIdent(index.name)} ON ${quoteQualifiedTable(index.table)} ${indexTail}` } + +function buildDropCheckConstraintStatement( + constraint: Pick, + actualTables: Set, +): string | null { + if (!isSafeReplicaTable(constraint.table, actualTables)) + return null + if (!isSafeQuotedIdentifier(constraint.name)) + return null + + return `ALTER TABLE ${quoteQualifiedTable(constraint.table)} DROP CONSTRAINT IF EXISTS ${quoteIdent(constraint.name)}` +} + +function buildAddCheckConstraintStatement( + constraint: SchemaConstraint, + actualTables: Set, +): string | null { + if ( + constraint.type !== 'c' + || !isSafeReplicaTable(constraint.table, actualTables) + || !isSafeQuotedIdentifier(constraint.name) + || !isSafeCheckConstraintDefinition(constraint.definition) + ) { + return null + } + + return `ALTER TABLE ${quoteQualifiedTable(constraint.table)} ADD CONSTRAINT ${quoteIdent(constraint.name)} ${constraint.definition}` +} + function buildAttachConstraintStatement( constraint: SchemaConstraint, expectedIndex: SchemaIndex | undefined, @@ -1493,6 +1560,13 @@ function isSafeSchemaDefinition(value: string): boolean { ) } +function isSafeCheckConstraintDefinition(value: string): boolean { + return ( + value.startsWith('CHECK (') + && isSafeSchemaDefinition(value) + ) +} + function isConstraintType(value: unknown): value is SchemaConstraint['type'] { return value === 'c' || value === 'p' || value === 'u' } diff --git a/read_replicate/schema_compatibility.ts b/read_replicate/schema_compatibility.ts index 87299ba594..ce49322805 100644 --- a/read_replicate/schema_compatibility.ts +++ b/read_replicate/schema_compatibility.ts @@ -108,7 +108,7 @@ function readReplicaSchemaCompatibilityIssuesInternal( compareTables(expectedCatalog, actualCatalog, issues) compareColumns(expectedCatalog, actualCatalog, issues, allowSafeSubscriberOnlyObjects) - compareConstraints(expectedCatalog, actualCatalog, issues) + compareConstraints(expectedCatalog, actualCatalog, issues, allowSafeSubscriberOnlyObjects) compareIndexes(expectedCatalog, actualCatalog, issues, allowSafeSubscriberOnlyObjects) compareTypes(expectedCatalog, actualCatalog, issues, allowSafeSubscriberOnlyObjects) compareSequences(expectedCatalog, actualCatalog, issues, allowSafeSubscriberOnlyObjects) @@ -235,7 +235,12 @@ function compareSubscriberOnlyColumns( } } -function compareConstraints(expected: SchemaCatalog, actual: SchemaCatalog, issues: SchemaCompatibilityIssue[]): void { +function compareConstraints( + expected: SchemaCatalog, + actual: SchemaCatalog, + issues: SchemaCompatibilityIssue[], + allowSafeSubscriberOnlyObjects: boolean, +): void { const expectedConstraints = new Map((expected.constraints ?? []).map(constraint => [constraintKey(constraint), constraint])) const actualConstraints = new Map((actual.constraints ?? []).map(constraint => [constraintKey(constraint), constraint])) @@ -243,12 +248,18 @@ function compareConstraints(expected: SchemaCatalog, actual: SchemaCatalog, issu const key = constraintKey(expectedConstraint) const actualConstraint = actualConstraints.get(key) if (expectedConstraint.type === 'c') { + // Publisher CHECK constraints are optional on read-only logical subscribers. + // Missing CHECK constraints are ignored; stale subscriber CHECK constraints must not block deploy. if ( actualConstraint - && ( - actualConstraint.type !== 'c' - || actualConstraint.definition !== expectedConstraint.definition - ) + && actualConstraint.type !== 'c' + ) { + issues.push({ kind: 'constraint', object: key, reason: 'subscriber CHECK constraint differs' }) + } + else if ( + actualConstraint + && !allowSafeSubscriberOnlyObjects + && actualConstraint.definition !== expectedConstraint.definition ) { issues.push({ kind: 'constraint', object: key, reason: 'subscriber CHECK constraint differs' }) } diff --git a/read_replicate/schema_replicate.catalog.json b/read_replicate/schema_replicate.catalog.json index 4a53b9047c..01120db85f 100644 --- a/read_replicate/schema_replicate.catalog.json +++ b/read_replicate/schema_replicate.catalog.json @@ -2012,11 +2012,11 @@ "valid": true }, { - "definition": "CHECK (jsonb_typeof(onboarding) = 'object'::text AND (NOT onboarding ? 'intent'::text OR ((onboarding ->> 'intent'::text) = ANY (ARRAY['unknown'::text, 'ota'::text, 'builder'::text, 'both'::text, 'exploring'::text]))))", + "definition": "CHECK (jsonb_typeof(onboarding) = 'object'::text AND (NOT onboarding ? 'intent'::text OR ((onboarding ->> 'intent'::text) = ANY (ARRAY['unknown'::text, 'ota'::text, 'builder'::text, 'both'::text, 'exploring'::text, 'publish'::text]))) AND (NOT onboarding ? 'development_environment'::text OR jsonb_typeof(onboarding -> 'development_environment'::text) = 'string'::text AND ((onboarding ->> 'development_environment'::text) = ANY (ARRAY['hosted_builder'::text, 'ai_assistant'::text, 'hand_coded'::text, 'other'::text, 'local_project'::text, 'exploring'::text, 'skipped'::text])))) NOT VALID", "name": "orgs_onboarding_valid", "table": "orgs", "type": "c", - "valid": true + "valid": false }, { "definition": "CHECK (password_policy_config IS NULL OR jsonb_typeof(password_policy_config) = 'object'::text AND (NOT password_policy_config ? 'min_length'::text OR jsonb_typeof(password_policy_config -> 'min_length'::text) = 'number'::text AND ((password_policy_config ->> 'min_length'::text)::numeric) = trunc((password_policy_config ->> 'min_length'::text)::numeric) AND ((password_policy_config ->> 'min_length'::text)::numeric) >= 6::numeric AND ((password_policy_config ->> 'min_length'::text)::numeric) <= 72::numeric))", @@ -2251,6 +2251,13 @@ "table": "apps", "valid": true }, + { + "constraintOwned": false, + "definition": "CREATE INDEX idx_apps_onboarding_login_creator ON public.apps USING btree (((onboarding ->> 'created_by_user_id'::text))) WHERE ((onboarding #>> '{setup,todo_list_version}'::text[]) = ANY (ARRAY['2'::text, '3'::text]))", + "name": "idx_apps_onboarding_login_creator", + "table": "apps", + "valid": true + }, { "constraintOwned": false, "definition": "CREATE INDEX idx_apps_onboarding_ota_stage ON public.apps USING btree (((((onboarding -> 'features'::text) -> 'ota'::text) ->> 'stage'::text)))", diff --git a/read_replicate/schema_replicate.sql b/read_replicate/schema_replicate.sql index 1b775fccaa..b4b7d47616 100644 --- a/read_replicate/schema_replicate.sql +++ b/read_replicate/schema_replicate.sql @@ -407,7 +407,6 @@ CREATE TABLE public.orgs ( support_channel_url text, support_channel_set_at timestamp with time zone, CONSTRAINT orgs_max_apikey_expiration_days_valid CHECK (((max_apikey_expiration_days IS NULL) OR ((max_apikey_expiration_days >= 1) AND (max_apikey_expiration_days <= 365)))), - CONSTRAINT orgs_onboarding_valid CHECK (((jsonb_typeof(onboarding) = 'object'::text) AND ((NOT (onboarding ? 'intent'::text)) OR ((onboarding ->> 'intent'::text) = ANY (ARRAY['unknown'::text, 'ota'::text, 'builder'::text, 'both'::text, 'exploring'::text]))))), CONSTRAINT orgs_password_policy_config_min_length_check CHECK (((password_policy_config IS NULL) OR ((jsonb_typeof(password_policy_config) = 'object'::text) AND ((NOT (password_policy_config ? 'min_length'::text)) OR ((jsonb_typeof((password_policy_config -> 'min_length'::text)) = 'number'::text) AND (((password_policy_config ->> 'min_length'::text))::numeric = trunc(((password_policy_config ->> 'min_length'::text))::numeric)) AND ((((password_policy_config ->> 'min_length'::text))::numeric >= (6)::numeric) AND (((password_policy_config ->> 'min_length'::text))::numeric <= (72)::numeric))))))), CONSTRAINT orgs_required_encryption_key_valid CHECK (((required_encryption_key IS NULL) OR (length((required_encryption_key)::text) = ANY (ARRAY[20, 21])))), CONSTRAINT orgs_support_channel_type_check CHECK (((support_channel_type IS NULL) OR (support_channel_type = ANY (ARRAY['slack'::text, 'discord'::text, 'teams'::text])))), @@ -599,6 +598,14 @@ ALTER TABLE public.orgs ADD CONSTRAINT orgs_auto_top_up_threshold_min CHECK (((auto_top_up_threshold >= (10)::numeric) AND (auto_top_up_threshold = trunc(auto_top_up_threshold)) AND (auto_top_up_threshold < 'Infinity'::numeric) AND (auto_top_up_threshold > '-Infinity'::numeric))) NOT VALID; +-- +-- Name: orgs orgs_onboarding_valid; Type: CHECK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE public.orgs + ADD CONSTRAINT orgs_onboarding_valid CHECK (((jsonb_typeof(onboarding) = 'object'::text) AND ((NOT (onboarding ? 'intent'::text)) OR ((onboarding ->> 'intent'::text) = ANY (ARRAY['unknown'::text, 'ota'::text, 'builder'::text, 'both'::text, 'exploring'::text, 'publish'::text]))) AND ((NOT (onboarding ? 'development_environment'::text)) OR ((jsonb_typeof((onboarding -> 'development_environment'::text)) = 'string'::text) AND ((onboarding ->> 'development_environment'::text) = ANY (ARRAY['hosted_builder'::text, 'ai_assistant'::text, 'hand_coded'::text, 'other'::text, 'local_project'::text, 'exploring'::text, 'skipped'::text])))))) NOT VALID; + + -- -- Name: orgs orgs_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -870,6 +877,13 @@ CREATE INDEX idx_apps_created_at ON public.apps USING btree (created_at); CREATE INDEX idx_apps_default_upload_channel ON public.apps USING btree (default_upload_channel); +-- +-- Name: idx_apps_onboarding_login_creator; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_apps_onboarding_login_creator ON public.apps USING btree (((onboarding ->> 'created_by_user_id'::text))) WHERE ((onboarding #>> '{setup,todo_list_version}'::text[]) = ANY (ARRAY['2'::text, '3'::text])); + + -- -- Name: idx_apps_onboarding_ota_stage; Type: INDEX; Schema: public; Owner: - -- diff --git a/scripts/capture-manage-2fa-screenshot.mjs b/scripts/capture-manage-2fa-screenshot.mjs new file mode 100644 index 0000000000..17194ef3bc --- /dev/null +++ b/scripts/capture-manage-2fa-screenshot.mjs @@ -0,0 +1,133 @@ +import { chromium } from '@playwright/test' +import { createClient } from '@supabase/supabase-js' +import { readFileSync } from 'node:fs' +import { mkdir, unlink } from 'node:fs/promises' +import { spawnSync } from 'node:child_process' +import keys from '../configs.json' with { type: 'json' } + +const baseUrl = process.env.CAPGO_URL || 'http://localhost:5173' +const email = process.env.CAPGO_EMAIL || 'test@capgo.app' +const password = process.env.CAPGO_PASSWORD || 'testtest' +const branch = process.env.BRANCH || 'preprod' +const outputPath = process.argv[2] +const mode = process.argv[3] || 'step2' + +if (!outputPath) { + console.error('Usage: bun scripts/capture-manage-2fa-screenshot.mjs [step2|rate-limit|captcha-error]') + process.exit(1) +} + +const supabaseUrl = keys.supa_url[branch] || keys.supa_url.preprod +const supabaseAnon = keys.supa_anon[branch] || keys.supa_anon.preprod +const supabaseId = supabaseUrl.split('//')[1].split('.')[0].split(':')[0] +const storageKey = `sb-${supabaseId}-auth-token` + +const i18n = JSON.parse(readFileSync(new URL('../messages/en.json', import.meta.url), 'utf8')) +const rateLimitSeconds = 54 +const rateLimitError = i18n['email-otp-rate-limit-wait'].replace('{seconds}', String(rateLimitSeconds)) +const rateLimitCountdown = i18n['email-otp-rate-limit-countdown'].replace('{seconds}', String(rateLimitSeconds)) +const rateLimitButton = i18n['email-otp-send-wait'].replace('{seconds}', String(rateLimitSeconds)) +const captchaFail = i18n['captcha-fail'] +const sendCodeLabel = i18n['email-otp-send-code'] + +await mkdir(outputPath.split('/').slice(0, -1).join('/') || '.', { recursive: true }) + +const supabase = createClient(supabaseUrl, supabaseAnon) +const { data, error } = await supabase.auth.signInWithPassword({ email, password }) +if (error || !data.session) + throw new Error(error?.message || 'Login failed') + +const browser = await chromium.launch({ headless: true }) +const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }) + +await context.addInitScript((captureMode) => { + let autoCompleteCount = 0 + window.turnstile = { + render(_element, options) { + if (autoCompleteCount === 0) { + autoCompleteCount++ + queueMicrotask(() => options.callback?.('screenshot-turnstile-token')) + } + return 'screenshot-turnstile-widget' + }, + reset() {}, + remove() {}, + } +}, mode) + +const page = await context.newPage() + +if (mode === 'rate-limit' || mode === 'captcha-error') { + await page.route('**/auth/v1/otp**', async (route) => { + if (mode === 'rate-limit') { + await route.fulfill({ + status: 429, + contentType: 'application/json', + body: JSON.stringify({ + code: 'over_email_send_rate_limit', + message: `For security purposes, you can only request this after ${rateLimitSeconds} seconds.`, + }), + }) + return + } + + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 'captcha_failed', + message: 'captcha protection: request disallowed (no captcha_token found)', + }), + }) + }) +} + +await page.goto(`${baseUrl}/login`, { waitUntil: 'domcontentloaded' }) +await page.evaluate(({ storageKey, session }) => { + localStorage.setItem(storageKey, JSON.stringify({ + access_token: session.access_token, + refresh_token: session.refresh_token, + expires_at: session.expires_at, + expires_in: session.expires_in, + token_type: session.token_type, + user: session.user, + })) +}, { storageKey, session: data.session }) + +await page.goto(`${baseUrl}/settings/account/manage-2fa`, { waitUntil: 'domcontentloaded' }) +await page.waitForSelector('h2', { timeout: 60000 }) + +if (mode === 'step2') { + await page.getByRole('button', { name: sendCodeLabel }).waitFor({ timeout: 30000 }) +} +else { + await page.getByRole('button', { name: sendCodeLabel }).click() +} + +if (mode === 'rate-limit') { + await page.getByRole('alert').filter({ hasText: rateLimitError }).waitFor({ timeout: 15000 }) + await page.getByRole('status').filter({ hasText: rateLimitCountdown }).waitFor({ timeout: 15000 }) + await page.getByRole('button', { name: rateLimitButton }).waitFor({ timeout: 15000 }) +} +else if (mode === 'captcha-error') { + await page.getByText(captchaFail).first().waitFor({ timeout: 15000 }) + await page.locator('.max-w-lg.mx-auto h4').filter({ hasText: i18n['2fa-step-captcha'] }).waitFor({ timeout: 15000 }) +} +else if (mode === 'step2') { + await page.getByRole('button', { name: sendCodeLabel }).waitFor({ timeout: 15000 }) +} + +await page.waitForTimeout(500) + +const tempPng = `${outputPath}.tmp.png` +if (mode === 'captcha-error') + await page.screenshot({ path: tempPng, type: 'png', fullPage: false }) +else + await page.locator('.max-w-lg.mx-auto').first().screenshot({ path: tempPng, type: 'png' }) +const ffmpeg = spawnSync('ffmpeg', ['-y', '-i', tempPng, outputPath], { stdio: 'pipe' }) +await unlink(tempPng).catch(() => {}) +if (ffmpeg.status !== 0) + throw new Error(`ffmpeg failed: ${ffmpeg.stderr?.toString() || ffmpeg.status}`) + +console.log(`Saved ${outputPath}`) +await browser.close() diff --git a/scripts/check-cap9-deprecated.mjs b/scripts/check-cap9-deprecated.mjs new file mode 100644 index 0000000000..a7c9bb8c1e --- /dev/null +++ b/scripts/check-cap9-deprecated.mjs @@ -0,0 +1,307 @@ +#!/usr/bin/env node +/** + * Capacitor 9 deprecated native API guard. + * + * Fails when plugin native sources still use APIs removed in Capacitor 9. + * Does not flag Cordova SwiftPM product dependencies (still required on Cap 8). + * + * Usage: + * node scripts/check-cap9-deprecated.mjs packages/capacitor-notifications + */ + +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +const PLUGIN_REL_PATTERN = /^packages\/[\w-]+(?:\/[\w-]+)*$/ + +const SKIP_DIRS = new Set([ + 'node_modules', + 'dist', + 'build', + '.build', + '.gradle', + 'Pods', + 'DerivedData', + '.swiftpm', + '.git', + 'example-app', +]) + +/** @type {{ id: string, pattern: RegExp, exts: string[], ignoreLine?: RegExp }[]} */ +const RULES = [ + { + id: 'hasOption', + pattern: /\bhasOption\s*\(/, + exts: ['.java', '.kt', '.swift'], + }, + { + id: 'getConfigValue', + pattern: /\bgetConfigValue\s*\(/, + exts: ['.java', '.kt', '.swift'], + }, + { + id: '@NativePlugin', + pattern: /@NativePlugin\b/, + exts: ['.java', '.kt'], + }, + { + id: 'saveCall', + pattern: /\bsaveCall\s*\(/, + exts: ['.java', '.kt', '.swift'], + }, + { + id: 'getSavedCall', + pattern: /\bgetSavedCall\s*\(/, + exts: ['.java', '.kt', '.swift'], + }, + { + id: 'freeSavedCall', + pattern: /\bfreeSavedCall\s*\(/, + exts: ['.java', '.kt', '.swift'], + }, + { + id: 'releaseCall', + pattern: /\breleaseCall\s*\(/, + exts: ['.java', '.kt', '.swift'], + }, + { + id: 'pluginRequestPermission', + pattern: /\bpluginRequestPermissions?\s*\(/, + exts: ['.java', '.kt'], + }, + { + id: 'pluginRequestAllPermissions', + pattern: /\bpluginRequestAllPermissions\s*\(/, + exts: ['.java', '.kt'], + }, + { + id: 'hasDefinedPermissions', + pattern: /\bhasDefinedPermissions\s*\(/, + exts: ['.java', '.kt'], + }, + { + id: 'CAPBridge', + pattern: /\bCAPBridge\./, + exts: ['.swift'], + ignoreLine: /CAPBridgedPlugin/, + }, + { + id: 'CAPNotifications', + pattern: /\bCAPNotifications\b/, + exts: ['.swift'], + }, +] + +const CORDova_SPM_LINE + = /\.product\s*\(\s*name\s*:\s*"Cordova"\s*,\s*package\s*:\s*"capacitor-swift-pm"\s*\)/ + +/** @type {string} */ +let pluginRoot = '' + +/** Returns whether `targetPath` resolves inside `rootDir`. */ +function isUnderRoot(targetPath, rootDir) { + const resolved = path.resolve(targetPath) + const rel = path.relative(rootDir, resolved) + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)) +} + +/** + * Resolve plugin directory from a repo-relative path (no cwd / free-form CLI paths). + * @param {string | undefined} pluginRel + * @returns {string | null} + */ +function resolvePluginDir(pluginRel) { + if (!pluginRel) { + console.error('[cap9-deprecated] ERROR: missing plugin path (expected packages/)') + return null + } + const normalized = pluginRel.replaceAll('\\', '/') + if (!PLUGIN_REL_PATTERN.test(normalized)) { + console.error('[cap9-deprecated] ERROR: plugin path must match packages/') + return null + } + const abs = path.join(REPO_ROOT, normalized) + if (!isUnderRoot(abs, REPO_ROOT)) { + console.error('[cap9-deprecated] ERROR: plugin dir must stay inside repo') + return null + } + return abs +} + +/** @param {string} p */ +function readText(p) { + const resolved = path.resolve(p) + if (!isUnderRoot(resolved, pluginRoot) || !fs.existsSync(resolved)) { + return '' + } + return fs.readFileSync(resolved, 'utf8') +} + +/** @param {string} p */ +function exists(p) { + const resolved = path.resolve(p) + return isUnderRoot(resolved, pluginRoot) && fs.existsSync(resolved) +} + +/** @param {string} rootDir @param {string[]} exts */ +function walkFiles(rootDir, exts) { + const out = [] + const stack = [rootDir] + while (stack.length) { + const dir = stack.pop() + if (!dir || !isUnderRoot(dir, pluginRoot)) { + continue + } + let entries + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn(`[cap9-deprecated] skip unreadable directory ${dir}: ${message}`) + continue + } + for (const e of entries) { + if (e.isDirectory()) { + if (SKIP_DIRS.has(e.name)) + continue + stack.push(path.join(dir, e.name)) + continue + } + if (!e.isFile()) + continue + for (const ext of exts) { + if (e.name.endsWith(ext)) { + out.push(path.join(dir, e.name)) + break + } + } + } + } + out.sort() + return out +} + +/** @param {string} pluginDir @param {Record} pkg */ +function collectScanRoots(pluginDir, pkg) { + const cap = typeof pkg.capacitor === 'object' && pkg.capacitor ? pkg.capacitor : {} + const roots = [] + if (cap.android) { + const androidMain = path.join(pluginDir, 'android', 'src', 'main') + if (exists(androidMain)) + roots.push(androidMain) + } + if (cap.ios) { + const iosSources = path.join(pluginDir, 'ios', 'Sources') + if (exists(iosSources)) + roots.push(iosSources) + else { + const iosDir = path.join(pluginDir, 'ios') + if (exists(iosDir)) + roots.push(iosDir) + } + } + const packageSwift = path.join(pluginDir, 'Package.swift') + if (exists(packageSwift)) + roots.push(packageSwift) + return roots +} + +/** @param {string} filePath @param {{ exts: string[], ignoreLine?: RegExp, pattern: RegExp }} rule */ +function scanFile(filePath, rule) { + const ext = path.extname(filePath) + if (!rule.exts.includes(ext)) + return [] + + const txt = readText(filePath) + const lines = txt.split(/\r?\n/) + const hits = [] + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (filePath.endsWith('Package.swift') && CORDova_SPM_LINE.test(line)) { + continue + } + if (rule.ignoreLine?.test(line)) + continue + if (rule.pattern.test(line)) { + hits.push({ line: i + 1, text: line.trim() }) + } + } + return hits +} + +function run() { + pluginRoot = resolvePluginDir(process.argv[2]) ?? '' + if (!pluginRoot) { + console.error('Usage: node scripts/check-cap9-deprecated.mjs packages/') + process.exitCode = 2 + return + } + + const pkgPath = path.join(pluginRoot, 'package.json') + if (!exists(pkgPath)) { + console.error(`[cap9-deprecated] ERROR: missing package.json in ${pluginRoot}`) + process.exitCode = 2 + return + } + + let pkg + try { + pkg = JSON.parse(readText(pkgPath)) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`[cap9-deprecated] ERROR: invalid package.json (${pkgPath}): ${message}`) + process.exitCode = 2 + return + } + + const cap = typeof pkg.capacitor === 'object' && pkg.capacitor ? pkg.capacitor : {} + if (!cap.android && !cap.ios) { + process.exitCode = 0 + return + } + + const scanRoots = collectScanRoots(pluginRoot, pkg) + const allExts = [...new Set(RULES.flatMap(r => r.exts))] + const files = [] + for (const root of scanRoots) { + if (root.endsWith('Package.swift')) { + files.push(root) + continue + } + files.push(...walkFiles(root, allExts)) + } + + const violations = [] + for (const file of files) { + for (const rule of RULES) { + const hits = scanFile(file, rule) + for (const hit of hits) { + violations.push({ + rule: rule.id, + file: path.relative(pluginRoot, file), + line: hit.line, + text: hit.text, + }) + } + } + } + + if (violations.length) { + const relDir = path.relative(REPO_ROOT, pluginRoot) || '.' + console.error(`[cap9-deprecated] FAIL in ${relDir}`) + for (const v of violations) { + console.error(` - ${v.rule}: ${v.file}:${v.line}: ${v.text}`) + } + process.exitCode = 1 + return + } + + process.exitCode = 0 +} + +run() diff --git a/scripts/compute-external-integrity.ts b/scripts/compute-external-integrity.ts new file mode 100644 index 0000000000..b4c8016baa --- /dev/null +++ b/scripts/compute-external-integrity.ts @@ -0,0 +1,81 @@ +#!/usr/bin/env bun +/** + * Compute Subresource Integrity (SRI) hashes from real remote bytes. + * + * Usage: + * bun run security:compute-integrity -- [...] + * bun run security:compute-integrity -- --file docs/external-integrity-sources.json + * + * Example: + * bun run security:compute-integrity -- "https://fonts.bunny.net/css?family=inter:400" + * + * Never invent integrity attributes β€” always regenerate from this script when a + * pinned third-party asset changes. + */ + +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' + +interface IntegritySource { + url: string + label?: string +} + +function parseArgs(argv: string[]) { + const fileFlagIndex = argv.indexOf('--file') + if (fileFlagIndex !== -1) { + const filePath = argv[fileFlagIndex + 1] + if (!filePath) + throw new Error('Missing path after --file') + const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as IntegritySource[] | { sources: IntegritySource[] } + return Array.isArray(parsed) ? parsed : parsed.sources + } + + const urls = argv.filter(arg => arg !== '--' && !arg.startsWith('--')) + return urls.map(url => ({ url })) +} + +async function fetchBytes(url: string) { + const parsed = new URL(url) + if (parsed.protocol !== 'https:') + throw new Error(`Only HTTPS sources are allowed: ${url}`) + + const response = await fetch(url, { redirect: 'follow' }) + if (!response.ok) + throw new Error(`HTTP ${response.status} for ${url}`) + + const finalUrl = new URL(response.url) + if (finalUrl.protocol !== 'https:') + throw new Error(`Final URL must stay HTTPS: ${finalUrl.href}`) + + return Buffer.from(await response.arrayBuffer()) +} + +function toIntegrity(buffer: Buffer, algorithm: 'sha256' | 'sha384' | 'sha512' = 'sha384') { + const digest = createHash(algorithm).update(buffer).digest('base64') + return `${algorithm}-${digest}` +} + +async function main() { + const sources = parseArgs(process.argv.slice(2)) + if (sources.length === 0) { + console.error('Provide at least one HTTPS URL') + process.exit(1) + } + + for (const source of sources) { + const bytes = await fetchBytes(source.url) + const integrity = toIntegrity(bytes) + const label = source.label ? `${source.label}: ` : '' + console.log(`${label}${source.url}`) + console.log(` integrity="${integrity}"`) + console.log(` crossorigin="anonymous"`) + console.log(` bytes=${bytes.length}`) + console.log('') + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exit(1) +}) diff --git a/scripts/console-security-policy.ts b/scripts/console-security-policy.ts new file mode 100644 index 0000000000..7e6bcafafb --- /dev/null +++ b/scripts/console-security-policy.ts @@ -0,0 +1,179 @@ +/** + * Single source of truth for the Capgo console Content-Security-Policy. + * + * Sync into `public/_headers` with: + * bun run security:sync-headers + */ + +import configs from '../configs.json' + +export interface ConsoleCspOptions { + /** Local Vite dev server β€” allow http/ws to localhost. */ + dev?: boolean +} + +function joinSources(sources: string[]) { + return [...new Set(sources)].join(' ') +} + +function addConnectOrigin(sources: Set, raw: string) { + if (!raw) + return + + if (raw.includes('://')) { + try { + const url = new URL(raw) + sources.add(url.origin) + if (url.protocol === 'https:') + sources.add(`wss://${url.host}`) + else if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) + sources.add(`ws://${url.host}`) + } + catch { + // Ignore invalid configured URLs. + } + return + } + + const host = raw.split('/')[0] + if (host.includes('localhost') || host.startsWith('127.')) { + sources.add(`http://${host}`) + sources.add(`ws://${host}`) + } + else { + sources.add(`https://${host}`) + } +} + +function getDeployTimeConnectOverrides(): string[] { + const overrides: string[] = [] + const supaUrl = process.env.SUPA_URL + const apiDomain = process.env.API_DOMAIN + + if (typeof supaUrl === 'string' && supaUrl) + overrides.push(supaUrl) + if (typeof apiDomain === 'string' && apiDomain) + overrides.push(apiDomain) + + return overrides +} + +function getConfiguredConnectSources(dev: boolean): string[] { + const sources = new Set() + const envKeys = dev + ? (['prod', 'preprod', 'development', 'local'] as const) + : (['prod'] as const) + + for (const env of envKeys) { + const supaUrl = configs.supa_url?.[env] + if (typeof supaUrl === 'string') + addConnectOrigin(sources, supaUrl) + + const apiDomain = configs.api_domain?.[env] + if (typeof apiDomain === 'string') + addConnectOrigin(sources, apiDomain) + } + + for (const raw of getDeployTimeConnectOverrides()) + addConnectOrigin(sources, raw) + + return [...sources] +} + +const PREVIEW_CONNECT_SOURCES = [ + 'https://*.preview.capgo.app', + 'https://*.preview.preprod.capgo.app', + 'https://*.preview.dev.capgo.app', + 'https://*.preview.development.capgo.app', +] + +export function buildConsoleContentSecurityPolicy(options: ConsoleCspOptions = {}): string { + const dev = options.dev === true + + const scriptSrc = joinSources([ + '\'self\'', + 'https://challenges.cloudflare.com', // Cloudflare Turnstile + 'https://static.cloudflareinsights.com', // Cloudflare Web Analytics (if enabled) + 'https://psthg.capgo.app', // first-party PostHog reverse proxy + ]) + + const styleSrc = joinSources([ + '\'self\'', + '\'unsafe-inline\'', // Vue / Tailwind runtime styles + ]) + + const fontSrc = joinSources([ + '\'self\'', + 'data:', + ]) + + const imgSrc = joinSources([ + '\'self\'', + 'data:', + 'blob:', + 'https:', // org logos, avatars, store icons, signed storage URLs + ]) + + const mediaSrc = joinSources([ + '\'self\'', + 'data:', + 'blob:', + 'https:', + ]) + + const connectSrc = joinSources([ + '\'self\'', + 'blob:', + 'https://*.supabase.co', + 'wss://*.supabase.co', + 'https://psthg.capgo.app', + 'https://eu.posthog.com', + 'https://eu.i.posthog.com', + 'https://challenges.cloudflare.com', + 'https://api.github.com', + 'https://registry.npmjs.org', + ...PREVIEW_CONNECT_SOURCES, + ...getConfiguredConnectSources(dev), + ...(dev + ? [ + 'http://localhost:*', + 'http://127.0.0.1:*', + 'ws://localhost:*', + 'ws://127.0.0.1:*', + ] + : []), + ]) + + const frameSrc = joinSources([ + '\'self\'', + 'https://challenges.cloudflare.com', // Turnstile widget + 'https://checkout.stripe.com', + 'https://billing.stripe.com', + 'https://js.stripe.com', + ...PREVIEW_CONNECT_SOURCES, + ]) + + const directives = [ + 'default-src \'self\'', + 'base-uri \'self\'', + 'object-src \'none\'', + 'frame-ancestors \'none\'', + 'form-action \'self\' https://checkout.stripe.com https://billing.stripe.com', + `script-src ${scriptSrc}`, + `style-src ${styleSrc}`, + `img-src ${imgSrc}`, + `font-src ${fontSrc}`, + `connect-src ${connectSrc}`, + `media-src ${mediaSrc}`, + `frame-src ${frameSrc}`, + 'worker-src \'self\' blob:', + 'manifest-src \'self\'', + ] + + if (!dev) + directives.push('upgrade-insecure-requests') + + return directives.join('; ') +} + +export const CONSOLE_CONTENT_SECURITY_POLICY = buildConsoleContentSecurityPolicy() diff --git a/scripts/deploy-scope.ts b/scripts/deploy-scope.ts index bf3877c6c8..6f0beb8641 100644 --- a/scripts/deploy-scope.ts +++ b/scripts/deploy-scope.ts @@ -137,6 +137,14 @@ export function resolveDeployScopeFromFiles(files: string[]): DeployScope { }, {} as DeployScope) } +export function hasMigrationChanges(files: string[]): boolean { + return files.some(file => file.startsWith('supabase/migrations/')) +} + +export function requiresSchemaTypesSync(result: DeployScopeResult): boolean { + return result.base === null || hasMigrationChanges(result.files) +} + export function getComparableDeployHead(after: string, run: GitRunner = runGit): string { const subject = run(['log', '-1', '--format=%s', after]) if (!subject.startsWith('chore(release):')) { @@ -220,4 +228,5 @@ if (import.meta.main) { for (const target of deployTargets) { console.log(`${target}=${result.scope[target]}`) } + console.log(`requires_schema_types_sync=${requiresSchemaTypesSync(result)}`) } diff --git a/scripts/getTypes.mjs b/scripts/getTypes.mjs index 7bf50569f9..4da91b9af9 100644 --- a/scripts/getTypes.mjs +++ b/scripts/getTypes.mjs @@ -48,25 +48,33 @@ async function getTypeGenTarget() { ) } -async function main() { - try { - const args = ['supabase', 'gen', 'types', 'typescript', ...await getTypeGenTarget()] - const { stdout, stderr } = await execFile('bunx', args) - await writeFile('src/types/supabase.types.ts', stdout) - if (stderr) - console.error(stderr) - else - console.log('Type generated βœ…') - } - catch (e) { - console.error(e) // should contain code (exit code) and signal (that caused the termination). - } - try { - await copyFile('src/types/supabase.types.ts', 'supabase/functions/_backend/utils/supabase.types.ts') - console.log('Copy done βœ…') - } - catch (e) { - console.error(e) // should contain code (exit code) and signal (that caused the termination). - } +/** + * @typedef {object} TypeGenerationDependencies + * @property {(source: string, destination: string) => Promise} [copy] + * @property {(file: string, args: string[]) => Promise<{ stdout: string, stderr: string }>} [execute] + * @property {() => Promise} [resolveTarget] + * @property {(file: string, contents: string) => Promise} [write] + */ + +/** @param {TypeGenerationDependencies} [dependencies] */ +export async function generateTypes(dependencies = {}) { + const { + copy = copyFile, + execute = execFile, + resolveTarget = getTypeGenTarget, + write = writeFile, + } = dependencies + const args = ['supabase', 'gen', 'types', 'typescript', ...await resolveTarget()] + const { stdout, stderr } = await execute('bunx', args) + await write('src/types/supabase.types.ts', stdout) + if (stderr) + console.error(stderr) + else + console.log('Type generated βœ…') + + await copy('src/types/supabase.types.ts', 'supabase/functions/_backend/utils/supabase.types.ts') + console.log('Copy done βœ…') } -main() + +if (import.meta.main) + await generateTypes() diff --git a/scripts/playwright-frontend-preview.ts b/scripts/playwright-frontend-preview.ts index c4d2bb1a59..830be891bf 100644 --- a/scripts/playwright-frontend-preview.ts +++ b/scripts/playwright-frontend-preview.ts @@ -13,6 +13,7 @@ const env = { ...process.env, API_DOMAIN: apiDomain, CAPTCHA_KEY: '', + CAPGO_PLAYWRIGHT_FIXTURES: 'true', ENV: 'local', SUPA_ANON: process.env.SUPABASE_ANON_KEY || '', SUPA_URL: supabaseUrl, diff --git a/scripts/publish-release.ts b/scripts/publish-release.ts new file mode 100644 index 0000000000..617d2a5d21 --- /dev/null +++ b/scripts/publish-release.ts @@ -0,0 +1,158 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import process from 'node:process' + +type GitRunner = (args: string[]) => string + +export interface PublishReleaseOptions { + branch: string + expectedBranchSha: string + knownTags: readonly string[] + remote: string +} + +function runGit(args: string[]): string { + return execFileSync('git', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim() +} + +function isValidRefName(value: string): boolean { + return value.length > 0 + && value.length <= 255 + && !value.startsWith('.') + && !value.endsWith('.') + && !value.endsWith('/') + && !value.endsWith('.lock') + && !value.includes('..') + && !value.includes('//') + && !value.includes('@{') + && !/[\x00-\x20\x7F~^:?*[\\]/.test(value) + && !value.split('/').some(part => part.startsWith('.') || part.endsWith('.lock')) +} + +function validateOptions(options: PublishReleaseOptions): void { + if (!isValidRefName(options.branch)) + throw new Error(`Invalid release branch: ${options.branch}`) + if (!/^[0-9a-f]{40,64}$/i.test(options.expectedBranchSha)) + throw new Error('Expected branch SHA must be a full hexadecimal object ID') + if (!options.remote || /[\r\n]/.test(options.remote) || options.remote.startsWith('-')) + throw new Error('Release remote must be non-empty, contain no line breaks, and not start with "-"') + for (const tag of options.knownTags) { + if (!isValidRefName(tag)) + throw new Error(`Invalid known release tag: ${tag}`) + } +} + +function readRemoteBranchSha(remote: string, branch: string, run: GitRunner): string { + const ref = `refs/heads/${branch}` + const output = run(['ls-remote', '--heads', remote, ref]) + const [line, ...extraLines] = output.split('\n').filter(Boolean) + const match = line?.match(/^([0-9a-f]{40,64})\trefs\/heads\/(.+)$/i) + + if (!match || extraLines.length > 0 || match[2] !== branch) + throw new Error(`Could not resolve remote branch ${ref}`) + + return match[1] +} + +function getNewTags(knownTags: readonly string[], run: GitRunner): string[] { + const known = new Set(knownTags) + const output = run(['tag', '--list']) + const newTags = output.split('\n').filter(Boolean).filter(tag => !known.has(tag)) + + for (const tag of newTags) { + if (!isValidRefName(tag)) + throw new Error(`Invalid new release tag: ${tag}`) + } + + return newTags +} + +function readRemoteTagSha(remote: string, tag: string, run: GitRunner): string | null { + const ref = `refs/tags/${tag}` + const output = run(['ls-remote', '--refs', '--tags', remote, ref]) + const [line, ...extraLines] = output.split('\n').filter(Boolean) + const match = line?.match(/^([0-9a-f]{40,64})\trefs\/tags\/(.+)$/i) + + if (!match || extraLines.length > 0 || match[2] !== tag) + return null + + return match[1] +} + +function remoteTagsMatchLocal( + remote: string, + newTags: readonly string[], + run: GitRunner, +): boolean { + for (const tag of newTags) { + const localTagSha = run(['rev-parse', `refs/tags/${tag}`]) + const remoteTagSha = readRemoteTagSha(remote, tag, run) + if (!remoteTagSha || remoteTagSha !== localTagSha) + return false + } + + return true +} + +export function publishReleaseAtomically( + options: PublishReleaseOptions, + run: GitRunner = runGit, +): 'published' | 'superseded' { + validateOptions(options) + + const remoteSha = readRemoteBranchSha(options.remote, options.branch, run) + if (remoteSha !== options.expectedBranchSha) + return 'superseded' + + const newTags = getNewTags(options.knownTags, run) + const pushArgs = [ + 'push', + '--atomic', + options.remote, + `HEAD:refs/heads/${options.branch}`, + ...newTags.map(tag => `refs/tags/${tag}:refs/tags/${tag}`), + ] + + try { + run(pushArgs) + return 'published' + } + catch (error) { + try { + const remoteSha = readRemoteBranchSha(options.remote, options.branch, run) + const localHead = run(['rev-parse', 'HEAD']) + + if (remoteSha === localHead && remoteTagsMatchLocal(options.remote, newTags, run)) + return 'published' + if (remoteSha !== localHead && remoteSha !== options.expectedBranchSha) + return 'superseded' + } + catch { + // Preserve the original push failure when the remote state cannot be fully verified. + } + + throw error + } +} + +if (import.meta.main) { + const branch = process.argv[2] ?? '' + const expectedBranchSha = process.argv[3] ?? '' + const knownTagsPath = process.argv[4] ?? '' + const remote = process.env.RELEASE_REMOTE_URL ?? '' + + if (!branch || !expectedBranchSha || !knownTagsPath || !remote) { + console.error('Usage: RELEASE_REMOTE_URL= bun scripts/publish-release.ts ') + process.exit(1) + } + + const knownTags = readFileSync(knownTagsPath, 'utf8').split('\n').filter(Boolean) + const status = publishReleaseAtomically({ branch, expectedBranchSha, knownTags, remote }) + + if (status === 'superseded') + console.error(`Release publication was superseded because ${branch} advanced after this run started.`) + console.log(status) +} diff --git a/scripts/publish-schema-types.ts b/scripts/publish-schema-types.ts new file mode 100644 index 0000000000..60f42f232a --- /dev/null +++ b/scripts/publish-schema-types.ts @@ -0,0 +1,102 @@ +import { execFileSync } from 'node:child_process' +import process from 'node:process' + +type GitRunner = (args: string[]) => string + +export interface PublishSchemaTypesOptions { + branch: string + expectedBranchSha: string + remote: string +} + +function runGit(args: string[]): string { + return execFileSync('git', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim() +} + +function isValidRefName(value: string): boolean { + return value.length > 0 + && value.length <= 255 + && !value.startsWith('.') + && !value.endsWith('.') + && !value.endsWith('/') + && !value.endsWith('.lock') + && !value.includes('..') + && !value.includes('//') + && !value.includes('@{') + && !/[\x00-\x20\x7F~^:?*[\\]/.test(value) + && !value.split('/').some(part => part.startsWith('.') || part.endsWith('.lock')) +} + +function validateOptions(options: PublishSchemaTypesOptions): void { + if (!isValidRefName(options.branch)) + throw new Error(`Invalid schema/types branch: ${options.branch}`) + if (!/^[0-9a-f]{40,64}$/i.test(options.expectedBranchSha)) + throw new Error('Expected schema/types branch SHA must be a full hexadecimal object ID') + if (!options.remote || /[\r\n]/.test(options.remote) || options.remote.startsWith('-')) + throw new Error('Schema/types remote must be non-empty, contain no line breaks, and not start with "-"') +} + +function readRemoteBranchSha(remote: string, branch: string, run: GitRunner): string { + const ref = `refs/heads/${branch}` + const output = run(['ls-remote', '--heads', remote, ref]) + const [line, ...extraLines] = output.split('\n').filter(Boolean) + const match = line?.match(/^([0-9a-f]{40,64})\trefs\/heads\/(.+)$/i) + + if (!match || extraLines.length > 0 || match[2] !== branch) + throw new Error(`Could not resolve remote branch ${ref}`) + + return match[1] +} + +export function publishSchemaTypes( + options: PublishSchemaTypesOptions, + run: GitRunner = runGit, +): 'published' | 'retry' { + validateOptions(options) + + const remoteSha = readRemoteBranchSha(options.remote, options.branch, run) + if (remoteSha !== options.expectedBranchSha) + return 'retry' + + try { + run([ + 'push', + '--atomic', + options.remote, + `HEAD:refs/heads/${options.branch}`, + ]) + return 'published' + } + catch (error) { + try { + const remoteShaAfterFailure = readRemoteBranchSha(options.remote, options.branch, run) + const localHead = run(['rev-parse', 'HEAD']) + + if (remoteShaAfterFailure === localHead) + return 'published' + if (remoteShaAfterFailure !== options.expectedBranchSha) + return 'retry' + } + catch { + // Preserve the original push failure when remote state cannot be verified. + } + + throw error + } +} + +if (import.meta.main) { + const branch = process.argv[2] ?? '' + const expectedBranchSha = process.argv[3] ?? '' + const remote = process.env.SCHEMA_TYPES_REMOTE_URL ?? '' + + if (!branch || !expectedBranchSha || !remote) { + console.error('Usage: SCHEMA_TYPES_REMOTE_URL= bun scripts/publish-schema-types.ts ') + process.exit(1) + } + + console.log(publishSchemaTypes({ branch, expectedBranchSha, remote })) +} diff --git a/scripts/release-scope.ts b/scripts/release-scope.ts index 6abfb4852c..90a412651a 100644 --- a/scripts/release-scope.ts +++ b/scripts/release-scope.ts @@ -5,6 +5,12 @@ export type Component = 'capgo' | 'cli' | 'notifications' export type ReleaseAs = 'patch' | 'minor' | 'major' type GitRunner = (args: string[]) => string +export interface PendingReleaseScope { + base: string | null + releaseAs: ReleaseAs + shouldRelease: boolean +} + const capgoRootMatchers = [ /^package\.json$/, /^bun\.lock$/, @@ -73,6 +79,17 @@ function runGit(args: string[]): string { }).trim() } +function isDescribeWithoutMatchingTag(error: unknown): boolean { + if (!(error instanceof Error)) + return false + + const stderr = typeof (error as NodeJS.ErrnoException & { stderr?: string }).stderr === 'string' + ? (error as NodeJS.ErrnoException & { stderr: string }).stderr + : '' + const message = `${error.message}\n${stderr}` + return /no names found|no tags can describe|no tags exactly match|not enough tags|not a valid object name/i.test(message) +} + function getCommitShas(before: string, after: string, run: GitRunner = runGit): string[] { const isZero = before === '' || /^0+$/.test(before) @@ -132,9 +149,8 @@ export function toReleaseAs(severity: number): ReleaseAs { return 'patch' } -export function resolveReleaseScope(component: Component, before: string, after: string, run: GitRunner = runGit) { - const commits = getCommitShas(before, after, run) +function resolveCommitsScope(component: Component, commits: string[], run: GitRunner) { let shouldRelease = false let highestSeverity = 0 @@ -144,29 +160,83 @@ export function resolveReleaseScope(component: Component, before: string, after: continue } - shouldRelease = true const message = getCommitMessage(commit, run) + if (message.subject.startsWith('chore(auto-sync):')) + continue + + shouldRelease = true highestSeverity = Math.max(highestSeverity, getSeverity(message.subject, message.body)) } return { shouldRelease, - releaseAs: shouldRelease ? toReleaseAs(highestSeverity) : 'patch', + releaseAs: shouldRelease ? toReleaseAs(highestSeverity) : 'patch' as ReleaseAs, + } +} + +export function resolveReleaseScope(component: Component, before: string, after: string, run: GitRunner = runGit) { + const commits = getCommitShas(before, after, run) + + return resolveCommitsScope(component, commits, run) +} + +export function resolvePendingReleaseScope( + component: Component, + after: string, + includePrereleaseTags: boolean, + run: GitRunner = runGit, +): PendingReleaseScope { + const describeArgs = includePrereleaseTags + ? ['describe', '--tags', '--match', `${component}-*-alpha.*`, '--abbrev=0', after] + : [ + 'describe', + '--tags', + '--match', + `${component}-[0-9]*`, + '--exclude', + `${component}-*-alpha.*`, + '--abbrev=0', + after, + ] + + let base: string | null = null + try { + base = run(describeArgs) || null + } + catch (error) { + if (!isDescribeWithoutMatchingTag(error)) + throw error + base = null + } + + const range = base ? `${base}..${after}` : after + const commitsOutput = run(['rev-list', '--reverse', range]) + const commits = commitsOutput ? commitsOutput.split('\n').filter(Boolean) : [] + const scope = resolveCommitsScope(component, commits, run) + + return { + base, + ...scope, } } if (import.meta.main) { const componentArg = process.argv[2] - const before = process.argv[3] ?? '' + const modeOrBefore = process.argv[3] ?? '' const after = process.argv[4] ?? 'HEAD' if (componentArg !== 'capgo' && componentArg !== 'cli' && componentArg !== 'notifications') { - console.error('Usage: bun scripts/release-scope.ts [before] [after]') + console.error('Usage: bun scripts/release-scope.ts [--latest-stable|--latest-alpha|before] [after]') process.exit(1) } - const scope = resolveReleaseScope(componentArg, before, after) + const isLatestMode = modeOrBefore === '--latest-stable' || modeOrBefore === '--latest-alpha' + const scope = isLatestMode + ? resolvePendingReleaseScope(componentArg, after, modeOrBefore === '--latest-alpha') + : resolveReleaseScope(componentArg, modeOrBefore, after) + if ('base' in scope) + console.log(`base=${scope.base ?? ''}`) console.log(`should_release=${scope.shouldRelease}`) console.log(`release_as=${scope.releaseAs}`) } diff --git a/scripts/resolve-deploy-tag.ts b/scripts/resolve-deploy-tag.ts new file mode 100644 index 0000000000..9d89420445 --- /dev/null +++ b/scripts/resolve-deploy-tag.ts @@ -0,0 +1,111 @@ +import { execFileSync } from 'node:child_process' +import process from 'node:process' + +type GitRunner = (args: string[]) => string + +export interface DeployTag { + isAlpha: boolean + sha: string + tag: string +} + +const stableTagPattern = /^capgo-(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/ +const alphaTagPattern = /^capgo-(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-alpha\.(?:0|[1-9]\d*)$/ + +function runGit(args: string[]): string { + return execFileSync('git', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim() +} + +function isAlphaTag(tag: string): boolean { + if (stableTagPattern.test(tag)) + return false + if (alphaTagPattern.test(tag)) + return true + + throw new Error(`Malformed Capgo deployment tag: ${tag}`) +} + +export function resolveDeployTag( + tag: string, + run: GitRunner = runGit, +): DeployTag { + const isAlpha = isAlphaTag(tag) + const sha = run(['rev-list', '-n', '1', tag]) + if (!/^[0-9a-f]{40,64}$/i.test(sha)) + throw new Error(`Capgo deployment tag ${tag} did not resolve to a full commit SHA`) + + return { isAlpha, sha, tag } +} + +export function resolveLatestDeployTag( + includePrereleaseTags: boolean, + run: GitRunner = runGit, +): DeployTag { + const output = run([ + 'tag', + '--list', + 'capgo-[0-9]*', + '--sort=-version:refname', + '--sort=-creatordate', + ]) + const tags = output.split('\n').filter(Boolean) + const tag = tags.find(candidate => includePrereleaseTags + ? candidate.includes('-alpha.') + : !candidate.includes('-alpha.')) + + if (!tag) { + const environment = includePrereleaseTags ? 'alpha' : 'stable' + throw new Error(`No ${environment} Capgo deployment tag was found`) + } + + const expectedPattern = includePrereleaseTags ? alphaTagPattern : stableTagPattern + if (!expectedPattern.test(tag)) + throw new Error(`Malformed Capgo deployment tag: ${tag}`) + + const sha = run(['rev-list', '-n', '1', tag]) + if (!/^[0-9a-f]{40,64}$/i.test(sha)) + throw new Error(`Capgo deployment tag ${tag} did not resolve to a full commit SHA`) + + return { + isAlpha: includePrereleaseTags, + sha, + tag, + } +} + +export function assertCurrentDeployTag( + tag: string, + run: GitRunner = runGit, +): DeployTag { + const target = resolveDeployTag(tag, run) + const latest = resolveLatestDeployTag(target.isAlpha, run) + + if (latest.tag !== target.tag) { + const environment = target.isAlpha ? 'alpha' : 'stable' + throw new Error(`Deployment tag ${target.tag} is stale; current ${environment} tag is ${latest.tag}`) + } + + return target +} + +if (import.meta.main) { + const mode = process.argv[2] + const tag = process.argv[3] + if ((mode !== '--resolve' && mode !== '--assert-current') || !tag) { + console.error('Usage: bun scripts/resolve-deploy-tag.ts [--resolve|--assert-current] ') + process.exit(1) + } + + const target = mode === '--resolve' + ? resolveDeployTag(tag) + : assertCurrentDeployTag(tag) + console.error(`${mode === '--resolve' ? 'Resolved' : 'Validated'} deployment target: ${target.tag} (${target.sha})`) + if (mode === '--resolve') { + console.log(`deploy_tag=${target.tag}`) + console.log(`deploy_sha=${target.sha}`) + console.log(`is_alpha=${target.isAlpha}`) + } +} diff --git a/scripts/sync-console-headers.ts b/scripts/sync-console-headers.ts new file mode 100644 index 0000000000..908cf12038 --- /dev/null +++ b/scripts/sync-console-headers.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env bun +import { readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { buildConsoleContentSecurityPolicy } from './console-security-policy.ts' + +const headersPath = path.resolve(import.meta.dir, '../public/_headers') +const policy = buildConsoleContentSecurityPolicy() +const headers = readFileSync(headersPath, 'utf8') + +if (!/^ Content-Security-Policy: .+$/m.test(headers)) { + console.error('No Content-Security-Policy line found in public/_headers') + process.exit(1) +} + +const cspLine = ` Content-Security-Policy: ${policy}` +const nextHeaders = headers.replace( + /^ Content-Security-Policy: .+$/m, + cspLine, +) + +if (nextHeaders === headers) { + console.log('Content-Security-Policy already up to date in public/_headers') + process.exit(0) +} + +writeFileSync(headersPath, nextHeaders) +console.log('Updated public/_headers Content-Security-Policy') diff --git a/scripts/sync-read-replica-schema.ts b/scripts/sync-read-replica-schema.ts index eedb90fab1..7563db5e1b 100644 --- a/scripts/sync-read-replica-schema.ts +++ b/scripts/sync-read-replica-schema.ts @@ -145,9 +145,15 @@ function statementResolvesCompatibilityIssue( ): boolean { switch (issue.kind) { case 'column': - case 'constraint': return statement.kind === issue.kind && issue.object === `${statement.table}.${statement.name}` + case 'constraint': + return ( + (statement.kind === 'constraint' + || statement.kind === 'drop_check_constraint' + || statement.kind === 'check_constraint') + && issue.object === `${statement.table}.${statement.name}` + ) case 'type': case 'sequence': case 'function': @@ -258,6 +264,14 @@ function assertGoogleReadReplicaSchemaStatement( assertSelectedReplicaTable(statement) assertConstraintStatement(statement) return + case 'drop_check_constraint': + assertSelectedReplicaTable(statement) + assertDropCheckConstraintStatement(statement) + return + case 'check_constraint': + assertSelectedReplicaTable(statement) + assertCheckConstraintStatement(statement) + return case 'type': assertTypeStatement(statement) return @@ -306,6 +320,51 @@ function assertConstraintStatement(statement: ReadReplicaSchemaSyncStatement): v } } +function assertDropCheckConstraintStatement( + statement: ReadReplicaSchemaSyncStatement, +): void { + const expected = `ALTER TABLE public.${quoteSqlIdentifier(statement.table)} DROP CONSTRAINT IF EXISTS ${quoteSqlIdentifier(statement.name)}` + if ( + !isSafeQuotedIdentifier(statement.name) + || statement.sql !== expected + ) { + throw new Error( + `Cloud SQL server-side import cannot atomically apply ${statement.kind} ${statement.table}.${statement.name} before primary migrations.`, + ) + } +} + +function assertCheckConstraintStatement( + statement: ReadReplicaSchemaSyncStatement, +): void { + const expectedPrefix = `ALTER TABLE public.${quoteSqlIdentifier(statement.table)} ADD CONSTRAINT ${quoteSqlIdentifier(statement.name)} CHECK (` + if ( + !isSafeQuotedIdentifier(statement.name) + || !statement.sql.startsWith(expectedPrefix) + || !isSafeCheckConstraintTail(statement.sql.slice(expectedPrefix.length)) + ) { + throw new Error( + `Cloud SQL server-side import cannot atomically apply ${statement.kind} ${statement.table}.${statement.name} before primary migrations.`, + ) + } +} + +function isSafeCheckConstraintTail(value: string): boolean { + const body = value.endsWith(' NOT VALID') + ? value.slice(0, -' NOT VALID'.length) + : value + + return ( + body.length > 0 + && body.endsWith(')') + && !value.includes('\0') + && !value.includes(';') + && !value.includes('--') + && !value.includes('/*') + && !value.includes('*/') + ) +} + function assertTypeStatement(statement: ReadReplicaSchemaSyncStatement): void { const identifier = quoteSqlIdentifier(statement.name) const createPrefix = `CREATE TYPE public.${identifier} AS ` @@ -442,7 +501,7 @@ function isSafeQuotedIdentifier(value: string): boolean { } function isSafeSchemaFragment(value: string): boolean { - return /^[\w .()[\],:'"{}+\-=<>!]+$/u.test(value) + return /^[\w #.()[\],:'"{}+\-=<>!]+$/u.test(value) } function isSafeTypeDefinition(value: string): boolean { diff --git a/scripts/visual-diff.ts b/scripts/visual-diff.ts index ed67da77ac..d98bf0d9d8 100644 --- a/scripts/visual-diff.ts +++ b/scripts/visual-diff.ts @@ -11,6 +11,7 @@ import { createClient } from '@supabase/supabase-js' import pixelmatch from 'pixelmatch' import { PNG } from 'pngjs' import type { VisualDiffRoute } from '../playwright/visual-diff.config' +import { dismissSupportPrompt } from '../playwright/support/dismissSupportPrompt' import { getSupabaseStatus } from './supabase-worktree-status' import { getPlaywrightStripeApiBaseUrl, getStripeEmulatorPort } from './playwright-stripe' import { getSupabaseWorktreeConfig } from './supabase-worktree-config' @@ -575,6 +576,8 @@ async function captureRouteScreenshot( await page.goto(route.path, { waitUntil: 'domcontentloaded', timeout: visualDiffActionTimeoutMs }) console.log(`[visual-diff] settling ${phase} ${route.slug}`) await settlePage(page) + if (route.auth) + await dismissSupportPrompt(page) if (route.prepare) { console.log(`[visual-diff] preparing ${phase} ${route.slug}`) await route.prepare(page) diff --git a/src/assets/onboarding-tools/assistant-claude.svg b/src/assets/onboarding-tools/assistant-claude.svg new file mode 100644 index 0000000000..a252231188 --- /dev/null +++ b/src/assets/onboarding-tools/assistant-claude.svg @@ -0,0 +1 @@ +Claude diff --git a/src/assets/onboarding-tools/assistant-codex.svg b/src/assets/onboarding-tools/assistant-codex.svg new file mode 100644 index 0000000000..3656b42615 --- /dev/null +++ b/src/assets/onboarding-tools/assistant-codex.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/src/assets/onboarding-tools/assistant-copilot.svg b/src/assets/onboarding-tools/assistant-copilot.svg new file mode 100644 index 0000000000..be2372b38e --- /dev/null +++ b/src/assets/onboarding-tools/assistant-copilot.svg @@ -0,0 +1 @@ +GitHub Copilot diff --git a/src/assets/onboarding-tools/assistant-cursor.svg b/src/assets/onboarding-tools/assistant-cursor.svg new file mode 100644 index 0000000000..8831508da8 --- /dev/null +++ b/src/assets/onboarding-tools/assistant-cursor.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/src/assets/onboarding-tools/assistant-opencode.svg b/src/assets/onboarding-tools/assistant-opencode.svg new file mode 100644 index 0000000000..fde7b1a3fb --- /dev/null +++ b/src/assets/onboarding-tools/assistant-opencode.svg @@ -0,0 +1 @@ +OpenCode diff --git a/src/assets/onboarding-tools/assistant-windsurf.svg b/src/assets/onboarding-tools/assistant-windsurf.svg new file mode 100644 index 0000000000..4fdf1827ab --- /dev/null +++ b/src/assets/onboarding-tools/assistant-windsurf.svg @@ -0,0 +1 @@ +Windsurf diff --git a/src/assets/onboarding-tools/editor-jetbrains.svg b/src/assets/onboarding-tools/editor-jetbrains.svg new file mode 100644 index 0000000000..a433d9dcd8 --- /dev/null +++ b/src/assets/onboarding-tools/editor-jetbrains.svg @@ -0,0 +1 @@ +JetBrains diff --git a/src/assets/onboarding-tools/editor-neovim.svg b/src/assets/onboarding-tools/editor-neovim.svg new file mode 100644 index 0000000000..c5c46ff06f --- /dev/null +++ b/src/assets/onboarding-tools/editor-neovim.svg @@ -0,0 +1 @@ +Neovim diff --git a/src/assets/onboarding-tools/editor-terminal.svg b/src/assets/onboarding-tools/editor-terminal.svg new file mode 100644 index 0000000000..ca330d7da1 --- /dev/null +++ b/src/assets/onboarding-tools/editor-terminal.svg @@ -0,0 +1 @@ +GNOME Terminal \ No newline at end of file diff --git a/src/assets/onboarding-tools/editor-vim.svg b/src/assets/onboarding-tools/editor-vim.svg new file mode 100644 index 0000000000..f0171beb64 --- /dev/null +++ b/src/assets/onboarding-tools/editor-vim.svg @@ -0,0 +1 @@ +Vim \ No newline at end of file diff --git a/src/assets/onboarding-tools/editor-vscode.svg b/src/assets/onboarding-tools/editor-vscode.svg new file mode 100644 index 0000000000..34667db004 --- /dev/null +++ b/src/assets/onboarding-tools/editor-vscode.svg @@ -0,0 +1 @@ +Visual Studio Code diff --git a/src/assets/onboarding-tools/hosted-base44.png b/src/assets/onboarding-tools/hosted-base44.png new file mode 100644 index 0000000000..da7dfe0d44 Binary files /dev/null and b/src/assets/onboarding-tools/hosted-base44.png differ diff --git a/src/assets/onboarding-tools/hosted-bolt.png b/src/assets/onboarding-tools/hosted-bolt.png new file mode 100644 index 0000000000..dc47182cce Binary files /dev/null and b/src/assets/onboarding-tools/hosted-bolt.png differ diff --git a/src/assets/onboarding-tools/hosted-lovable.png b/src/assets/onboarding-tools/hosted-lovable.png new file mode 100644 index 0000000000..9379547261 Binary files /dev/null and b/src/assets/onboarding-tools/hosted-lovable.png differ diff --git a/src/assets/onboarding-tools/hosted-v0.svg b/src/assets/onboarding-tools/hosted-v0.svg new file mode 100644 index 0000000000..4b8217daa2 --- /dev/null +++ b/src/assets/onboarding-tools/hosted-v0.svg @@ -0,0 +1 @@ +v0 diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts index 42af951bbb..33e40761db 100644 --- a/src/auto-imports.d.ts +++ b/src/auto-imports.d.ts @@ -133,6 +133,7 @@ declare global { const useAdminDashboardStore: typeof import('./stores/adminDashboard').useAdminDashboardStore const useAnimate: typeof import('@vueuse/core').useAnimate const useAppDetailStore: typeof import('./stores/appDetail').useAppDetailStore + const useAppOnboardingCliProgress: typeof import('./composables/useAppOnboardingCliProgress').useAppOnboardingCliProgress const useAppPage: typeof import('./composables/useAppPage').useAppPage const useArrayDifference: typeof import('@vueuse/core').useArrayDifference const useArrayEvery: typeof import('@vueuse/core').useArrayEvery @@ -243,6 +244,7 @@ declare global { const useNow: typeof import('@vueuse/core').useNow const useObjectUrl: typeof import('@vueuse/core').useObjectUrl const useOffsetPagination: typeof import('@vueuse/core').useOffsetPagination + const useOnboardingChannelAnimation: typeof import('./composables/useOnboardingChannelAnimation').useOnboardingChannelAnimation const useOnline: typeof import('@vueuse/core').useOnline const useOrgBillingCycleChart: typeof import('./composables/useOrgBillingCycleChart').useOrgBillingCycleChart const useOrganizationStore: typeof import('./stores/organization').useOrganizationStore @@ -250,6 +252,7 @@ declare global { const useParallax: typeof import('@vueuse/core').useParallax const useParentElement: typeof import('@vueuse/core').useParentElement const usePerformanceObserver: typeof import('@vueuse/core').usePerformanceObserver + const usePeriodDaysQuery: typeof import('./composables/usePeriodDaysQuery').usePeriodDaysQuery const usePermission: typeof import('@vueuse/core').usePermission const usePointer: typeof import('@vueuse/core').usePointer const usePointerLock: typeof import('@vueuse/core').usePointerLock @@ -503,6 +506,7 @@ declare module 'vue' { readonly useAdminDashboardStore: UnwrapRef readonly useAnimate: UnwrapRef readonly useAppDetailStore: UnwrapRef + readonly useAppOnboardingCliProgress: UnwrapRef readonly useAppPage: UnwrapRef readonly useArrayDifference: UnwrapRef readonly useArrayEvery: UnwrapRef @@ -613,6 +617,7 @@ declare module 'vue' { readonly useNow: UnwrapRef readonly useObjectUrl: UnwrapRef readonly useOffsetPagination: UnwrapRef + readonly useOnboardingChannelAnimation: UnwrapRef readonly useOnline: UnwrapRef readonly useOrgBillingCycleChart: UnwrapRef readonly useOrganizationStore: UnwrapRef @@ -620,6 +625,7 @@ declare module 'vue' { readonly useParallax: UnwrapRef readonly useParentElement: UnwrapRef readonly usePerformanceObserver: UnwrapRef + readonly usePeriodDaysQuery: UnwrapRef readonly usePermission: UnwrapRef readonly usePointer: UnwrapRef readonly usePointerLock: UnwrapRef diff --git a/src/components.d.ts b/src/components.d.ts index 1cbc4b0a5c..b3d03942a4 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -12,13 +12,22 @@ export {} declare module 'vue' { export interface GlobalComponents { AccessTable: typeof import('./components/tables/AccessTable.vue')['default'] + AdminABTestAnimationRetentionChart: typeof import('./components/admin/AdminABTestAnimationRetentionChart.vue')['default'] + AdminABTestChannelCreation: typeof import('./components/admin/AdminABTestChannelCreation.vue')['default'] + AdminABTestDevelopmentEnvironment: typeof import('./components/admin/AdminABTestDevelopmentEnvironment.vue')['default'] + AdminABTestDevelopmentEnvironmentIntent: typeof import('./components/admin/AdminABTestDevelopmentEnvironmentIntent.vue')['default'] + AdminABTestDistributionMatrix: typeof import('./components/admin/AdminABTestDistributionMatrix.vue')['default'] + AdminABTestPublishIntentOutcome: typeof import('./components/admin/AdminABTestPublishIntentOutcome.vue')['default'] AdminBarChart: typeof import('./components/admin/AdminBarChart.vue')['default'] AdminChartDeduplicateControl: typeof import('./components/admin/AdminChartDeduplicateControl.vue')['default'] AdminDailyConversionChart: typeof import('./components/admin/AdminDailyConversionChart.vue')['default'] + AdminDevelopmentEnvironmentFlow: typeof import('./components/admin/AdminDevelopmentEnvironmentFlow.vue')['default'] AdminFilterBar: typeof import('./components/admin/AdminFilterBar.vue')['default'] AdminFunnelChart: typeof import('./components/admin/AdminFunnelChart.vue')['default'] AdminMultiLineChart: typeof import('./components/admin/AdminMultiLineChart.vue')['default'] AdminOnboardingJourneyGraph: typeof import('./components/admin/AdminOnboardingJourneyGraph.vue')['default'] + AdminOnboardingPaymentCohorts: typeof import('./components/admin/AdminOnboardingPaymentCohorts.vue')['default'] + AdminRegistrationComparison: typeof import('./components/admin/AdminRegistrationComparison.vue')['default'] AdminStackedBarChart: typeof import('./components/admin/AdminStackedBarChart.vue')['default'] AdminStatsCard: typeof import('./components/admin/AdminStatsCard.vue')['default'] AdminTrendChart: typeof import('./components/admin/AdminTrendChart.vue')['default'] @@ -29,6 +38,7 @@ declare module 'vue' { AppOnboardingCliSteps: typeof import('./components/dashboard/AppOnboardingCliSteps.vue')['default'] AppOnboardingFlow: typeof import('./components/dashboard/AppOnboardingFlow.vue')['default'] AppOnboardingIconInput: typeof import('./components/dashboard/AppOnboardingIconInput.vue')['default'] + AppOnboardingSetupChecklist: typeof import('./components/dashboard/AppOnboardingSetupChecklist.vue')['default'] AppOnboardingWelcome: typeof import('./components/dashboard/AppOnboardingWelcome.vue')['default'] AppPageFrame: typeof import('./components/dashboard/AppPageFrame.vue')['default'] AppPageNotFound: typeof import('./components/dashboard/AppPageNotFound.vue')['default'] @@ -55,12 +65,21 @@ declare module 'vue' { BundleUploadsCard: typeof import('./components/dashboard/BundleUploadsCard.vue')['default'] BundleUploadsChart: typeof import('./components/dashboard/BundleUploadsChart.vue')['default'] ChannelAccessPanel: typeof import('./components/permissions/ChannelAccessPanel.vue')['default'] + ChannelConsoleAssignMockup: typeof import('./components/dashboard/ChannelConsoleAssignMockup.vue')['default'] + ChannelConsoleAssignOnboarding: typeof import('./components/dashboard/ChannelConsoleAssignOnboarding.vue')['default'] + ChannelCreateOnboarding: typeof import('./components/dashboard/ChannelCreateOnboarding.vue')['default'] + ChannelDefaultRoutingMockup: typeof import('./components/dashboard/ChannelDefaultRoutingMockup.vue')['default'] + ChannelDefaultRoutingOnboarding: typeof import('./components/dashboard/ChannelDefaultRoutingOnboarding.vue')['default'] ChannelHistoryTable: typeof import('./components/tables/ChannelHistoryTable.vue')['default'] ChannelOverrideRetentionNotice: typeof import('./components/ChannelOverrideRetentionNotice.vue')['default'] ChannelPermissionOverridesPanel: typeof import('./components/permissions/ChannelPermissionOverridesPanel.vue')['default'] + ChannelSelfAssignMockup: typeof import('./components/dashboard/ChannelSelfAssignMockup.vue')['default'] + ChannelSelfAssignOnboarding: typeof import('./components/dashboard/ChannelSelfAssignOnboarding.vue')['default'] + ChannelSetupOnboardingDialog: typeof import('./components/dashboard/ChannelSetupOnboardingDialog.vue')['default'] ChannelTable: typeof import('./components/tables/ChannelTable.vue')['default'] ChartCard: typeof import('./components/dashboard/ChartCard.vue')['default'] ChartLegend: typeof import('./components/dashboard/ChartLegend.vue')['default'] + CliLoginSkippedOrganizations: typeof import('./components/CliLoginSkippedOrganizations.vue')['default'] CompatibilityBanner: typeof import('./components/dashboard/CompatibilityBanner.vue')['default'] ConnectAppPicker: typeof import('./components/connect/ConnectAppPicker.vue')['default'] CreditsCta: typeof import('./components/CreditsCta.vue')['default'] @@ -94,10 +113,17 @@ declare module 'vue' { LineChartStats: typeof import('./components/dashboard/LineChartStats.vue')['default'] LogMetadataPopover: typeof import('./components/tables/LogMetadataPopover.vue')['default'] LogTable: typeof import('./components/tables/LogTable.vue')['default'] + NativeDeviceMetricCard: typeof import('./components/dashboard/NativeDeviceMetricCard.vue')['default'] + NativePlatformTrendChart: typeof import('./components/dashboard/NativePlatformTrendChart.vue')['default'] Navbar: typeof import('./components/Navbar.vue')['default'] OnboardingAltSetup: typeof import('./components/dashboard/OnboardingAltSetup.vue')['default'] OnboardingExploreBanner: typeof import('./components/dashboard/OnboardingExploreBanner.vue')['default'] + OnboardingExploreReminder: typeof import('./components/dashboard/OnboardingExploreReminder.vue')['default'] + OnboardingPublishIntentIcon: typeof import('./components/dashboard/OnboardingPublishIntentIcon.vue')['default'] + OnboardingPublishIntentIconMobileApp: typeof import('./components/dashboard/OnboardingPublishIntentIconMobileApp.vue')['default'] + OnboardingPublishIntentIconWebPage: typeof import('./components/dashboard/OnboardingPublishIntentIconWebPage.vue')['default'] OnboardingSupportUsernames: typeof import('./components/dashboard/OnboardingSupportUsernames.vue')['default'] + OnboardingToolPattern: typeof import('./components/dashboard/OnboardingToolPattern.vue')['default'] OrganizationOnboardingInvite: typeof import('./components/dashboard/OrganizationOnboardingInvite.vue')['default'] OrgEmailNotificationsPanel: typeof import('./components/dashboard/OrgEmailNotificationsPanel.vue')['default'] OrgNotificationStatsPanel: typeof import('./components/dashboard/OrgNotificationStatsPanel.vue')['default'] diff --git a/src/components/Banner.vue b/src/components/Banner.vue index 349540efb9..4d758ef675 100644 --- a/src/components/Banner.vue +++ b/src/components/Banner.vue @@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n' import { stripeEnabled } from '~/services/supabase' import { useOrganizationStore } from '~/stores/organization' -import { isCreditsOnlyOrg } from '~/utils/organizationBilling' +import { resolveOrgBillingStatus } from '~/utils/organizationBilling' const props = defineProps({ text: { type: String, default: '' }, @@ -16,138 +16,175 @@ const props = defineProps({ const { t } = useI18n() const organizationStore = useOrganizationStore() const isMobile = Capacitor.isNativePlatform() -const isCreditsOnly = computed(() => isCreditsOnlyOrg(organizationStore.currentOrganization)) +const lacksSecurityAccess = computed(() => { + const org = organizationStore.currentOrganization + const lacks2FA = org?.enforcing_2fa === true && org?.['2fa_has_access'] === false + const lacksPassword = org?.password_policy_config?.enabled === true && org?.password_has_access === false + return lacks2FA || lacksPassword +}) +const status = computed(() => resolveOrgBillingStatus(organizationStore.currentOrganization, { + stripeEnabled: !!stripeEnabled.value, + lacksSecurityAccess: lacksSecurityAccess.value, + organizationFailed: !!organizationStore.currentOrganizationFailed, +})) const billingCtaHref = computed(() => { if (isMobile) return '/settings/organization/usage' - if (isCreditsOnly.value) + if (status.value.cta === 'go_credits') return '/settings/organization/credits' return '/settings/organization/plans' }) const billingCtaLabel = computed(() => { if (isMobile) return t('see-usage') - if (isCreditsOnly.value) + if (status.value.cta === 'go_credits') return t('manage') return t('upgrade') }) -// Check if user lacks security compliance (2FA or password) - data is unreliable in this case -const lacksSecurityAccess = computed(() => { - const org = organizationStore.currentOrganization - const lacks2FA = org?.enforcing_2fa === true && org?.['2fa_has_access'] === false - const lacksPassword = org?.password_policy_config?.enabled && org?.password_has_access === false - return lacks2FA || lacksPassword -}) - -const bannerLeftText = computed(() => { - const org = organizationStore.currentOrganization - if (isCreditsOnlyOrg(org)) - return t('credits') - if (org?.paying) - return t('billing') - - return t('free-trial') +const showBanner = computed(() => status.value.kind !== 'hidden') +const showCta = computed(() => status.value.cta !== 'none') + +const statusLabel = computed(() => { + switch (status.value.kind) { + case 'trial': + return t('free-trial') + case 'trial_over': + return t('trial-over') + case 'plan_active': + return t('plan-active') + case 'using_credits': + case 'limit_reached_credits': + return t('using-credits') + case 'limit_reached': + return t('plan-limit-reached') + default: + return '' + } }) -const bannerText = computed(() => { - if (!stripeEnabled.value) - return null - const org = organizationStore.currentOrganization - if (!org) - return null - - // Don't show billing banner when user lacks 2FA or password access - data is unreliable - if (lacksSecurityAccess.value) +const statusDetail = computed(() => { + if (status.value.kind !== 'trial') return null - - if (organizationStore.currentOrganizationFailed) - return t('subscription-required') - - if (isCreditsOnlyOrg(org)) { - return t('credits-only-banner') - } - - if (org.is_canceled) { - return t('plan-inactive') - } - - else if (!org.paying && org.trial_left > 1) { - return `${org.trial_left} ${t('trial-left')}` - } - - else if (!org.paying && org.trial_left === 1) { + if (status.value.trialDaysLeft === 1) return t('one-day-left') - } + return t('trial-days-left', { count: status.value.trialDaysLeft }) +}) - else if (!org.paying && !org.can_use_more) { - return t('trial-plan-expired') - } +const statusAriaLabel = computed(() => { + if (statusDetail.value) + return `${statusLabel.value}, ${statusDetail.value}` + return statusLabel.value +}) - else if (org.paying && !org.can_use_more) { - // Si l'org a des crΓ©dits, afficher un message diffΓ©rent - const hasCredits = (org.credit_available ?? 0) > 0 - return hasCredits ? t('limit-reached-with-credits') : t('limit-reached-no-credits') +const badgeClass = computed(() => { + switch (status.value.tone) { + case 'trial': + return 'border-none bg-azure-500 text-white' + case 'warning': + return 'd-badge-warning text-black' + case 'success': + return 'd-badge-success text-black' + default: + return 'd-badge-ghost' } - - return null }) -const bannerColor = computed(() => { - const warning = 'd-btn-warning text-black' - const success = 'd-btn-success text-black' - - const org = organizationStore.currentOrganization - if (!org) - return '' - if (organizationStore.currentOrganizationFailed) - return warning - - if (org.paying && org.can_use_more) +const bannerColor = computed(() => { + if (status.value.tone === 'warning' || (status.value.kind === 'trial' && status.value.trialDaysLeft <= 7)) + return 'd-btn-warning text-black' + if (status.value.cta === 'none') return '' - - else if (isCreditsOnlyOrg(org)) - return success - - else if (org.is_canceled) - return warning - - else if (!org.paying && org.trial_left > 1 && org.trial_left <= 7) - return warning - - else if (!org.paying && org.trial_left === 1) - return warning - - else if (!org.paying && !org.can_use_more) - return warning - - else if (org.paying && !org.can_use_more) - return warning - - return success + return 'd-btn-success text-black' }) diff --git a/src/components/CliLoginSkippedOrganizations.vue b/src/components/CliLoginSkippedOrganizations.vue new file mode 100644 index 0000000000..e677b62946 --- /dev/null +++ b/src/components/CliLoginSkippedOrganizations.vue @@ -0,0 +1,56 @@ + + + diff --git a/src/components/DataTable.vue b/src/components/DataTable.vue index e535491d33..64e6c637bc 100644 --- a/src/components/DataTable.vue +++ b/src/components/DataTable.vue @@ -2,7 +2,6 @@ import type { TableColumn } from './comp_def' import { FormKit } from '@formkit/vue' import { useDebounceFn } from '@vueuse/core' -import DOMPurify from 'dompurify' import { computed, nextTick, @@ -30,6 +29,7 @@ import IconReload from '~icons/tabler/reload' import FilterModal from '~/components/FilterModal.vue' import { RenderCell } from '~/components/RenderCell' import { createClearedFilters } from '~/composables/useFilterModal' +import { sanitizeHtml } from '~/utils/sanitize' interface Props { isLoading?: boolean @@ -421,7 +421,7 @@ function displayValueKey(elem: any, col: TableColumn | undefined) { return '' const text = col.displayFunction ? col.displayFunction(elem) : elem[col.key] if (col.sanitizeHtml) - return DOMPurify.sanitize(text) + return sanitizeHtml(text) return text } diff --git a/src/components/admin/AdminABTestAnimationRetentionChart.vue b/src/components/admin/AdminABTestAnimationRetentionChart.vue new file mode 100644 index 0000000000..49bbb0a8e6 --- /dev/null +++ b/src/components/admin/AdminABTestAnimationRetentionChart.vue @@ -0,0 +1,127 @@ + + + diff --git a/src/components/admin/AdminABTestChannelCreation.vue b/src/components/admin/AdminABTestChannelCreation.vue new file mode 100644 index 0000000000..b790a95984 --- /dev/null +++ b/src/components/admin/AdminABTestChannelCreation.vue @@ -0,0 +1,321 @@ + + + diff --git a/src/components/admin/AdminABTestDevelopmentEnvironment.vue b/src/components/admin/AdminABTestDevelopmentEnvironment.vue new file mode 100644 index 0000000000..d9067be2cd --- /dev/null +++ b/src/components/admin/AdminABTestDevelopmentEnvironment.vue @@ -0,0 +1,91 @@ + + + diff --git a/src/components/admin/AdminABTestDevelopmentEnvironmentIntent.vue b/src/components/admin/AdminABTestDevelopmentEnvironmentIntent.vue new file mode 100644 index 0000000000..f03a67686a --- /dev/null +++ b/src/components/admin/AdminABTestDevelopmentEnvironmentIntent.vue @@ -0,0 +1,114 @@ + + + diff --git a/src/components/admin/AdminABTestDistributionMatrix.vue b/src/components/admin/AdminABTestDistributionMatrix.vue new file mode 100644 index 0000000000..58f3fe708f --- /dev/null +++ b/src/components/admin/AdminABTestDistributionMatrix.vue @@ -0,0 +1,96 @@ + + + diff --git a/src/components/admin/AdminABTestPublishIntentOutcome.vue b/src/components/admin/AdminABTestPublishIntentOutcome.vue new file mode 100644 index 0000000000..f6dd5800b7 --- /dev/null +++ b/src/components/admin/AdminABTestPublishIntentOutcome.vue @@ -0,0 +1,79 @@ + + + diff --git a/src/components/admin/AdminDevelopmentEnvironmentFlow.vue b/src/components/admin/AdminDevelopmentEnvironmentFlow.vue new file mode 100644 index 0000000000..1b99a4ee75 --- /dev/null +++ b/src/components/admin/AdminDevelopmentEnvironmentFlow.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/components/admin/AdminOnboardingPaymentCohorts.vue b/src/components/admin/AdminOnboardingPaymentCohorts.vue new file mode 100644 index 0000000000..8b5ea41a61 --- /dev/null +++ b/src/components/admin/AdminOnboardingPaymentCohorts.vue @@ -0,0 +1,175 @@ + + + diff --git a/src/components/admin/AdminRegistrationComparison.vue b/src/components/admin/AdminRegistrationComparison.vue new file mode 100644 index 0000000000..054df2a1ae --- /dev/null +++ b/src/components/admin/AdminRegistrationComparison.vue @@ -0,0 +1,141 @@ + + + diff --git a/src/components/dashboard/AppOnboardingCliSteps.vue b/src/components/dashboard/AppOnboardingCliSteps.vue index 183a527732..f833915928 100644 --- a/src/components/dashboard/AppOnboardingCliSteps.vue +++ b/src/components/dashboard/AppOnboardingCliSteps.vue @@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n' import IconCheck from '~icons/lucide/check' import IconChevronDown from '~icons/lucide/chevron-down' import IconMinus from '~icons/lucide/minus' -import { APP_ONBOARDING_STEP_IDS, hasStartedCliSetup, parseAppOnboarding } from '~/services/appOnboarding' +import { getAppOnboardingStepIds, hasStartedCliSetup, parseAppOnboarding } from '~/services/appOnboarding' import { useSupabase } from '~/services/supabase' const props = defineProps<{ @@ -24,7 +24,7 @@ const onboarding = ref(parseAppOnboarding(props.initialOnboarding)) let pollTimer: number | null = null let refreshGeneration = 0 -const steps = computed(() => APP_ONBOARDING_STEP_IDS.map(id => ({ +const steps = computed(() => getAppOnboardingStepIds(onboarding.value.todo_list_version).map(id => ({ id, status: onboarding.value.steps[id]?.status as AppOnboardingStepStatus | undefined, title: t(`app-onboarding-cli-step-${id}`), diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index 53d6ea1046..7962de03a3 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -1,9 +1,11 @@ + + diff --git a/src/components/dashboard/BuilderPresentationModal.vue b/src/components/dashboard/BuilderPresentationModal.vue index 9147350879..46734b857d 100644 --- a/src/components/dashboard/BuilderPresentationModal.vue +++ b/src/components/dashboard/BuilderPresentationModal.vue @@ -17,6 +17,7 @@ import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import { pushEvent } from '~/services/posthog' import { getLocalConfig } from '~/services/supabase' +import { sanitizeHtml } from '~/utils/sanitize' const props = defineProps<{ open: boolean, appId?: string }>() const emit = defineEmits<{ close: [] }>() @@ -124,6 +125,30 @@ function makeScript(p: 'ios' | 'android'): TermStep[] { ] } +function appendTrustedQrImage(container: HTMLElement) { + if (!qrDataUrl) + return + + const qrLine = container.querySelector('.bp-qr-line') + if (!qrLine || qrLine.querySelector('img.bp-qr')) + return + + const img = document.createElement('img') + img.className = 'bp-qr' + img.src = qrDataUrl + img.alt = 'QR code β€” scan to install the build on your device' + qrLine.insertBefore(img, qrLine.firstChild) +} + +function renderTerminalBody(el: HTMLElement, html: string, withQr = false) { + el.innerHTML = sanitizeHtml(html) + if (withQr) + appendTrustedQrImage(el) + el.scrollTop = el.scrollHeight +} + +const TERMINAL_QR_PLACEHOLDER = 'β–Έ Scan to install on your deviceno cable, no Xcode β€” just your phone camera' + function staticTerminal(p: 'ios' | 'android'): string { const cmd = `$ npx @capgo/cli@latest build request --platform ${p}` if (p === 'android') { @@ -142,15 +167,14 @@ function startTerminal(p: 'ios' | 'android') { if (!el) return if (reduce) { - el.innerHTML = staticTerminal(p) + renderTerminalBody(el, `${staticTerminal(p)}\n${TERMINAL_QR_PLACEHOLDER}`, true) return } let buf = '' let idx = 0 const script = makeScript(p) - const render = () => { - el.innerHTML = buf - el.scrollTop = el.scrollHeight + const render = (withQr = false) => { + renderTerminalBody(el, buf, withQr) } const runSpin = (label: string, doneHtml: string, ms: number, onDone: () => void) => { let frame = 0 @@ -203,8 +227,8 @@ function startTerminal(p: 'ios' | 'android') { if (myGen !== termGen) return if (idx >= script.length) { - buf += `\n\nβœ” Ready to install on a real device:\nQR code β€” scan to install the build on your deviceβ–Έ Scan to install on your deviceno cable, no Xcode β€” just your phone camera` - render() + buf += `\n\nβœ” Ready to install on a real device:\n${TERMINAL_QR_PLACEHOLDER}` + render(true) return } const s = script[idx++] diff --git a/src/components/dashboard/ChannelConsoleAssignMockup.vue b/src/components/dashboard/ChannelConsoleAssignMockup.vue new file mode 100644 index 0000000000..9499c07e35 --- /dev/null +++ b/src/components/dashboard/ChannelConsoleAssignMockup.vue @@ -0,0 +1,1281 @@ + + + + + diff --git a/src/components/dashboard/ChannelConsoleAssignOnboarding.vue b/src/components/dashboard/ChannelConsoleAssignOnboarding.vue new file mode 100644 index 0000000000..39938284a3 --- /dev/null +++ b/src/components/dashboard/ChannelConsoleAssignOnboarding.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/dashboard/ChannelCreateOnboarding.vue b/src/components/dashboard/ChannelCreateOnboarding.vue new file mode 100644 index 0000000000..2491fb4ac0 --- /dev/null +++ b/src/components/dashboard/ChannelCreateOnboarding.vue @@ -0,0 +1,563 @@ + + + diff --git a/src/components/dashboard/ChannelDefaultRoutingMockup.vue b/src/components/dashboard/ChannelDefaultRoutingMockup.vue new file mode 100644 index 0000000000..30a0cd52d5 --- /dev/null +++ b/src/components/dashboard/ChannelDefaultRoutingMockup.vue @@ -0,0 +1,1538 @@ + + + + + diff --git a/src/components/dashboard/ChannelDefaultRoutingOnboarding.vue b/src/components/dashboard/ChannelDefaultRoutingOnboarding.vue new file mode 100644 index 0000000000..68d872da32 --- /dev/null +++ b/src/components/dashboard/ChannelDefaultRoutingOnboarding.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/dashboard/ChannelSelfAssignMockup.vue b/src/components/dashboard/ChannelSelfAssignMockup.vue new file mode 100644 index 0000000000..de8ac5a07d --- /dev/null +++ b/src/components/dashboard/ChannelSelfAssignMockup.vue @@ -0,0 +1,1466 @@ + + + + + diff --git a/src/components/dashboard/ChannelSelfAssignOnboarding.vue b/src/components/dashboard/ChannelSelfAssignOnboarding.vue new file mode 100644 index 0000000000..d34dc6ba2e --- /dev/null +++ b/src/components/dashboard/ChannelSelfAssignOnboarding.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/dashboard/ChannelSetupOnboardingDialog.vue b/src/components/dashboard/ChannelSetupOnboardingDialog.vue new file mode 100644 index 0000000000..157fc0e657 --- /dev/null +++ b/src/components/dashboard/ChannelSetupOnboardingDialog.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/src/components/dashboard/DevicesStats.vue b/src/components/dashboard/DevicesStats.vue index 0ce85c3e6e..5aa29c36f7 100644 --- a/src/components/dashboard/DevicesStats.vue +++ b/src/components/dashboard/DevicesStats.vue @@ -1,6 +1,7 @@ + + diff --git a/src/components/dashboard/NativePlatformTrendChart.vue b/src/components/dashboard/NativePlatformTrendChart.vue new file mode 100644 index 0000000000..f31b1a0193 --- /dev/null +++ b/src/components/dashboard/NativePlatformTrendChart.vue @@ -0,0 +1,116 @@ + + + diff --git a/src/components/dashboard/OnboardingExploreBanner.vue b/src/components/dashboard/OnboardingExploreBanner.vue index 6faa5f5f94..26d95c233f 100644 --- a/src/components/dashboard/OnboardingExploreBanner.vue +++ b/src/components/dashboard/OnboardingExploreBanner.vue @@ -12,7 +12,10 @@ const { t } = useI18n() const router = useRouter() function continueOnboarding() { - router.push(`/app/new?resume=${encodeURIComponent(props.appId)}`) + router.push({ + path: '/onboarding/app', + query: { resume: props.appId, step: 'setup' }, + }) } diff --git a/src/components/dashboard/OnboardingExploreReminder.vue b/src/components/dashboard/OnboardingExploreReminder.vue new file mode 100644 index 0000000000..3ce230e4d0 --- /dev/null +++ b/src/components/dashboard/OnboardingExploreReminder.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/components/dashboard/OnboardingPublishIntentIcon.vue b/src/components/dashboard/OnboardingPublishIntentIcon.vue new file mode 100644 index 0000000000..a54692a98e --- /dev/null +++ b/src/components/dashboard/OnboardingPublishIntentIcon.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/components/dashboard/OnboardingPublishIntentIconMobileApp.vue b/src/components/dashboard/OnboardingPublishIntentIconMobileApp.vue new file mode 100644 index 0000000000..4fc6b95802 --- /dev/null +++ b/src/components/dashboard/OnboardingPublishIntentIconMobileApp.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/dashboard/OnboardingPublishIntentIconWebPage.vue b/src/components/dashboard/OnboardingPublishIntentIconWebPage.vue new file mode 100644 index 0000000000..b55b0ca51d --- /dev/null +++ b/src/components/dashboard/OnboardingPublishIntentIconWebPage.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/dashboard/OnboardingToolPattern.vue b/src/components/dashboard/OnboardingToolPattern.vue new file mode 100644 index 0000000000..a897e2c0d2 --- /dev/null +++ b/src/components/dashboard/OnboardingToolPattern.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/components/dashboard/TechnicalTeammateInviteCard.vue b/src/components/dashboard/TechnicalTeammateInviteCard.vue index 02a6182cfb..33dcab78aa 100644 --- a/src/components/dashboard/TechnicalTeammateInviteCard.vue +++ b/src/components/dashboard/TechnicalTeammateInviteCard.vue @@ -1,6 +1,8 @@ + + diff --git a/src/pages/admin/dashboard/frontend-onboarding.vue b/src/pages/admin/dashboard/frontend-onboarding.vue index 9412d6c6e9..5484a1adff 100644 --- a/src/pages/admin/dashboard/frontend-onboarding.vue +++ b/src/pages/admin/dashboard/frontend-onboarding.vue @@ -20,6 +20,8 @@ import AdminDailyConversionChart from '~/components/admin/AdminDailyConversionCh import AdminFilterBar from '~/components/admin/AdminFilterBar.vue' import AdminFunnelChart from '~/components/admin/AdminFunnelChart.vue' import AdminOnboardingJourneyGraph from '~/components/admin/AdminOnboardingJourneyGraph.vue' +import AdminOnboardingPaymentCohorts from '~/components/admin/AdminOnboardingPaymentCohorts.vue' +import AdminRegistrationComparison from '~/components/admin/AdminRegistrationComparison.vue' import AdminStackedBarChart from '~/components/admin/AdminStackedBarChart.vue' import AdminStatsCard from '~/components/admin/AdminStatsCard.vue' import ChartCard from '~/components/dashboard/ChartCard.vue' @@ -51,6 +53,7 @@ const isLoading = ref(true) const isLoadingStats = ref(false) const isReady = ref(false) const analytics = ref(null) +const cliChecklistVersion = ref<1 | 2>(2) const loadError = ref(false) const deduplicateDailyAttempts = ref(false) const deduplicateV4Funnel = ref(false) @@ -219,9 +222,7 @@ const intentToDetailsDaily = computed(() => latestDailyConversions.value?.intent const detailsToOrganizationDaily = computed(() => latestDailyConversions.value?.details_to_organization ?? []) const organizationToSetupDaily = computed(() => latestDailyConversions.value?.organization_to_setup ?? []) const hasConversionData = (points: readonly { started: number }[]) => points.some(point => point.started > 0) -const v1FunnelStages = computed(() => buildFrontendOnboardingFunnelStages(visibleAnalytics.value?.funnels.v1 ?? [])) const v4FunnelStages = computed(() => buildFrontendOnboardingFunnelStages(displayedV4Funnel.value)) -const v1FunnelSummaries = computed(() => buildFrontendOnboardingFunnelSummaries(visibleAnalytics.value?.funnels.v1 ?? [])) const v4FunnelSummaries = computed(() => buildFrontendOnboardingFunnelSummaries(displayedV4Funnel.value)) const hasDailyAttempts = computed(() => displayedDailyAttempts.value .some(day => day.v1_attempts > 0 || day.v2_attempts > 0 || day.v3_attempts > 0 || (day.v4_attempts ?? 0) > 0)) @@ -258,6 +259,21 @@ const setupCliOutcomeValues = computed(() => [ ]) const setupCliOutcomeColors = ['#119eff', '#8b5cf6', '#94a3b8'] const hasSetupCliOutcomeData = computed(() => setupCliOutcomes.value.total_users > 0) +const cliChecklistCoverage = computed(() => visibleAnalytics.value?.v4_cli_checklist_coverage_by_version?.[cliChecklistVersion.value] + ?? (cliChecklistVersion.value === 1 ? visibleAnalytics.value?.v4_cli_checklist_coverage : undefined) ?? { + linked_apps: 0, + active_apps: 0, + unavailable_apps: 0, + steps: [], +}) +const cliChecklistRows = computed(() => cliChecklistCoverage.value.steps.map(step => ({ + ...step, + label: t(`app-onboarding-cli-step-${step.step_id}`), +}))) +const hasCliChecklistCoverage = computed(() => cliChecklistCoverage.value.active_apps > 0) +const cliChecklistNoDataMessage = computed(() => cliChecklistCoverage.value.unavailable_apps > 0 + ? t('frontend-onboarding-cli-checklist-unavailable', { count: cliChecklistCoverage.value.unavailable_apps }) + : undefined) const dailySetupCliOutcomeLabels = computed>(() => ({ cli_copy_init: t('frontend-onboarding-daily-setup-cli-cli-copy-init'), ai_copy_init: t('frontend-onboarding-daily-setup-cli-ai-copy-init'), @@ -520,6 +536,8 @@ displayStore.defaultBack = '/dashboard'
+ +
@@ -581,6 +599,51 @@ displayStore.defaultBack = '/dashboard' /> + + +
+
+
+ {{ step.label }} + + {{ step.done }}/{{ cliChecklistCoverage.active_apps }} Β· {{ formatNumberValue(step.done_percent) }}% + +
+ +

+ {{ t('frontend-onboarding-cli-checklist-step-detail', { count: step.skipped }) }} +

+
+
+

+ {{ t('frontend-onboarding-cli-checklist-unavailable', { count: cliChecklistCoverage.unavailable_apps }) }} +

+
+ - - - -
- -
-
-
-

- {{ formatNumberValue(summary.conversion_percent) }}% -

-

- {{ summary.from_label ? t('frontend-onboarding-transition', { from: summary.from_label, to: summary.to_label }) : summary.to_label }} Β· {{ formatNumberValue(summary.reached) }} -

-
-
-
+
diff --git a/src/pages/admin/dashboard/users.vue b/src/pages/admin/dashboard/users.vue index 08ff9a87e7..1307a7bf8e 100644 --- a/src/pages/admin/dashboard/users.vue +++ b/src/pages/admin/dashboard/users.vue @@ -1070,6 +1070,7 @@ const onboardingFunnelStages = computed(() => { const wizardDropoffLabelKeys: Record = { not_started: 'onboarding-wizard-not-started', intent: 'unified-onboarding-step-intent', + publish_app_question: 'onboarding-wizard-publish-app-question', details: 'app-onboarding-step-details', organization: 'unified-onboarding-step-organization', choice: 'app-onboarding-step-choice', diff --git a/src/pages/app/[app].bundle.[bundle].vue b/src/pages/app/[app].bundle.[bundle].vue index 75baf7561d..57131237a5 100644 --- a/src/pages/app/[app].bundle.[bundle].vue +++ b/src/pages/app/[app].bundle.[bundle].vue @@ -303,7 +303,7 @@ async function handleChannelLink(chan: Database['public']['Tables']['channels'][ toast.error(t('bundle-not-compatible-with-channel', { channel: chan.name })) dialogStore.openDialog({ - title: t('confirm-action'), + title: t('compatibility-accept-title'), description: t('set-even-not-compatible'), buttons: [ { @@ -321,7 +321,7 @@ async function handleChannelLink(chan: Database['public']['Tables']['channels'][ }, }, { - text: t('button-confirm'), + text: t('compatibility-accept'), role: 'primary', }, ], diff --git a/src/pages/app/[app].channel.[channel].vue b/src/pages/app/[app].channel.[channel].vue index 3401506890..37ab6b9f2d 100644 --- a/src/pages/app/[app].channel.[channel].vue +++ b/src/pages/app/[app].channel.[channel].vue @@ -3,7 +3,7 @@ import type { Database } from '~/types/supabase.types' import { FormKit } from '@formkit/vue' import { greaterOrEqual, parse } from '@std/semver' import { computedAsync, onClickOutside } from '@vueuse/core' -import { computed, ref, watchEffect } from 'vue' +import { computed, nextTick, ref, watch, watchEffect } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import { toast } from 'vue-sonner' @@ -24,6 +24,8 @@ import { isInternalVersionName, withBuiltinChannelVersion } from '~/services/ver import { useAppDetailStore } from '~/stores/appDetail' import { useDialogV2Store } from '~/stores/dialogv2' import { useDisplayStore } from '~/stores/display' +import { createChannelRolloutConfirmFlows, formatRolloutCacheTtlDisplay, formatRolloutCacheTtlHuman, isRolloutPercentageDraftChanged, parseRolloutCacheTtlSeconds } from '~/utils/channelRolloutConfirmFlows' +import { getUpdatePackageDescription as getUpdatePackageDescriptionCopy, getUpdatePackageInfoDescription as getUpdatePackageInfoDescriptionCopy, getUpdatePackageLabel as getUpdatePackageLabelCopy } from '~/utils/channelUpdatePackageCopy' interface Channel { version: Database['public']['Tables']['app_versions']['Row'] @@ -115,22 +117,6 @@ const rolloutDeliveryBannerClass = computed(() => { } return 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-800/70 dark:bg-sky-950/30 dark:text-sky-100' }) -const rolloutStatusLabel = computed(() => { - if (!rolloutConfigured.value) - return t('not-configured') - if (channel.value?.rollout_paused_at) - return t('paused') - return channel.value?.rollout_enabled ? t('enabled') : t('disabled') -}) -const rolloutStatusClass = computed(() => { - if (!rolloutConfigured.value || !channel.value?.rollout_enabled) { - return 'border-slate-300 bg-slate-50 text-slate-700 dark:border-slate-700 dark:bg-slate-900/40 dark:text-slate-300' - } - if (channel.value.rollout_paused_at) { - return 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800/70 dark:bg-amber-950/30 dark:text-amber-200' - } - return 'border-sky-300 bg-sky-50 text-sky-800 dark:border-sky-800/70 dark:bg-sky-950/30 dark:text-sky-200' -}) const rolloutProgressClass = computed(() => { if (!rolloutConfigured.value || !channel.value?.rollout_enabled) return 'bg-slate-300 dark:bg-slate-600' @@ -165,6 +151,80 @@ const rolloutEnableDisabled = computed(() => { return true return channel.value.rollout_version ? rolloutControlsDisabled.value : !canPromoteBundle.value }) +const fieldInputClass = 'min-h-11 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-900 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100 disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:focus:border-sky-700 dark:focus:ring-sky-950' +const rolloutPercentageDraft = ref('0') +const rolloutCacheTtlDraft = ref('2592000') +const editingRolloutSettings = ref(false) +const rolloutPercentageInput = useTemplateRef('rolloutPercentageInput') +watch( + () => [channel.value?.rollout_percentage_bps, channel.value?.rollout_cache_ttl_seconds] as const, + ([bps, ttl]) => { + if (editingRolloutSettings.value) + return + rolloutPercentageDraft.value = String((bps ?? 0) / 100) + rolloutCacheTtlDraft.value = String(ttl ?? 2592000) + }, + { immediate: true }, +) +const rolloutSettingsDraftChanged = computed(() => { + const ttl = parseRolloutCacheTtlSeconds(rolloutCacheTtlDraft.value) + return isRolloutPercentageDraftChanged(rolloutPercentageDraft.value, channel.value?.rollout_percentage_bps ?? 0) + || (ttl != null && ttl !== (channel.value?.rollout_cache_ttl_seconds ?? 2592000)) +}) +const rolloutCacheTtlText = computed(() => formatRolloutCacheTtlDisplay(channel.value?.rollout_cache_ttl_seconds ?? 2592000, t)) +const rolloutCacheTtlDraftHuman = computed(() => { + const parsed = parseRolloutCacheTtlSeconds(rolloutCacheTtlDraft.value) + return parsed == null ? '' : formatRolloutCacheTtlHuman(parsed, t) +}) + +type AutoPauseAction = 'pause' | 'rollback' | 'notify' +interface AutoPauseDraft { + failureRateBps: string + action: AutoPauseAction + windowMinutes: string + confidence: string + minAttempts: string + minFailures: string + cooldownMinutes: string +} +function autoPauseDraftFromChannel(row: NonNullable): AutoPauseDraft { + return { + failureRateBps: row.auto_pause_failure_rate_bps == null ? '' : String(row.auto_pause_failure_rate_bps), + action: (row.auto_pause_action as AutoPauseAction) || 'pause', + windowMinutes: String(row.auto_pause_window_minutes), + confidence: String(row.auto_pause_confidence), + minAttempts: row.auto_pause_min_attempts == null ? '' : String(row.auto_pause_min_attempts), + minFailures: row.auto_pause_min_failures == null ? '' : String(row.auto_pause_min_failures), + cooldownMinutes: String(row.auto_pause_cooldown_minutes), + } +} +const autoPauseDraft = ref({ + failureRateBps: '', + action: 'pause', + windowMinutes: '60', + confidence: '0.95', + minAttempts: '', + minFailures: '', + cooldownMinutes: '60', +}) +const editingAutoPause = ref(false) +watch( + () => channel.value && [ + channel.value.auto_pause_failure_rate_bps, + channel.value.auto_pause_action, + channel.value.auto_pause_window_minutes, + channel.value.auto_pause_confidence, + channel.value.auto_pause_min_attempts, + channel.value.auto_pause_min_failures, + channel.value.auto_pause_cooldown_minutes, + ], + () => { + if (!channel.value || editingAutoPause.value) + return + autoPauseDraft.value = autoPauseDraftFromChannel(channel.value) + }, + { immediate: true }, +) const showDebugSection = ref(false) @@ -462,7 +522,7 @@ async function handleVersionLink(appVersion: Database['public']['Tables']['app_v toast.error(t('bundle-not-compatible-with-channel', { channel: channel.value.name })) dialogStore.openDialog({ - title: t('confirm-action'), + title: t('compatibility-accept-title'), description: t('set-even-not-compatible'), buttons: [ { @@ -481,7 +541,7 @@ async function handleVersionLink(appVersion: Database['public']['Tables']['app_v }, }, { - text: t('button-confirm'), + text: t('compatibility-accept'), role: 'primary', }, ], @@ -496,13 +556,26 @@ async function handleVersionLink(appVersion: Database['public']['Tables']['app_v toast.info(t('bundle-compatible-with-channel', { channel: channel.value.name })) } if (bundleLinkMode.value === 'rollout') { - const saved = await saveChannelChanges({ - rollout_version: appVersion.id, - rollout_enabled: true, - }) - if (saved) { - toast.success(t('rollout-target-linked')) - await askUpdateNotificationAfterBundleChange() + const applyRolloutTargetLink = async () => { + const saved = await saveChannelChanges({ + rollout_version: appVersion.id, + rollout_enabled: true, + }) + if (saved) { + toast.success(t('rollout-target-linked')) + await askUpdateNotificationAfterBundleChange() + } + } + const previousRolloutVersionId = channel.value.rollout_version + if (previousRolloutVersionId != null && previousRolloutVersionId !== appVersion.id) { + await getRolloutConfirmFlows().confirmSetRolloutTarget({ + currentTargetName: rolloutTargetName.value, + nextTargetName: appVersion.name, + onConfirm: applyRolloutTargetLink, + }) + } + else { + await applyRolloutTargetLink() } return } @@ -566,7 +639,7 @@ async function handleRevert() { await dialogStore.onDialogDismiss() } -async function openSelectVersion() { +async function openSelectVersion(startInSearch = false) { if (!canPromoteBundle.value) { toast.error(t('no-permission')) return @@ -591,7 +664,7 @@ async function openSelectVersion() { bundleLinkVersions.value = data ?? [] bundleLinkSearchVal.value = '' - bundleLinkSearchMode.value = false + bundleLinkSearchMode.value = startInSearch // Open the dialog dialogStore.openDialog({ @@ -615,100 +688,148 @@ async function openSelectStableVersion() { async function openSelectRolloutVersion() { bundleLinkMode.value = 'rollout' - await openSelectVersion() + await openSelectVersion(true) } async function enableRollout() { - if (!channel.value) - return - if (!channel.value.rollout_version) { - await openSelectRolloutVersion() - return - } - await saveChannelChange('rollout_enabled', true as any) + await getRolloutConfirmFlows().enableRollout() } -async function disableRollout() { - if (await saveChannelChanges({ - rollout_enabled: false, - rollout_version: null, - rollout_paused_at: null, - rollout_pause_reason: null, - })) { - await askUpdateNotificationAfterBundleChange() - } +function syncRolloutSettingsDraft() { + rolloutPercentageDraft.value = String((channel.value?.rollout_percentage_bps ?? 0) / 100) + rolloutCacheTtlDraft.value = String(channel.value?.rollout_cache_ttl_seconds ?? 2592000) } -async function saveRolloutPercentage(value: string) { - const percentage = Number.parseFloat(value) - if (Number.isNaN(percentage) || percentage < 0 || percentage > 100) { - toast.error(t('invalid-rollout-percentage')) - return - } - await saveChannelChange('rollout_percentage_bps', Math.round(percentage * 100) as any) +async function startEditRolloutSettings() { + syncRolloutSettingsDraft() + editingRolloutSettings.value = true + await nextTick() + rolloutPercentageInput.value?.focus() + rolloutPercentageInput.value?.select() } -async function saveIntegerField(key: EditableChannelKey, value: string, min: number, max: number, nullable = false) { - const trimmedValue = value.trim() - if (!trimmedValue && nullable) { - await saveChannelChange(key, null as any) - return - } +function cancelEditRolloutSettings() { + editingRolloutSettings.value = false + syncRolloutSettingsDraft() +} + +async function applyRolloutSettings() { + await getRolloutConfirmFlows().applyRolloutSettings({ + percentage: rolloutPercentageDraft.value, + cacheTtlSeconds: rolloutCacheTtlDraft.value, + }) + if (!rolloutSettingsDraftChanged.value) + editingRolloutSettings.value = false +} + +function parseOptionalInteger(value: string, min: number, max: number) { + const trimmed = value.trim() + if (!trimmed) + return { ok: true as const, value: null } + const parsed = Number(trimmed) + if (!Number.isInteger(parsed) || parsed < min || parsed > max) + return { ok: false as const } + return { ok: true as const, value: parsed } +} - const parsedValue = Number(trimmedValue) - if (!Number.isInteger(parsedValue) || parsedValue < min || parsedValue > max) { +function parseRequiredInteger(value: string, min: number, max: number) { + const parsed = Number(value.trim()) + if (!Number.isInteger(parsed) || parsed < min || parsed > max) + return { ok: false as const } + return { ok: true as const, value: parsed } +} + +function autoPauseActionLabel(action: string) { + if (action === 'rollback') + return t('rollback') + if (action === 'notify') + return t('notify') + return t('pause') +} + +function displayOptionalNumber(value: number | null | undefined) { + return value == null ? t('not-configured') : String(value) +} + +function buildAutoPauseChanges() { + const row = channel.value + const draft = autoPauseDraft.value + if (!row) + return null + const failure = parseOptionalInteger(draft.failureRateBps, 0, 10000) + const window = parseRequiredInteger(draft.windowMinutes, 1, 10080) + const confidence = Number(Number(draft.confidence.trim()).toFixed(4)) + const minAttempts = parseOptionalInteger(draft.minAttempts, 0, Number.MAX_SAFE_INTEGER) + const minFailures = parseOptionalInteger(draft.minFailures, 0, Number.MAX_SAFE_INTEGER) + const cooldown = parseRequiredInteger(draft.cooldownMinutes, 0, 10080) + if (!failure.ok || !window.ok || !minAttempts.ok || !minFailures.ok || !cooldown.ok + || !Number.isFinite(confidence) || confidence <= 0 || confidence >= 1 + || !['pause', 'rollback', 'notify'].includes(draft.action)) { toast.error(t('error-update-channel')) - return + return null + } + const next = { + auto_pause_failure_rate_bps: failure.value, + auto_pause_action: draft.action, + auto_pause_window_minutes: window.value, + auto_pause_confidence: confidence, + auto_pause_min_attempts: minAttempts.value, + auto_pause_min_failures: minFailures.value, + auto_pause_cooldown_minutes: cooldown.value, } + const changes: Record = {} + if (row.auto_pause_failure_rate_bps !== next.auto_pause_failure_rate_bps) + changes.auto_pause_failure_rate_bps = next.auto_pause_failure_rate_bps + if (row.auto_pause_action !== next.auto_pause_action) + changes.auto_pause_action = next.auto_pause_action + if (row.auto_pause_window_minutes !== next.auto_pause_window_minutes) + changes.auto_pause_window_minutes = next.auto_pause_window_minutes + if (Number(row.auto_pause_confidence) !== next.auto_pause_confidence) + changes.auto_pause_confidence = next.auto_pause_confidence + if (row.auto_pause_min_attempts !== next.auto_pause_min_attempts) + changes.auto_pause_min_attempts = next.auto_pause_min_attempts + if (row.auto_pause_min_failures !== next.auto_pause_min_failures) + changes.auto_pause_min_failures = next.auto_pause_min_failures + if (row.auto_pause_cooldown_minutes !== next.auto_pause_cooldown_minutes) + changes.auto_pause_cooldown_minutes = next.auto_pause_cooldown_minutes + return changes +} - await saveChannelChange(key, parsedValue as any) +function startEditAutoPause() { + if (channel.value) + autoPauseDraft.value = autoPauseDraftFromChannel(channel.value) + editingAutoPause.value = true } -async function saveAutoPauseFailureRate(value: string) { - await saveIntegerField('auto_pause_failure_rate_bps', value, 0, 10000, true) +function cancelEditAutoPause() { + editingAutoPause.value = false + if (channel.value) + autoPauseDraft.value = autoPauseDraftFromChannel(channel.value) } -async function saveAutoPauseConfidence(value: string) { - const confidence = Number(value.trim()) - if (!Number.isFinite(confidence) || confidence <= 0 || confidence >= 1) { - toast.error(t('error-update-channel')) +async function applyAutoPauseSettings() { + const changes = buildAutoPauseChanges() + if (!changes) + return + if (Object.keys(changes).length === 0) { + editingAutoPause.value = false return } - - await saveChannelChange('auto_pause_confidence', Number(confidence.toFixed(4)) as any) + await getRolloutConfirmFlows().applyAutoPauseSettings(changes) + if (Object.keys(buildAutoPauseChanges() ?? {}).length === 0) + editingAutoPause.value = false } async function rollbackRollout() { - if (await saveChannelChanges({ - rollout_version: null, - rollout_enabled: false, - rollout_percentage_bps: 0, - rollout_paused_at: null, - rollout_pause_reason: null, - })) { - await askUpdateNotificationAfterBundleChange() - } + await getRolloutConfirmFlows().rollbackRollout() } async function promoteRollout() { - if (!channel.value?.rollout_version) - return - if (await saveChannelChanges({ - version: channel.value.rollout_version, - rollout_version: null, - rollout_enabled: false, - rollout_percentage_bps: 0, - rollout_paused_at: null, - rollout_pause_reason: null, - })) { - await askUpdateNotificationAfterBundleChange() - } + await getRolloutConfirmFlows().promoteRollout() } async function toggleRolloutPause() { - await saveChannelChanges(channel.value?.rollout_paused_at - ? { rollout_paused_at: null, rollout_pause_reason: null } - : { rollout_paused_at: new Date().toISOString(), rollout_pause_reason: t('manual-rollout-pause') }) + await getRolloutConfirmFlows().toggleRolloutPause() } async function refreshFilteredVersions() { @@ -766,6 +887,69 @@ function closeUpdatePackageDropdown() { } } +function openChannelInfoDialog(id: string, title: string, description: string) { + dialogStore.openDialog({ + id, + title, + description, + size: 'lg', + buttons: [ + { + text: t('close'), + role: 'primary', + }, + ], + }) +} + +function openRolloutSettingsInfo() { + openChannelInfoDialog( + 'rollout-settings-info', + t('progressive-rollout'), + `${t('rollout-settings-help')}\n\n${t('rollout-percentage-help')}\n\n${t('cache-ttl-help')}`, + ) +} + +function openAutoPauseInfo() { + openChannelInfoDialog( + 'auto-pause-info', + t('auto-pause'), + t('auto-pause-help'), + ) +} + +function openUpdatePackageInfo() { + openChannelInfoDialog( + 'update-package-info', + t('update-package'), + getUpdatePackageInfoDescriptionCopy(t, channel.value?.update_package ?? 'all'), + ) +} + +let rolloutConfirmFlows!: ReturnType + +function getRolloutConfirmFlows() { + return rolloutConfirmFlows +} + +rolloutConfirmFlows = createChannelRolloutConfirmFlows({ + dialogStore, + t, + toast, + canUpdateChannelSettings: () => canUpdateChannelSettings.value, + closeUpdatePackageDropdown, + getChannel: () => channel.value, + rolloutTargetName: () => rolloutTargetName.value, + stableBundleName: () => stableBundleName.value, + rolloutPercentageText: () => rolloutPercentageText.value, + saveChannelChange: async (key, value) => { + await saveChannelChange(key as EditableChannelKey, value as any) + }, + saveChannelChanges, + askUpdateNotificationAfterBundleChange, + openSelectRolloutVersion, +}) + function getAutoUpdateLabel(value: string) { switch (value) { case 'major': @@ -821,28 +1005,15 @@ const updatePackageOptions = [ ] as const satisfies Database['public']['Enums']['channel_update_package'][] function getUpdatePackageLabel(value?: Database['public']['Enums']['channel_update_package'] | null) { - switch (value) { - case 'zip': - return t('update-package-zip') - case 'delta': - return t('update-package-delta') - case 'zip_from_builtin': - return t('update-package-zip-from-builtin') - case 'delta_from_builtin': - return t('update-package-delta-from-builtin') - default: - return t('update-package-all') - } + return getUpdatePackageLabelCopy(t, value) } -async function onSelectUpdatePackage(value: Database['public']['Enums']['channel_update_package']) { - if (!canUpdateChannelSettings.value) { - toast.error(t('no-permission')) - return false - } +function getUpdatePackageDescription(value: Database['public']['Enums']['channel_update_package']) { + return getUpdatePackageDescriptionCopy(t, value) +} - await saveChannelChange('update_package', value) - closeUpdatePackageDropdown() +async function onSelectUpdatePackage(value: Database['public']['Enums']['channel_update_package']) { + await getRolloutConfirmFlows().onSelectUpdatePackage(value) } function openLink(url?: string): void { @@ -1034,8 +1205,8 @@ async function copyCurlCommand() {
{{ channel.version.name }} +
+
+

+ {{ t('rollout-target') }}: + {{ channel?.rollout_version_info?.name ?? t('not-configured') }}

+ + + +
- - {{ rolloutStatusLabel }} - - -
-
-
- {{ t('rollout-target') }} -
-
- {{ channel?.rollout_version_info?.name ?? t('not-configured') }} -
-
-
-
- {{ t('rollout-percentage') }} -
-
- {{ rolloutPercentageText }} -
-
-
+

+ {{ channel.rollout_pause_reason }} +

@@ -1119,56 +1292,77 @@ async function copyCurlCommand() {
-
-
- -
@@ -1399,13 +1680,13 @@ async function copyCurlCommand() {
-
+
{{ getUpdatePackageLabel(channel.update_package) }} -
    +
    • - {{ getUpdatePackageLabel(option) }} + {{ getUpdatePackageLabel(option) }} + {{ getUpdatePackageDescription(option) }}
-
- -
- {{ t('update-package-help') }} -
-
-
+
@@ -1527,7 +1812,7 @@ async function copyCurlCommand() {

- {{ t('select-bundle-action-for-channel') }} + {{ bundleLinkMode === 'rollout' ? t('search-and-select-a-different-bundle') : t('select-bundle-action-for-channel') }}

diff --git a/src/pages/app/[app].notifications.vue b/src/pages/app/[app].notifications.vue index 09d6c8a5ca..23aeb19717 100644 --- a/src/pages/app/[app].notifications.vue +++ b/src/pages/app/[app].notifications.vue @@ -23,6 +23,7 @@ interface NotificationProviderConfig { status: string config: Record secret_ref?: string | null + has_secret?: boolean } interface NotificationCampaign { @@ -102,8 +103,10 @@ const providerForm = ref({ platform: 'android', status: 'draft', secretRef: '', + secretMaterial: '', config: '{\n "projectId": "",\n "serviceAccountEmail": ""\n}', }) +const providerSecretFileName = ref('') const campaignForm = ref({ name: '', kind: 'alert', @@ -157,6 +160,10 @@ const expectedProviderSecretRef = computed(() => { const normalizedAppId = normalizeSecretRefSegment(id.value) return `NOTIFICATIONS_${normalizedAppId}_${providerSecretRefSegment(providerForm.value.platform)}` }) +const currentProviderHasSecret = computed(() => { + const provider = providers.value.find(item => item.platform === providerForm.value.platform) + return Boolean(provider?.has_secret) +}) const providerConfigPlaceholder = computed(() => { if (providerForm.value.platform === 'ios') { return '{\n "teamId": "",\n "keyId": "",\n "bundleId": "",\n "environment": "production"\n}' @@ -168,6 +175,28 @@ function providerSecretRefSegment(platform: string) { return platform === 'ios' ? 'IOS' : 'ANDROID' } +function providerSecretAccept(platform: string) { + return platform === 'ios' ? '.p8,text/plain' : '.json,application/json,text/plain' +} + +function providerSecretLabel(platform: string) { + return platform === 'ios' ? t('notification-provider-secret-file-ios') : t('notification-provider-secret-file-android') +} + +async function onProviderSecretFileChange(event: Event) { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + if (!file) + return + providerSecretFileName.value = file.name + providerForm.value.secretMaterial = await file.text() +} + +function clearProviderSecretSelection() { + providerForm.value.secretMaterial = '' + providerSecretFileName.value = '' +} + function notificationPlatformLabel(platform?: string) { if (platform === 'android') return t('notification-platform-android') @@ -382,16 +411,22 @@ async function refreshData(appId = id.value, refreshId = ++activeRefreshId) { async function saveProvider() { isSaving.value = true try { + const payload: Record = { + appId: id.value, + platform: providerForm.value.platform, + status: providerForm.value.status, + config: parseJson(providerForm.value.config, {}), + } + if (providerForm.value.secretMaterial.trim()) + payload.secretMaterial = providerForm.value.secretMaterial.trim() + else if (providerForm.value.secretRef.trim()) + payload.secretRef = providerForm.value.secretRef.trim() await notificationFetch('/providers', { method: 'PUT', - body: JSON.stringify({ - appId: id.value, - platform: providerForm.value.platform, - status: providerForm.value.status, - secretRef: providerForm.value.secretRef.trim() || (providerForm.value.status === 'configured' ? expectedProviderSecretRef.value : null), - config: parseJson(providerForm.value.config, {}), - }), + body: JSON.stringify(payload), }) + providerForm.value.secretMaterial = '' + providerSecretFileName.value = '' toast.success(t('notification-save-success')) await reloadNotifications() } @@ -607,6 +642,8 @@ watch(() => { watch(() => providerForm.value.platform, () => { providerForm.value.config = providerConfigPlaceholder.value + providerForm.value.secretMaterial = '' + providerSecretFileName.value = '' }) watch(broadcastSearch, () => { @@ -744,11 +781,38 @@ watch(activeNotificationTab, () => {
+
+ + {{ t('notification-provider-self-host-advanced') }} + +
+ +
+