From 73754df3d8ac73ff1a45ec18940a097848e64e87 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sun, 31 May 2026 07:29:18 +0200 Subject: [PATCH 01/14] Merge onboarding TUI rewrite with main's workflow + tracking features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the squash of claude/build-init-onboarding-tui onto current main. The branch rewrote the build-init onboarding apps after forking, while main shipped two features in the same files: • #2315 — GitHub Actions workflow generation + CAPGO_TOKEN auto-push • #2327 — Android service-account onboarding tracking Both features are RE-PORTED into the rewritten state machine (not dropped): workflow step-flow (3-option GitHub prompt → build-script picker → writing-workflow-file / confirm-overwrite, .env export fallback), the diff/secrets viewers, and the Android SA-tracking events — all coexisting with the rewrite's per-platform min size, scrollable iOS error viewer, build-log sanitize, one-row-per-line build viewer, and the OnboardingShell picker. Conflicts resolved: • tsconfig.json — keep ignoreDeprecations (bumped 5.0→6.0 for TS6) + baseUrl • package.json — main's newer versions + @xterm/headless + unioned test scripts • command.ts — OnboardingShell render path • ui/components.tsx — union: AiViewer/BuildOutput + DiffViewer/SecretsTable • ui/app.tsx, android/ui/app.tsx — feature re-port (see above) • bun.lock — regenerated Verified: build green (tsc), both feature test sets pass (workflow-generator 13, diff-utils 8, ci-secrets 17, android-onboarding-progress 11, onboarding-telemetry) AND the rewrite suites (min-size 50, frame-fit 9/9, build-log-sanitize 12, viewport 4, gates 9+7, platform-layout 11, ai-fit 31, ai-onboarding-mode 9). Pre-existing lint debt (unused `dense` params / dead size-gate symbols from the rewrite) is cleaned in a follow-up commit. --- bun.lock | 185 +- cli/package.json | 14 +- cli/src/ai/analyze.ts | 42 +- cli/src/ai/telemetry.ts | 4 +- cli/src/build/onboarding/ai-fit.ts | 211 ++ cli/src/build/onboarding/android/types.ts | 14 + cli/src/build/onboarding/android/ui/app.tsx | 2050 ++++++++------- cli/src/build/onboarding/build-log.ts | 59 + cli/src/build/onboarding/command.ts | 105 +- cli/src/build/onboarding/min-terminal-size.ts | 74 + cli/src/build/onboarding/types.ts | 14 + cli/src/build/onboarding/ui/app.tsx | 2257 ++++++++--------- .../onboarding/ui/completed-steps-log.tsx | 53 + cli/src/build/onboarding/ui/components.tsx | 438 +++- cli/src/build/onboarding/ui/frame-fit.ts | 101 + cli/src/build/onboarding/ui/min-size-gate.tsx | 91 + .../build/onboarding/ui/platform-picker.tsx | 111 + cli/src/build/onboarding/ui/shell.tsx | 136 + .../build/onboarding/ui/steps/android-ci.tsx | 272 ++ .../onboarding/ui/steps/android-keystore.tsx | 363 +++ .../onboarding/ui/steps/android-sa-gcp.tsx | 574 +++++ .../onboarding/ui/steps/android-shared.tsx | 375 +++ cli/src/build/onboarding/ui/steps/ios-ci.tsx | 299 +++ .../onboarding/ui/steps/ios-credentials.tsx | 446 ++++ .../build/onboarding/ui/steps/ios-import.tsx | 367 +++ .../build/onboarding/ui/steps/ios-shared.tsx | 585 +++++ cli/src/build/request.ts | 37 +- cli/src/schemas/build.ts | 20 + cli/test/find-min-onboarding-size.mjs | 78 + cli/test/helpers/frame-fit.mjs | 168 ++ cli/test/helpers/onboarding-fixtures.mjs | 124 + cli/test/helpers/onboarding-frame.mjs | 55 + cli/test/helpers/size-search.mjs | 97 + cli/test/helpers/vt-grid.mjs | 160 ++ cli/test/run-frame-fit.mjs | 31 + cli/test/test-ai-fit.mjs | 288 +++ cli/test/test-ai-onboarding-mode.mjs | 167 ++ cli/test/test-build-log-sanitize.mjs | 97 + cli/test/test-build-output-viewport.mjs | 101 + cli/test/test-frame-fit-ai.mjs | 63 + cli/test/test-frame-fit-android-shared.mjs | 289 +++ cli/test/test-frame-fit-build.mjs | 147 ++ cli/test/test-frame-fit-decision.mjs | 131 + cli/test/test-frame-fit-ios-shared.mjs | 63 + cli/test/test-frame-fit-log.mjs | 154 ++ cli/test/test-frame-fit-platform.mjs | 71 + cli/test/test-frame-fit-resize.mjs | 96 + cli/test/test-frame-fit-viewer.mjs | 139 + cli/test/test-min-size-gate.mjs | 80 + cli/test/test-onboarding-min-size.mjs | 51 + cli/test/test-platform-layout.mjs | 66 + cli/test/test-shell-size-gate.mjs | 112 + cli/tsconfig.json | 2 + 53 files changed, 9731 insertions(+), 2396 deletions(-) create mode 100644 cli/src/build/onboarding/ai-fit.ts create mode 100644 cli/src/build/onboarding/build-log.ts create mode 100644 cli/src/build/onboarding/min-terminal-size.ts create mode 100644 cli/src/build/onboarding/ui/completed-steps-log.tsx create mode 100644 cli/src/build/onboarding/ui/frame-fit.ts create mode 100644 cli/src/build/onboarding/ui/min-size-gate.tsx create mode 100644 cli/src/build/onboarding/ui/platform-picker.tsx create mode 100644 cli/src/build/onboarding/ui/shell.tsx create mode 100644 cli/src/build/onboarding/ui/steps/android-ci.tsx create mode 100644 cli/src/build/onboarding/ui/steps/android-keystore.tsx create mode 100644 cli/src/build/onboarding/ui/steps/android-sa-gcp.tsx create mode 100644 cli/src/build/onboarding/ui/steps/android-shared.tsx create mode 100644 cli/src/build/onboarding/ui/steps/ios-ci.tsx create mode 100644 cli/src/build/onboarding/ui/steps/ios-credentials.tsx create mode 100644 cli/src/build/onboarding/ui/steps/ios-import.tsx create mode 100644 cli/src/build/onboarding/ui/steps/ios-shared.tsx create mode 100644 cli/test/find-min-onboarding-size.mjs create mode 100644 cli/test/helpers/frame-fit.mjs create mode 100644 cli/test/helpers/onboarding-fixtures.mjs create mode 100644 cli/test/helpers/onboarding-frame.mjs create mode 100644 cli/test/helpers/size-search.mjs create mode 100644 cli/test/helpers/vt-grid.mjs create mode 100644 cli/test/run-frame-fit.mjs create mode 100644 cli/test/test-ai-fit.mjs create mode 100644 cli/test/test-ai-onboarding-mode.mjs create mode 100644 cli/test/test-build-log-sanitize.mjs create mode 100644 cli/test/test-build-output-viewport.mjs create mode 100644 cli/test/test-frame-fit-ai.mjs create mode 100644 cli/test/test-frame-fit-android-shared.mjs create mode 100644 cli/test/test-frame-fit-build.mjs create mode 100644 cli/test/test-frame-fit-decision.mjs create mode 100644 cli/test/test-frame-fit-ios-shared.mjs create mode 100644 cli/test/test-frame-fit-log.mjs create mode 100644 cli/test/test-frame-fit-platform.mjs create mode 100644 cli/test/test-frame-fit-resize.mjs create mode 100644 cli/test/test-frame-fit-viewer.mjs create mode 100644 cli/test/test-min-size-gate.mjs create mode 100644 cli/test/test-onboarding-min-size.mjs create mode 100644 cli/test/test-platform-layout.mjs create mode 100644 cli/test/test-shell-size-gate.mjs diff --git a/bun.lock b/bun.lock index 0cd3d68418..19a97b4b33 100644 --- a/bun.lock +++ b/bun.lock @@ -207,7 +207,7 @@ }, "cli": { "name": "@capgo/cli", - "version": "7.112.3", + "version": "7.119.0", "bin": { "capgo": "dist/index.js", }, @@ -242,6 +242,7 @@ "@types/ws": "^8.18.1", "@typescript/native-preview": "7.0.0-dev.20260526.1", "@vercel/ncc": "^0.38.4", + "@xterm/headless": "^6.0.0", "adm-zip": "^0.5.17", "ci-info": "^4.4.0", "commander": "^14.0.3", @@ -290,45 +291,45 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], - "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1055.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.14", "@aws-sdk/credential-provider-node": "^3.972.45", "@aws-sdk/middleware-bucket-endpoint": "^3.972.16", "@aws-sdk/middleware-expect-continue": "^3.972.13", "@aws-sdk/middleware-flexible-checksums": "^3.974.22", "@aws-sdk/middleware-location-constraint": "^3.972.11", "@aws-sdk/middleware-sdk-s3": "^3.972.43", "@aws-sdk/middleware-ssec": "^3.972.11", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-FxVwuw86c2Mw4p+0tOtoE+1sDTk+eOBZD/NwwK+wwx1gHkdO/EYSv231O9A1YM8HPjUrI0vZ/hP/szckBxHW0A=="], + "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1057.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-node": "^3.972.47", "@aws-sdk/middleware-bucket-endpoint": "^3.972.17", "@aws-sdk/middleware-expect-continue": "^3.972.14", "@aws-sdk/middleware-flexible-checksums": "^3.974.23", "@aws-sdk/middleware-location-constraint": "^3.972.11", "@aws-sdk/middleware-sdk-s3": "^3.972.44", "@aws-sdk/middleware-ssec": "^3.972.11", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-4MV5+ph7WSLEqStKYdWf2EIHIvLpPzV8xN98jWSVJfUpp5j7T8dyN3AROPPsKWvCme8hbx1ybCjtK76ALCZUYg=="], - "@aws-sdk/core": ["@aws-sdk/core@3.974.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.26", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.3", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-ppamm04uoj3hhNO5IlQSs5D6rWX1fWkzcn6a4pZrojk8Y6ObY9wzLDdT/Eq3gv6O9hOebi9tYTNB8b8fQj9XJw=="], + "@aws-sdk/core": ["@aws-sdk/core@3.974.15", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.26", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.5", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw=="], "@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-P+QGozmXn2mZZI7sDgk+aUm+RTI61MPSFB+Ir2vjEjEbEsE4e7hYtzrDvAUxZy9ko81h53e11+F/GYlvwDkaOQ=="], - "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.40", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-jjT0p0Y7KZtcvExYiPCLJnqM9lkXDV1KBEg/13OE2DXv/9batzlyJHVKUEnRNJccY0O2Sul17E1su38CgdBhGQ=="], + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg=="], - "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-+3fsKtWybe5BjKEUA3/07oh7Ayfd82IED2+gyyaVfS/4PU78E3TaOQxSGOJ1t7Imefoidw/ne9QA7apX8wEnJg=="], + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA=="], - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/credential-provider-env": "^3.972.40", "@aws-sdk/credential-provider-http": "^3.972.42", "@aws-sdk/credential-provider-login": "^3.972.44", "@aws-sdk/credential-provider-process": "^3.972.40", "@aws-sdk/credential-provider-sso": "^3.972.44", "@aws-sdk/credential-provider-web-identity": "^3.972.44", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-gZFw5wBefCIPg9vpT+gV5FdhfNKhYTVDZa1IsZCcn3SRoYUOJ/E05vwIogkJoonqBL0ttBGi5vhthX7xceekRg=="], + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA=="], - "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-QqEGHfQeZgUDqh7zpqHufrZ8T644ELEWvB+4gUdewLyRw4IRF+6CJqeQuRWqucZdQzoQeMh7fNAD9BWxFAdNig=="], + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.45", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.40", "@aws-sdk/credential-provider-http": "^3.972.42", "@aws-sdk/credential-provider-ini": "^3.972.44", "@aws-sdk/credential-provider-process": "^3.972.40", "@aws-sdk/credential-provider-sso": "^3.972.44", "@aws-sdk/credential-provider-web-identity": "^3.972.44", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-3YCv52ExXIRz3LAVNysevd+s7akSpg9dl39v9LJ7dOQH+s5rHi3jMZYQyxwMmglxQGMuzYRfQ0o1VSP2UOlIRw=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.47", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HrId+C0DWA5qDIyLG64/kjUB2RNtPypxmABnIctK+TA1P1kHlOYoE/Wf5T5tKOMKgb08P7k/zNyhvfJ3lh5Oag=="], - "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.40", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-cXaozlgJCOwmE6D7x4npcPdyk7kiFZdrGjN3D6tXXtItJJMNGPafDfAJn4YQmciMooG/X+b0Y6RTqdVVMx26jg=="], + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ=="], - "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/token-providers": "3.1054.0", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-YePoj5kQuPmE0MHnyftXCfsO8ZSBd2kDr50XEIUrdejSbGFlayYvUuCohdb8drhGhPm6b65o7H1eC26EZhwUvA=="], + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/token-providers": "3.1056.0", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA=="], - "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Ys/JJe++8Z2Y5meR1taMBaVcrGBA0/XsVTQR+qOKZbdNyg+8Jlv5rYZSwh8SqEHY00goSOZy7PHzZ2rLNQxDLg=="], + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ=="], - "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.16", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-FhasMTBDBmMN7EEa1hUeHwo5p5Mv3Dm8w0VEbdXX/6ola/uyhRuJt8zGkH09mLTmab20USTzEpPqyqEoe1MqNg=="], + "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-lbDmWuHenc+kiwCNrxz4MyN6nkxCWyTXPIWuspJN0ibziu+8CXci7vI1bK9MAkwy8cwJOEXNu0gBM5S0uTGRIg=="], - "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.13", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-sHiqIFg8o2ipT7t40B89Vj0ubSUtY6OSt/+Ee/OXhHch5K4+81zP2+QX8Lkc/nJ2QSmCySxOke7TEbmX69fe2g=="], + "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-3TNFEVGO4sWZj9TEXOCZLzGEctXHnaO4fk2EQ8KVaboTbwHmEPEQrm17Xb9koImUIXEw0sgi2xtHjg7LuTS3rA=="], - "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.22", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.14", "@aws-sdk/crc64-nvme": "^3.972.9", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-ot1kZ1JGHUxcXPOARhej/n/+Odfx9VPt60pNrUq8Lf/U2blIF3+uj5v56gw76VD70dZvrfeLNo9jKz6pQJfOlA=="], + "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.23", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/crc64-nvme": "^3.972.9", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-4nPKARo2lfKvQGUt2fPA5NlS/mEohckdxpuC9ecbjVfj7B7NFFYHeTg+Bf5BEQwdn3yRfUIzFiEkPp8Yuaw3wA=="], "@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hkfspNUP4criAH6ton6BGKgnm5dZx+7bUOy1YqlTfejDeUPAM23D81q/IX+hdlS3KUsfwGz5ADTqZWKBEUpf4A=="], - "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-CBmixMY36JdAdt9ALgm7yVlvOXGUCHt9Z2kn5p9XVO5StO6HCH+cayV7YYV1CDLsXvVyebaXgBmif9wHoxCeNA=="], + "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-8HQsRg1NpX8vR4vNl1E8pyLnqZroq9VSL2vZQVSgBqp6wv6365LzYD08/c9FFh/9FTg7YRc7aTtEmXF0ir/pqg=="], "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-7PQvGNhtveKlvVqNahqWx5yrwxP7ecwAoB1dYBf8eKwfo2tzzCbNnW+q2nO3N066ktQaB4iBQbDRWtizm+amoQ=="], - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.12", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.14", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Js2VYaCM269feB0cs0cGmlIhdOgT9aMqzdBx68lCy6kVCYfzr0T36ovUFDvfUmatkuBeyBJhCwaLBh7P8meH5Q=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg=="], - "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.29", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Few9FoQqOt/0KSvZYP+qdW0dfOhfQ9N+gl2UUDvCPW6mkPKHli9LMbKxWj+wZ5zKPaOoqxuR3Hhy3OTpndkfSw=="], + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.30", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw=="], - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1054.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hG9YKApmZOw+drJ9Nuoaf/OvC8e5W1+3eoLeN5p2uVCZRWsv27teIS0b4kiH6Sfv3WMmamqYJxmE2WMwyp/L/A=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1056.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA=="], "@aws-sdk/types": ["@aws-sdk/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="], @@ -532,7 +533,7 @@ "@capgo/capacitor-keep-awake": ["@capgo/capacitor-keep-awake@8.1.11", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-Xfa+1EayBlFb75r54CBh1LU9eHsbsNWZuhkzQffVts4JCIqVpMS2sGDduX/2VjQ5mdyYksQxaFBurPYxt67i7g=="], - "@capgo/capacitor-launch-navigator": ["@capgo/capacitor-launch-navigator@8.0.23", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-oU9+TBayGKnj7Y59n1jqgNlwuqatcJJ+aOSmrcf7MFO+1fcVTWIDTCK7xADgXVxVBHOL/VahqkwhWisVWkiytg=="], + "@capgo/capacitor-launch-navigator": ["@capgo/capacitor-launch-navigator@8.0.24", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-OAA5mzRA6Z2rDCMvyLQ9FB2KCyCI8445ILQ0lOD+whMpnULDqkbAMQlxa8mgATf2GX634M5XPEt5Jgy5OhcdPw=="], "@capgo/capacitor-light-sensor": ["@capgo/capacitor-light-sensor@8.1.9", "", { "peerDependencies": { "@capacitor/core": "^8.0.0" } }, "sha512-I04pvrKg+RGUPjOPXQLJzUYYWkg90CV+e8sEAV1VtN9flS1SKnztZJwOThB255gSjSNqnMfNxFc3IECwF8Rypw=="], @@ -572,7 +573,7 @@ "@capgo/capacitor-shake": ["@capgo/capacitor-shake@8.0.30", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-iL6MKR77cn7Uw4Nb/we0YXwFtf8pK4IlaOUZY+2g3LVCtwW9WY0s4OxG7QNNQEfqk4QaQrjjWXMGl2ztjg9NyQ=="], - "@capgo/capacitor-sheets": ["@capgo/capacitor-sheets@8.0.8", "", { "optionalDependencies": { "@rolldown/binding-wasm32-wasi": "1.0.2" }, "peerDependencies": { "@angular/core": ">=17.0.0", "react": ">=18.0.0", "react-dom": ">=18.0.0", "solid-js": ">=1.0.0", "svelte": ">=4.0.0", "vue": ">=3.0.0" }, "optionalPeers": ["@angular/core", "react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-kije3w8vfmmFwyOz0wJiRnqhmBmads+jPI8qfu3L7rR+ih1gV2R6DzsarQZ3M/IHBGITu+yCHvBC9VV4uY4i/Q=="], + "@capgo/capacitor-sheets": ["@capgo/capacitor-sheets@8.0.9", "", { "optionalDependencies": { "@rolldown/binding-wasm32-wasi": "1.0.2" }, "peerDependencies": { "@angular/core": ">=17.0.0", "react": ">=18.0.0", "react-dom": ">=18.0.0", "solid-js": ">=1.0.0", "svelte": ">=4.0.0", "vue": ">=3.0.0" }, "optionalPeers": ["@angular/core", "react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-ODYrkBchePnmN+G5omuj+mejFC6QmEAMaraJJZ0+YgFV09uzP/5E7MlfzJGm/pKCG+ZAhePc1/NcBfPGGuKp7w=="], "@capgo/capacitor-sim": ["@capgo/capacitor-sim@8.0.26", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-S3umJle3WAz1KGzYowC+f2cf19qlqukB46lObZj/vArSR2YoZ31k3EfFGaDxLRJG9SsERhJsB362wOcqSSN6Tw=="], @@ -580,9 +581,9 @@ "@capgo/capacitor-textinteraction": ["@capgo/capacitor-textinteraction@8.0.25", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-rM3GamkyzUge9LwHVaWEPmsZ9Tc/rUm02X41BgSP1ntVkYRaSEktFnP98k5WBPhpk+Fb2UubBCCh+VFUYwZQtA=="], - "@capgo/capacitor-transitions": ["@capgo/capacitor-transitions@8.1.6", "", { "peerDependencies": { "@angular/core": ">=17.0.0", "react": ">=18.0.0", "react-dom": ">=18.0.0", "solid-js": ">=1.0.0", "svelte": ">=4.0.0", "vue": ">=3.0.0" }, "optionalPeers": ["@angular/core", "react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-PCUgpE2We3aCXDAO96bRNbBvlZeaYenBkR6uzn6F3McNVvwu56HgE2EHSAkEmFCsZMv/P5xI58dK/wC0KWfaSg=="], + "@capgo/capacitor-transitions": ["@capgo/capacitor-transitions@8.1.7", "", { "peerDependencies": { "@angular/core": ">=17.0.0", "react": ">=18.0.0", "react-dom": ">=18.0.0", "solid-js": ">=1.0.0", "svelte": ">=4.0.0", "vue": ">=3.0.0" }, "optionalPeers": ["@angular/core", "react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-0YJvNRs7uhIF6CedsJDH3EOh0CVwx4DOnuUds1tOBCCoktpKjhXUwTqF7QePBLZKupJMDrYNdSUZhqDNMRf47A=="], - "@capgo/capacitor-updater": ["@capgo/capacitor-updater@8.47.3", "", { "peerDependencies": { "@capacitor/core": "^8.0.0" } }, "sha512-IKLr0vAMHjJ/4qEfCt4lenLYNxRneA6kqh09ytuqNIwwnB3rwtljKeLEjlccHaOSbkDJ19TVK3x1O4YETIb3zQ=="], + "@capgo/capacitor-updater": ["@capgo/capacitor-updater@8.47.5", "", { "peerDependencies": { "@capacitor/core": "^8.0.0" } }, "sha512-EPhikDtnVRCiYZdluqykuNWOhKnOKS1qf7h2JLY8NxokMIOUU5qiNSslINRlwrhqeURuex/aVojCR5h13WOXew=="], "@capgo/capacitor-uploader": ["@capgo/capacitor-uploader@8.2.1", "", { "dependencies": { "idb": "^8.0.2" }, "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-jkOew7h10Auwusu+w0TVGMKXx3wIfj3ipIvX7mZlPdDpgw9RFAEqrOqmTsMfM/h9pbOgooJF5iO0I7346KOfwA=="], @@ -592,7 +593,7 @@ "@capgo/capacitor-volume-buttons": ["@capgo/capacitor-volume-buttons@8.0.11", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-cyS6KFh+DBHx/LCxAYjO48flbvnNfqgZCBaVX7i/QYiKLV6uvTbP/VbbxhhZFzggqOQJjrheBesPpZlRkJH6lg=="], - "@capgo/capacitor-webview-crash": ["@capgo/capacitor-webview-crash@8.0.3", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-nUmxj55nOjbX0F6iSBlb7VwkK8lUzO6xD2mh2yqN/pBDmDGAa0/88Qwynk38YnFKV/kA7eFwHmaUt92lkMHQaw=="], + "@capgo/capacitor-webview-crash": ["@capgo/capacitor-webview-crash@8.1.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-geF8JZgR76HkXI8TMaZxa5gBbnQ7VyiVsW8hq7j/fcnFK/EOr7+kOGTBBNTBuXntQs8iUmN5RZtZRWOkLjifIA=="], "@capgo/capacitor-webview-guardian": ["@capgo/capacitor-webview-guardian@8.0.16", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-zt1slNG/gIXP2hve0jlps6Wps+3RoHMTc7iJw585RdXRdUygg+l+TQ3FR8fz8R7bLAqcW/stdDTod69AywMWGA=="], @@ -608,7 +609,7 @@ "@capgo/find-package-manager": ["@capgo/find-package-manager@0.0.18", "", {}, "sha512-YTajLnUJYYOqHWH59l6Umlqq1PmdUReWY5HLgfHfVHJk/xyWzfQ8Kzo5dLd1vxqNWZV8zvGHLk9mCxMAxt/q1A=="], - "@capgo/inappbrowser": ["@capgo/inappbrowser@8.6.13", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-Ktzt7m5CrNs5ESCeeZrtCfimZHGF0+Qp4B3S9JSDkHDwsSad8IfaR+R7wrqRi2ntnSoaO0hbRYG1F1qtPicI0w=="], + "@capgo/inappbrowser": ["@capgo/inappbrowser@8.6.14", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-5aSpqDf94h6za78Sa2ZTlGIJpIhdV76ZPeuFSULTCDHswyGRGFjT5BKcs5WxiguUGD6dT+6tc04wlWU+yndNrA=="], "@capgo/native-audio": ["@capgo/native-audio@8.4.2", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-YI85cH5IQkiab12QvSozwqq0uuhTqcfsYtrFjC0IQQCIAca7yq6hlG6zxScjr6a8XpQc8xWyyEVWuWxa3lIHPw=="], @@ -618,9 +619,9 @@ "@capgo/ricoh360": ["@capgo/ricoh360@8.0.11", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-qgJGX+Q9XKhuZ+jcnpWvxWZICFSKQkTG1W+5GuFI7SGAiO51rUclfoBScaaXCNW3DzgDoy4s+ffOW9yyAsSI0A=="], - "@clack/core": ["@clack/core@1.3.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA=="], + "@clack/core": ["@clack/core@1.4.0", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-7Wctjq6f7c1CPz8sPpkwUnz8yRgVANkpNupb81q432FjcJg4l+Sw7XANdNSdWfAKq0IHI0JTcUeK5dxs/HrGPw=="], - "@clack/prompts": ["@clack/prompts@1.4.0", "", { "dependencies": { "@clack/core": "1.3.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA=="], + "@clack/prompts": ["@clack/prompts@1.5.0", "", { "dependencies": { "@clack/core": "1.4.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA=="], "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], @@ -638,9 +639,9 @@ "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260526.1", "", {}, "sha512-pQZBjD7p6C5R1ZPkSywA2yiZnL/LVfqdPKLQLdbEItro4ekmMuGXQv72vHkHIT3GeZmEgZktMA0JoWn2fBKmXw=="], - "@codspeed/core": ["@codspeed/core@5.4.0", "", { "dependencies": { "axios": "^1.4.0", "find-up": "^6.3.0", "form-data": "^4.0.4", "node-gyp-build": "^4.6.0" } }, "sha512-SwGjXDixN/zX1awBR95LzS0KxIs931qwf7Hbk7BRWv1jAdlMYf9o9GlSnWER4zGBHz941BvzFQJ1O2RIofW3cg=="], + "@codspeed/core": ["@codspeed/core@5.5.0", "", { "dependencies": { "axios": "^1.4.0", "find-up": "^6.3.0", "form-data": "^4.0.4", "node-gyp-build": "^4.6.0", "stack-trace": "1.0.0-pre2" } }, "sha512-5FbjNlxSVOfemB85orEecikZiTz0C8aZYUfCkt5HY6QLLd1mqkrHAfekEJw0gkHcgCjNgD6DVp2TXm0V/xtt4w=="], - "@codspeed/vitest-plugin": ["@codspeed/vitest-plugin@5.4.0", "", { "dependencies": { "@codspeed/core": "^5.4.0" }, "peerDependencies": { "tinybench": ">=2.9.0", "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "vitest": "^3.2 || ^4" } }, "sha512-Xa9HaZHUjYXn1T39bTipV5hmguk1vIuDZs3Gc5OYA8X4ohftYbKfyoFtBqVFfB/ii/p1ihuwt+tltraKMcRDsA=="], + "@codspeed/vitest-plugin": ["@codspeed/vitest-plugin@5.5.0", "", { "dependencies": { "@codspeed/core": "^5.5.0" }, "peerDependencies": { "tinybench": ">=2.9.0", "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "vitest": "^3.2 || ^4" } }, "sha512-u7NTLXujxo0bQVCYDXk2mDJ+lA8h5vPY96FvUpPYLUbzVRsM6HO62gXXn4ABJOGYwx1bIwXtgSpUlK/V63iIcg=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -734,7 +735,7 @@ "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], @@ -782,7 +783,7 @@ "@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.84", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-v4JVu6xIewGoETD4mm2k6UAdFAbTlY1duw5ZNSxYORfs2yFsHDhoU9Omn/BgrV0nR/ptWkF3ZIr/ZHoYXI/6Jw=="], - "@iconify/json": ["@iconify/json@2.2.479", "", { "dependencies": { "@iconify/types": "*", "pathe": "^2.0.3" } }, "sha512-BADLue16jcLxNU4+FUzPE+1uiUAGWH124VhFhluyHT1MvvI2l+X6WiRBqLmFQs+VyG3BQB40iLzXb8UxSglTeA=="], + "@iconify/json": ["@iconify/json@2.2.481", "", { "dependencies": { "@iconify/types": "*", "pathe": "^2.0.3" } }, "sha512-H/bCPJN+JawFnEgSFtBGQMz2nAc3KNsLZ6fKG6KfJX7X9H0blv0Acbt6umCBVRxZ0xBM/9FcUYZst4/Zv6SB/A=="], "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], @@ -840,7 +841,7 @@ "@inkjs/ui": ["@inkjs/ui@2.0.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-spinners": "^3.0.0", "deepmerge": "^4.3.1", "figures": "^6.1.0" }, "peerDependencies": { "ink": ">=5" } }, "sha512-5+8fJmwtF9UvikzLfph9sA+LS+l37Ij/szQltkuXLOAXwNkBX9innfzh4pLGXIB59vKEQUtc6D4qGvhD7h3pAg=="], - "@intlify/bundle-utils": ["@intlify/bundle-utils@11.2.3", "", { "dependencies": { "@intlify/message-compiler": "~11.4.2", "@intlify/shared": "~11.4.2", "acorn": "^8.8.2", "esbuild": "^0.25.4", "escodegen": "^2.1.0", "estree-walker": "^2.0.2", "jsonc-eslint-parser": "^2.3.0", "source-map-js": "^1.2.1", "yaml-eslint-parser": "^1.2.2" }, "peerDependencies": { "petite-vue-i18n": "*", "vue-i18n": "*" }, "optionalPeers": ["petite-vue-i18n", "vue-i18n"] }, "sha512-9mrJyUJGPFJCIFGthvIFT58CknG701z9D0VRtLBtat3teo0fisP3Q6bo/t9YHnljBTEZ42hYm1ukn16LfLkRRg=="], + "@intlify/bundle-utils": ["@intlify/bundle-utils@11.2.3", "", { "dependencies": { "@intlify/message-compiler": "~11.4.2", "@intlify/shared": "~11.4.2", "acorn": "^8.8.2", "esbuild": "^0.25.4", "escodegen": "^2.1.0", "estree-walker": "^2.0.2", "jsonc-eslint-parser": "^2.3.0", "source-map-js": "^1.2.1", "yaml-eslint-parser": "^1.2.2" } }, "sha512-9mrJyUJGPFJCIFGthvIFT58CknG701z9D0VRtLBtat3teo0fisP3Q6bo/t9YHnljBTEZ42hYm1ukn16LfLkRRg=="], "@intlify/core-base": ["@intlify/core-base@11.4.4", "", { "dependencies": { "@intlify/devtools-types": "11.4.4", "@intlify/message-compiler": "11.4.4", "@intlify/shared": "11.4.4" } }, "sha512-w/vItlylrAmhebkIbVl5YY8XMCtj8Mb2g70ttxktMYuf5AuRahgEHL2iLgLIsZBIbTSgs4hkUo7ucCL0uTJvOg=="], @@ -904,7 +905,7 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], + "@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -956,45 +957,43 @@ "@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], - "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.19.1", "", { "os": "android", "cpu": "arm" }, "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg=="], + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.20.0", "", { "os": "android", "cpu": "arm" }, "sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg=="], - "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.19.1", "", { "os": "android", "cpu": "arm64" }, "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA=="], + "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.20.0", "", { "os": "android", "cpu": "arm64" }, "sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q=="], - "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.19.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ=="], + "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.20.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ=="], - "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.19.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ=="], + "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.20.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg=="], - "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.19.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw=="], + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.20.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ=="], - "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1", "", { "os": "linux", "cpu": "arm" }, "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A=="], + "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg=="], - "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.19.1", "", { "os": "linux", "cpu": "arm" }, "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ=="], + "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.20.0", "", { "os": "linux", "cpu": "arm" }, "sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg=="], - "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.19.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig=="], + "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.20.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg=="], - "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.19.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew=="], + "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.20.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw=="], - "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.19.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ=="], + "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.20.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ=="], - "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.19.1", "", { "os": "linux", "cpu": "none" }, "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w=="], + "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.20.0", "", { "os": "linux", "cpu": "none" }, "sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw=="], - "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.19.1", "", { "os": "linux", "cpu": "none" }, "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw=="], + "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.20.0", "", { "os": "linux", "cpu": "none" }, "sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg=="], - "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.19.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA=="], + "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.20.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g=="], - "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.19.1", "", { "os": "linux", "cpu": "x64" }, "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ=="], + "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.20.0", "", { "os": "linux", "cpu": "x64" }, "sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g=="], - "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.19.1", "", { "os": "linux", "cpu": "x64" }, "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw=="], + "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.20.0", "", { "os": "linux", "cpu": "x64" }, "sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ=="], - "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.19.1", "", { "os": "none", "cpu": "arm64" }, "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA=="], + "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.20.0", "", { "os": "none", "cpu": "arm64" }, "sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ=="], - "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.19.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg=="], + "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.20.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg=="], - "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.19.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ=="], + "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.20.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA=="], - "@oxc-resolver/binding-win32-ia32-msvc": ["@oxc-resolver/binding-win32-ia32-msvc@11.19.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA=="], - - "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.19.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw=="], + "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.20.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.35.0", "", { "os": "android", "cpu": "arm" }, "sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA=="], @@ -1100,7 +1099,7 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], - "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], + "@pkgr/core": ["@pkgr/core@0.3.6", "", {}, "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA=="], "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], @@ -1150,7 +1149,7 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], + "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], "@sauber/table": ["@jsr/sauber__table@0.1.0", "https://npm.jsr.io/~/11/@jsr/sauber__table/0.1.0.tgz", {}, "sha512-DWmafG/lO+4SM+BVjIYd6gBrWc4GuLqVGk9bOgy5zK4SrqZS2HoF+p4xOhURmI62ucZDWs7ASIfF25nywuZy+g=="], @@ -1160,7 +1159,7 @@ "@smithy/core": ["@smithy/core@3.24.5", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA=="], - "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-yiF8xHpdkaTfzLVqFzsP6WvNghEK+qZzLYWFD13L2SsFhbXwBGlxdocKF95qjr7s5lE5NRage+EJFK4mAsx88Q=="], + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-tHhdiWZfG1ZIh2YcRfPJmY2gHcBmqbAzqm3ER4TIDFYsSEqTD5tICT7cgQ/kI8LRakxp12myOYyK68XPn7MnHw=="], "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-SK3VMeH0fibgdTg2QeB+O4p7Yy/2E5HBOHJeC58FshkDdeuX8lOgO7PfjYfLyPLP1ch55j91cQqKBzDS0mRjSQ=="], @@ -1186,21 +1185,21 @@ "@supabase/auth-js": ["@supabase/auth-js@2.106.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-VcAjUErkHkhC5Jaf+g/G1qbkQrFh8edaCdHa7pxJmHUjkWKjT7UnYCtPA89XV0N0GIYRkEqJZw5V62CtOxTmBQ=="], - "@supabase/cli-darwin-arm64": ["@supabase/cli-darwin-arm64@2.101.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SoWYzu2CIE+rADWsZH6H0YZYF8nV2YMelZLbjAqzahb5RRgFOTMWGhKCUj6g4KlNvocUnU19JObUJ7oCx/9aHg=="], + "@supabase/cli-darwin-arm64": ["@supabase/cli-darwin-arm64@2.102.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DzQetP1iOnxAmbarZJLoFr3yMtyDrfOC/T2aNpYEASykFGnvCb87CbWMQhjp9zDdA0pFfDYgzM5yESg7cC/anw=="], - "@supabase/cli-darwin-x64": ["@supabase/cli-darwin-x64@2.101.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rPES03BF9KYkirq3X1Yll+T+eORueRvC858Yp2+2aQMvB4qxi6k+WI+ilCQ7NGzOJ3G0jl/ZG/KsSjZIa4AJ6g=="], + "@supabase/cli-darwin-x64": ["@supabase/cli-darwin-x64@2.102.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-jJxyULNvgBuWxDrjVySwIB76IUlWOf5lYd/2Zz/Lrop8r1sr7aKJFULz7l1A1vk09ImbvnbkM9IBtH8rNdmBUg=="], - "@supabase/cli-linux-arm64": ["@supabase/cli-linux-arm64@2.101.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-E+hN1GRIFEi+U1Jf4Omw/gjwpSRTpJ5jai58tQana15AaXt3GCoG7z2YzWVnzD/mvAubTqA7SLHsV+A2lcdTGQ=="], + "@supabase/cli-linux-arm64": ["@supabase/cli-linux-arm64@2.102.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vOEdz4mWoqSpygXliGysYsDobtTlZLMhC2KpDsoYio9vQuQPRgaK2kW4fIGPKfagR4Odv3PycgjoNfdsg7TYUQ=="], - "@supabase/cli-linux-arm64-musl": ["@supabase/cli-linux-arm64-musl@2.101.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-B5JFFKKpJLLMwn8vWUB7j5euZPytiDOLo9WYXp0mshg45OaFKjRcDImSzBzx4k0W8MK2x75rFEURca90Ic60Ug=="], + "@supabase/cli-linux-arm64-musl": ["@supabase/cli-linux-arm64-musl@2.102.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-VCEJiXK4JhDRXPR32UcIijKdsVT0zg81EoIt6ydmNDJ9vfPI+eFRIzJpeEST1XobTMAVzbJEBw/b/zq5BywR5w=="], - "@supabase/cli-linux-x64": ["@supabase/cli-linux-x64@2.101.0", "", { "os": "linux", "cpu": "x64" }, "sha512-7iU9uA+8pbmW5uz+yGxU0fM83a+8SH/gtKmSSRZrOu9vIGcugg9gLbN9GlWkG5P7I7wEjnoVKb7Jzea6onSeQA=="], + "@supabase/cli-linux-x64": ["@supabase/cli-linux-x64@2.102.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Jcekp6NOWlLuj8vs/ToR11bbqGWhlijs0rmMRyQvDRocWFgeDWX5IBWZwCXZX2xxr7pICJaSdRZx6V8uW2XVEQ=="], - "@supabase/cli-linux-x64-musl": ["@supabase/cli-linux-x64-musl@2.101.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pr9Ar1/aVaQVqjNSfwRgXHWJznQTIaxblGO+hKIl9k55Ajahiq8GConCwx6DjX1Wa9VW//5Dji+O0pkmFSfcHw=="], + "@supabase/cli-linux-x64-musl": ["@supabase/cli-linux-x64-musl@2.102.0", "", { "os": "linux", "cpu": "x64" }, "sha512-KFzdivlbk8rnMHebxFa//AlQE3uxCWF6ZNIWkYUjE+1EBVXJr/j2Mr1SxkJJyt9h/8jztBUUPJgzuRzIJW0GyA=="], - "@supabase/cli-windows-arm64": ["@supabase/cli-windows-arm64@2.101.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSnFDhTIAe9cr1iifv+DTQzpY/0WphywED2YT3yMGeu94jjxrXrreQkywxEaeSXTnc4ys1umtnWOB1CaBlYKag=="], + "@supabase/cli-windows-arm64": ["@supabase/cli-windows-arm64@2.102.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-yY3qbHhMtQFI2TBXbU611UI7b0TaShxGwbunMUwmTUyaCLDTSsxOjxwbpCyfvgY7eDEeYI0WnkbBKHKxknKVDQ=="], - "@supabase/cli-windows-x64": ["@supabase/cli-windows-x64@2.101.0", "", { "os": "win32", "cpu": "x64" }, "sha512-xzZpybVWq62VF102abZr1EP3BbA2bYecwYm3G2dJk+6ua7nAIodu1fiGHAvBSR0BLGQmesNb6KCneawNL3XBsQ=="], + "@supabase/cli-windows-x64": ["@supabase/cli-windows-x64@2.102.0", "", { "os": "win32", "cpu": "x64" }, "sha512-PpIBXxGRi3bk0Zj2JeDCsr2edDt2qd5j31aaixuxHSsPJh0A052kDEDw4jSOWFHBL1UEai6rGF7zWuX/F3gNiw=="], "@supabase/functions-js": ["@supabase/functions-js@2.106.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-oRnr0QrL8H+zTO1YyQ1QjiHZU/957jvubbxSJTUm2XLAgzoGGV9Tahfyd+uvLsBLRVmXLtpU3oyCjdQIvkGMOA=="], @@ -1484,6 +1483,8 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], + "@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="], + "@zip.js/zip.js": ["@zip.js/zip.js@2.8.26", "", {}, "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -1522,7 +1523,7 @@ "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.2", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ=="], - "ast-walker-scope": ["ast-walker-scope@0.8.3", "", { "dependencies": { "@babel/parser": "^7.28.4", "ast-kit": "^2.1.3" } }, "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg=="], + "ast-walker-scope": ["ast-walker-scope@0.9.0", "", { "dependencies": { "@babel/parser": "^7.29.2", "@babel/types": "^7.29.0", "ast-kit": "^2.2.0" } }, "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A=="], "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], @@ -1538,7 +1539,7 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.33", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw=="], "better-qr": ["better-qr@0.1.1", "", {}, "sha512-DE2bUFHqglXOOoLDwh4x43FKFN7i7MjyCBvaMKvILU9V3rueG4G411RBSTyEnxfjRbYwfxPTZ/HboNNBHPTqpw=="], @@ -1680,13 +1681,13 @@ "daisyui": ["daisyui@5.5.20", "", {}, "sha512-HemJcjl0Gk9rQ8BcgofN6p+EURrqftQG9wK1Hkxs98i49xe68+QxpNvry+PyxwkIUgrbMpNmZ5ZWjmtffAjfhQ=="], - "date-fns": ["date-fns@4.3.0", "", {}, "sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ=="], + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], "de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], @@ -1732,7 +1733,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.362", "", {}, "sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w=="], + "electron-to-chromium": ["electron-to-chromium@1.5.364", "", {}, "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw=="], "elementtree": ["elementtree@0.1.7", "", { "dependencies": { "sax": "1.1.4" } }, "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg=="], @@ -1744,7 +1745,7 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "enhanced-resolve": ["enhanced-resolve@5.22.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A=="], + "enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="], "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -1786,7 +1787,7 @@ "eslint-formatting-reporter": ["eslint-formatting-reporter@0.0.0", "", { "dependencies": { "prettier-linter-helpers": "^1.0.0" }, "peerDependencies": { "eslint": ">=8.40.0" } }, "sha512-k9RdyTqxqN/wNYVaTk/ds5B5rA8lgoAmvceYN7bcZMBwU7TuXx5ntewJv81eF3pIL/CiJE+pJZm36llG8yhyyw=="], - "eslint-json-compat-utils": ["eslint-json-compat-utils@0.2.3", "", { "dependencies": { "esquery": "^1.6.0" }, "peerDependencies": { "@eslint/json": "*", "eslint": "*", "jsonc-eslint-parser": "^2.4.0 || ^3.0.0" }, "optionalPeers": ["@eslint/json"] }, "sha512-RbBmDFyu7FqnjE8F0ZxPNzx5UaptdeS9Uu50r7A+D7s/+FCX+ybiyViYEgFUaFIFqSWJgZRTpL5d8Kanxxl2lQ=="], + "eslint-json-compat-utils": ["eslint-json-compat-utils@0.2.3", "", { "dependencies": { "esquery": "^1.6.0" }, "peerDependencies": { "eslint": "*", "jsonc-eslint-parser": "^2.4.0 || ^3.0.0" } }, "sha512-RbBmDFyu7FqnjE8F0ZxPNzx5UaptdeS9Uu50r7A+D7s/+FCX+ybiyViYEgFUaFIFqSWJgZRTpL5d8Kanxxl2lQ=="], "eslint-merge-processors": ["eslint-merge-processors@2.0.0", "", { "peerDependencies": { "eslint": "*" } }, "sha512-sUuhSf3IrJdGooquEUB5TNpGNpBoQccbnaLHsb1XkBLUPPqCNivCpY05ZcpCOiV9uHwO2yxXEWVczVclzMxYlA=="], @@ -1804,7 +1805,7 @@ "eslint-plugin-jsdoc": ["eslint-plugin-jsdoc@62.9.0", "", { "dependencies": { "@es-joy/jsdoccomment": "~0.86.0", "@es-joy/resolve.exports": "1.2.0", "are-docs-informative": "^0.0.2", "comment-parser": "1.4.6", "debug": "^4.4.3", "escape-string-regexp": "^4.0.0", "espree": "^11.2.0", "esquery": "^1.7.0", "html-entities": "^2.6.0", "object-deep-merge": "^2.0.0", "parse-imports-exports": "^0.2.4", "semver": "^7.7.4", "spdx-expression-parse": "^4.0.0", "to-valid-identifier": "^1.0.0" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" } }, "sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA=="], - "eslint-plugin-jsonc": ["eslint-plugin-jsonc@3.1.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.5.1", "@eslint/core": "^1.0.1", "@eslint/plugin-kit": "^0.6.0", "@ota-meshi/ast-token-store": "^0.3.0", "diff-sequences": "^29.6.3", "eslint-json-compat-utils": "^0.2.3", "jsonc-eslint-parser": "^3.1.0", "natural-compare": "^1.4.0", "synckit": "^0.11.12" }, "peerDependencies": { "eslint": ">=9.38.0" } }, "sha512-dopTxdB22iuOkgKyJCupEC5IYBItUT4J/teq1H5ddUObcaYhOURxtJElZczdcYnnKCghNU/vccuyPkliy2Wxsg=="], + "eslint-plugin-jsonc": ["eslint-plugin-jsonc@3.2.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.5.1", "@eslint/core": "^1.0.1", "@eslint/plugin-kit": "^0.7.0", "@ota-meshi/ast-token-store": "^0.3.0", "diff-sequences": "^29.6.3", "eslint-json-compat-utils": "^0.2.3", "jsonc-eslint-parser": "^3.1.0", "natural-compare": "^1.4.0", "synckit": "^0.11.12" }, "peerDependencies": { "eslint": ">=9.38.0" } }, "sha512-eQSxJypkpNycQAFE/ph/j+bDD2MiCcojxNb+7nugYzuQZvELYg4YO1Cv1y/8MbjPIEw5u3Lx0VPOTlqJJIhPPw=="], "eslint-plugin-n": ["eslint-plugin-n@18.0.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.5.0", "enhanced-resolve": "^5.17.1", "eslint-plugin-es-x": "^7.8.0", "get-tsconfig": "^4.8.1", "globals": "^15.11.0", "globrex": "^0.1.2", "ignore": "^5.3.2", "semver": "^7.6.3" }, "peerDependencies": { "eslint": ">=8.57.1", "ts-declaration-location": "^1.0.6", "typescript": ">=5.0.0" }, "optionalPeers": ["ts-declaration-location", "typescript"] }, "sha512-q3ARhk+eZRc7myR0KHx+R3/GJeOHF+Ir6PK95Pu2tEX8Sl/4BIpmmVLva2kPrjC2gCmn6WHlHm+3yeo6Rxhycw=="], @@ -1818,7 +1819,7 @@ "eslint-plugin-regexp": ["eslint-plugin-regexp@3.1.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", "comment-parser": "^1.4.0", "jsdoc-type-pratt-parser": "^7.0.0", "refa": "^0.12.1", "regexp-ast-analysis": "^0.7.1", "scslre": "^0.3.0" }, "peerDependencies": { "eslint": ">=9.38.0" } }, "sha512-qGXIC3DIKZHcK1H9A9+Byz9gmndY6TTSRkSMTZpNXdyCw2ObSehRgccJv35n9AdUakEjQp5VFNLas6BMXizCZg=="], - "eslint-plugin-toml": ["eslint-plugin-toml@1.3.1", "", { "dependencies": { "@eslint/core": "^1.0.1", "@eslint/plugin-kit": "^0.6.0", "@ota-meshi/ast-token-store": "^0.3.0", "debug": "^4.1.1", "toml-eslint-parser": "^1.0.1" }, "peerDependencies": { "eslint": ">=9.38.0" } }, "sha512-1l00fBP03HIt9IPV7ZxBi7x0y0NMdEZmakL1jBD6N/FoKBvfKxPw5S8XkmzBecOnFBTn5Z8sNJtL5vdf9cpRMQ=="], + "eslint-plugin-toml": ["eslint-plugin-toml@1.4.0", "", { "dependencies": { "@eslint/core": "^1.0.1", "@eslint/plugin-kit": "^0.7.0", "@ota-meshi/ast-token-store": "^0.3.0", "debug": "^4.1.1", "toml-eslint-parser": "^1.0.1" }, "peerDependencies": { "eslint": ">=9.38.0" } }, "sha512-3ErTnfUjXq/23f72XeyRcE0Y4Sd/ME1lsZeezczqpn2R4tE7+Sgco/NUKDXm0xAMz15tzcRz/9RfJRm6AqRO+A=="], "eslint-plugin-unicorn": ["eslint-plugin-unicorn@64.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "@eslint-community/eslint-utils": "^4.9.1", "change-case": "^5.4.4", "ci-info": "^4.4.0", "clean-regexp": "^1.0.0", "core-js-compat": "^3.49.0", "find-up-simple": "^1.0.1", "globals": "^17.4.0", "indent-string": "^5.0.0", "is-builtin-module": "^5.0.0", "jsesc": "^3.1.0", "pluralize": "^8.0.0", "regexp-tree": "^0.1.27", "regjsparser": "^0.13.0", "semver": "^7.7.4", "strip-indent": "^4.1.1" }, "peerDependencies": { "eslint": ">=9.38.0" } }, "sha512-rNZwalHh8i0UfPlhNwg5BTUO1CMdKNmjqe+TgzOTZnpKoi8VBgsW7u9qCHIdpxEzZ1uwrJrPF0uRb7l//K38gA=="], @@ -1826,7 +1827,7 @@ "eslint-plugin-vue": ["eslint-plugin-vue@10.9.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "natural-compare": "^1.4.0", "nth-check": "^2.1.1", "postcss-selector-parser": "^7.1.0", "semver": "^7.6.3", "xml-name-validator": "^4.0.0" }, "peerDependencies": { "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "vue-eslint-parser": "^10.3.0" }, "optionalPeers": ["@stylistic/eslint-plugin", "@typescript-eslint/parser"] }, "sha512-cHB0Tf4Duvzwecwd/AqWzZvF/QszE13BhjVUpVXWCy9AeMR5GjkAjP3i85vqgLgOuTmkHR1OJ5oMeqLHtuw8zg=="], - "eslint-plugin-yml": ["eslint-plugin-yml@3.3.2", "", { "dependencies": { "@eslint/core": "^1.0.1", "@eslint/plugin-kit": "^0.7.0", "@ota-meshi/ast-token-store": "^0.3.0", "diff-sequences": "^29.0.0", "escape-string-regexp": "5.0.0", "natural-compare": "^1.4.0", "yaml-eslint-parser": "^2.0.0" }, "peerDependencies": { "eslint": ">=9.38.0" } }, "sha512-XjmOB/fLBwYHqevnpclPL938V+9ExX7xw1hPaM3IPePGyMFRV1giS16RjSTNhIyCv/Oh0G0PEdmmZPATJ02YCw=="], + "eslint-plugin-yml": ["eslint-plugin-yml@3.4.0", "", { "dependencies": { "@eslint/core": "^1.0.1", "@eslint/plugin-kit": "^0.7.0", "@ota-meshi/ast-token-store": "^0.3.0", "diff-sequences": "^29.0.0", "escape-string-regexp": "5.0.0", "natural-compare": "^1.4.0", "yaml-eslint-parser": "^2.0.0" }, "peerDependencies": { "eslint": ">=9.38.0" } }, "sha512-j6U3ESrAkidkvNb3HFN2UMxke46GNp6bsJokabXCICcgomSy3YU4oED9cjzkZ58nYxWD5qnWV1b/2YlqyWMOxA=="], "eslint-processor-vue-blocks": ["eslint-processor-vue-blocks@2.0.0", "", { "peerDependencies": { "@vue/compiler-sfc": "^3.3.0", "eslint": ">=9.0.0" } }, "sha512-u4W0CJwGoWY3bjXAuFpc/b6eK3NQEI8MoeW7ritKj3G3z/WtHrKjkqf+wk8mPEy5rlMGS+k6AZYOw2XBoN/02Q=="], @@ -1914,7 +1915,7 @@ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], - "follow-redirects": ["follow-redirects@1.16.0", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], @@ -1970,7 +1971,7 @@ "hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="], - "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], @@ -2004,7 +2005,7 @@ "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], - "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="], + "immutable": ["immutable@5.1.6", "", {}, "sha512-q1swsS8K7L8usSHuOqF2TAoCCkonYz0SG38wLAggaa4Wml70zixIvt2ql4coQ2C2B3hTjltJry4r6bULwgAXLQ=="], "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], @@ -2016,7 +2017,7 @@ "ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], - "ink": ["ink@7.0.4", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.3", "auto-bind": "^5.0.1", "chalk": "^5.6.2", "cli-boxes": "^4.0.1", "cli-cursor": "^4.0.0", "cli-truncate": "^6.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.45.1", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^9.0.0", "stack-utils": "^2.0.6", "string-width": "^8.2.0", "terminal-size": "^4.0.1", "type-fest": "^5.5.0", "widest-line": "^6.0.0", "wrap-ansi": "^10.0.0", "ws": "^8.20.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.2.0", "react": ">=19.2.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-4wsM/gMKOT2ZANNTJibI6I9IcwBfobqv/CgaDcwvOaCREZIQxo3iGQS7qPHa2hmA67NYltZWCMtBDELB/mcbJQ=="], + "ink": ["ink@7.0.5", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.3", "auto-bind": "^5.0.1", "chalk": "^5.6.2", "cli-boxes": "^4.0.1", "cli-cursor": "^4.0.0", "cli-truncate": "^6.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.45.1", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^9.0.0", "stack-utils": "^2.0.6", "string-width": "^8.2.0", "terminal-size": "^4.0.1", "type-fest": "^5.5.0", "widest-line": "^6.0.0", "wrap-ansi": "^10.0.0", "ws": "^8.20.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.2.0", "react": ">=19.2.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-zWNjGHQPxSeiSAmDUOq+QPQ6CfmMhmNi85vrJIuy4prafKKUSoZlXEy4wbM7LuLuF1pDURk7qvF4fxrQlLxv3w=="], "ink-spinner": ["ink-spinner@5.0.0", "", { "dependencies": { "cli-spinners": "^2.7.0" }, "peerDependencies": { "ink": ">=4.0.0", "react": ">=18.0.0" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="], @@ -2360,7 +2361,7 @@ "oxc-parser": ["oxc-parser@0.130.0", "", { "dependencies": { "@oxc-project/types": "^0.130.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.130.0", "@oxc-parser/binding-android-arm64": "0.130.0", "@oxc-parser/binding-darwin-arm64": "0.130.0", "@oxc-parser/binding-darwin-x64": "0.130.0", "@oxc-parser/binding-freebsd-x64": "0.130.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.130.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.130.0", "@oxc-parser/binding-linux-arm64-gnu": "0.130.0", "@oxc-parser/binding-linux-arm64-musl": "0.130.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.130.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.130.0", "@oxc-parser/binding-linux-riscv64-musl": "0.130.0", "@oxc-parser/binding-linux-s390x-gnu": "0.130.0", "@oxc-parser/binding-linux-x64-gnu": "0.130.0", "@oxc-parser/binding-linux-x64-musl": "0.130.0", "@oxc-parser/binding-openharmony-arm64": "0.130.0", "@oxc-parser/binding-wasm32-wasi": "0.130.0", "@oxc-parser/binding-win32-arm64-msvc": "0.130.0", "@oxc-parser/binding-win32-ia32-msvc": "0.130.0", "@oxc-parser/binding-win32-x64-msvc": "0.130.0" } }, "sha512-X0PJ+NmOok8qP3vK9uaW431ngkdM9UPEK7KG466urtIL2+EYTEgbZK2yqe2MWKJKBjRlFweP/pJPx0x9muMEVw=="], - "oxc-resolver": ["oxc-resolver@11.19.1", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.19.1", "@oxc-resolver/binding-android-arm64": "11.19.1", "@oxc-resolver/binding-darwin-arm64": "11.19.1", "@oxc-resolver/binding-darwin-x64": "11.19.1", "@oxc-resolver/binding-freebsd-x64": "11.19.1", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-musl": "11.19.1", "@oxc-resolver/binding-openharmony-arm64": "11.19.1", "@oxc-resolver/binding-wasm32-wasi": "11.19.1", "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" } }, "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg=="], + "oxc-resolver": ["oxc-resolver@11.20.0", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.20.0", "@oxc-resolver/binding-android-arm64": "11.20.0", "@oxc-resolver/binding-darwin-arm64": "11.20.0", "@oxc-resolver/binding-darwin-x64": "11.20.0", "@oxc-resolver/binding-freebsd-x64": "11.20.0", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.20.0", "@oxc-resolver/binding-linux-arm-musleabihf": "11.20.0", "@oxc-resolver/binding-linux-arm64-gnu": "11.20.0", "@oxc-resolver/binding-linux-arm64-musl": "11.20.0", "@oxc-resolver/binding-linux-ppc64-gnu": "11.20.0", "@oxc-resolver/binding-linux-riscv64-gnu": "11.20.0", "@oxc-resolver/binding-linux-riscv64-musl": "11.20.0", "@oxc-resolver/binding-linux-s390x-gnu": "11.20.0", "@oxc-resolver/binding-linux-x64-gnu": "11.20.0", "@oxc-resolver/binding-linux-x64-musl": "11.20.0", "@oxc-resolver/binding-openharmony-arm64": "11.20.0", "@oxc-resolver/binding-wasm32-wasi": "11.20.0", "@oxc-resolver/binding-win32-arm64-msvc": "11.20.0", "@oxc-resolver/binding-win32-x64-msvc": "11.20.0" } }, "sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g=="], "oxfmt": ["oxfmt@0.35.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.35.0", "@oxfmt/binding-android-arm64": "0.35.0", "@oxfmt/binding-darwin-arm64": "0.35.0", "@oxfmt/binding-darwin-x64": "0.35.0", "@oxfmt/binding-freebsd-x64": "0.35.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.35.0", "@oxfmt/binding-linux-arm-musleabihf": "0.35.0", "@oxfmt/binding-linux-arm64-gnu": "0.35.0", "@oxfmt/binding-linux-arm64-musl": "0.35.0", "@oxfmt/binding-linux-ppc64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-musl": "0.35.0", "@oxfmt/binding-linux-s390x-gnu": "0.35.0", "@oxfmt/binding-linux-x64-gnu": "0.35.0", "@oxfmt/binding-linux-x64-musl": "0.35.0", "@oxfmt/binding-openharmony-arm64": "0.35.0", "@oxfmt/binding-win32-arm64-msvc": "0.35.0", "@oxfmt/binding-win32-ia32-msvc": "0.35.0", "@oxfmt/binding-win32-x64-msvc": "0.35.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q=="], @@ -2616,6 +2617,8 @@ "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "stack-trace": ["stack-trace@1.0.0-pre2", "", {}, "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A=="], + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], @@ -2640,13 +2643,13 @@ "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], - "supabase": ["supabase@2.101.0", "", { "optionalDependencies": { "@supabase/cli-darwin-arm64": "2.101.0", "@supabase/cli-darwin-x64": "2.101.0", "@supabase/cli-linux-arm64": "2.101.0", "@supabase/cli-linux-arm64-musl": "2.101.0", "@supabase/cli-linux-x64": "2.101.0", "@supabase/cli-linux-x64-musl": "2.101.0", "@supabase/cli-windows-arm64": "2.101.0", "@supabase/cli-windows-x64": "2.101.0" }, "bin": { "supabase": "dist/supabase.js" } }, "sha512-9wqlvXGeI+VjOMUKOGEpcZzTJGLia6IOv8LWN3W2u8F6Xiv4ZqAuTuXCp3MWfkhV5x9KQaBtN5d6IiLEMWzkJA=="], + "supabase": ["supabase@2.102.0", "", { "optionalDependencies": { "@supabase/cli-darwin-arm64": "2.102.0", "@supabase/cli-darwin-x64": "2.102.0", "@supabase/cli-linux-arm64": "2.102.0", "@supabase/cli-linux-arm64-musl": "2.102.0", "@supabase/cli-linux-x64": "2.102.0", "@supabase/cli-linux-x64-musl": "2.102.0", "@supabase/cli-windows-arm64": "2.102.0", "@supabase/cli-windows-x64": "2.102.0" }, "bin": { "supabase": "dist/supabase.js" } }, "sha512-PMoWgoQOb1/mZ3Y+e/HIIvWmEfui20cWELXYURrb6s66Swvo1jnkBmRvtCtSA8lan+aHtkgVQAyekocugle2eg=="], "superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="], + "synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "^0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], @@ -2662,9 +2665,9 @@ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyexec": ["tinyexec@1.2.2", "", {}, "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g=="], + "tinyexec": ["tinyexec@1.2.3", "", {}, "sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ=="], - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], @@ -2736,7 +2739,7 @@ "unplugin-combine": ["unplugin-combine@1.2.1", "", { "peerDependencies": { "@rspack/core": "*", "esbuild": ">=0.13", "rolldown": "*", "rollup": "^3.2.0 || ^4.0.0", "unplugin": "^1.0.0 || ^2.0.0", "vite": "^2.3.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0-0", "webpack": "4 || 5" }, "optionalPeers": ["@rspack/core", "esbuild", "rolldown", "rollup", "unplugin", "vite", "webpack"] }, "sha512-qGkXjQo8yTq5QknP8f8p8/Aw3BJKqclTbTe8de0pC6exHzpoPBnH69Eztf00G2oc50IaIlV7KX/g4cKgzCq9BA=="], - "unplugin-formkit": ["unplugin-formkit@0.3.0", "", { "dependencies": { "pathe": "^1.1.1", "unplugin": "^1.4.0" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-YUN4c3GpOy1hHbHYIAhdtocC8hVw57Wz84mpoSmr2aSpODcmf+n8gMDmNpDX3CYOfZ5W+Pxi0ZqMSWBhvdT2yw=="], + "unplugin-formkit": ["unplugin-formkit@0.3.0", "", { "dependencies": { "pathe": "^1.1.1", "unplugin": "^1.4.0" } }, "sha512-YUN4c3GpOy1hHbHYIAhdtocC8hVw57Wz84mpoSmr2aSpODcmf+n8gMDmNpDX3CYOfZ5W+Pxi0ZqMSWBhvdT2yw=="], "unplugin-icons": ["unplugin-icons@23.0.1", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/utils": "^3.1.0", "local-pkg": "^1.1.2", "obug": "^2.1.1", "unplugin": "^2.3.11" }, "peerDependencies": { "@svgr/core": ">=7.0.0", "@svgx/core": "^1.0.1", "@vue/compiler-sfc": "^3.0.2", "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["@svgr/core", "@svgx/core", "@vue/compiler-sfc", "svelte"] }, "sha512-rv0XEJepajKzDLvRUWASM8K+8+/CCfZn2jtogXqg6RIp7kpatRc/aFrVJn8ANQA09e++lPEEv9yX8cC9enc+QQ=="], @@ -2764,7 +2767,7 @@ "vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="], - "vite-dev-rpc": ["vite-dev-rpc@1.1.0", "", { "dependencies": { "birpc": "^2.4.0", "vite-hot-client": "^2.1.0" }, "peerDependencies": { "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0" } }, "sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A=="], + "vite-dev-rpc": ["vite-dev-rpc@2.0.0", "", { "dependencies": { "birpc": "^4.0.0", "vite-hot-client": "^2.2.0" }, "peerDependencies": { "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0 || ^8.0.0" } }, "sha512-yKwbTwdHKSD2k/aGqyWpPHepo45OQc8lH3/6IfT4ZqeKE26ooKvi4WIEKzqWav8v+9Is8u1k8q54hvOmqASazA=="], "vite-hot-client": ["vite-hot-client@2.2.0", "", { "peerDependencies": { "vite": "^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0" } }, "sha512-76Zs9zrHbH7M7wqeyooGQKdX+yg0pQ0xuQ1PbFp4z5a0Lzn2e5IPFoCswnmqZ4GiwqB4Jo3WcDAMO9jARTJl8w=="], @@ -2772,7 +2775,7 @@ "vite-plugin-environment": ["vite-plugin-environment@1.1.3", "", { "peerDependencies": { "vite": ">= 2.7" } }, "sha512-9LBhB0lx+2lXVBEWxFZC+WO7PKEyE/ykJ7EPWCq95NEcCpblxamTbs5Dm3DLBGzwODpJMEnzQywJU8fw6XGGGA=="], - "vite-plugin-inspect": ["vite-plugin-inspect@11.3.3", "", { "dependencies": { "ansis": "^4.1.0", "debug": "^4.4.1", "error-stack-parser-es": "^1.0.5", "ohash": "^2.0.11", "open": "^10.2.0", "perfect-debounce": "^2.0.0", "sirv": "^3.0.1", "unplugin-utils": "^0.3.0", "vite-dev-rpc": "^1.1.0" }, "peerDependencies": { "@nuxt/kit": "*", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["@nuxt/kit"] }, "sha512-u2eV5La99oHoYPHE6UvbwgEqKKOQGz86wMg40CCosP6q8BkB6e5xPneZfYagK4ojPJSj5anHCrnvC20DpwVdRA=="], + "vite-plugin-inspect": ["vite-plugin-inspect@11.4.1", "", { "dependencies": { "ansis": "^4.3.0", "error-stack-parser-es": "^1.0.5", "obug": "^2.1.1", "ohash": "^2.0.11", "open": "^11.0.0", "perfect-debounce": "^2.1.0", "sirv": "^3.0.2", "unplugin-utils": "^0.3.1", "vite-dev-rpc": "^2.0.0" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0-0 || ^8.0.0-0" } }, "sha512-ShOFe2PURXGvRS5OrgmOLZOCwDTD7dEBVt0tMpFPKb9AsvqXKCRGM8QgKrUbRbJYFXScHvDPpGRd28rYidC0tA=="], "vite-plugin-vue-devtools": ["vite-plugin-vue-devtools@8.1.2", "", { "dependencies": { "@vue/devtools-core": "^8.1.2", "@vue/devtools-kit": "^8.1.2", "@vue/devtools-shared": "^8.1.2", "sirv": "^3.0.2", "vite-plugin-inspect": "^11.3.3", "vite-plugin-vue-inspector": "^6.0.0" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-gt5h1CNryR9Hy0tvhSbqY3j0F7aj0pGxBxWLa1lXSiZVkhdWDf0vbCOZyjh8ivFGE6FDHTGy3zkcZGlMZdVHig=="], @@ -2796,7 +2799,7 @@ "vue-i18n": ["vue-i18n@11.4.4", "", { "dependencies": { "@intlify/core-base": "11.4.4", "@intlify/devtools-types": "11.4.4", "@intlify/shared": "11.4.4", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-gIbXVSFQV4jcSJxfwdZ5zSZmZ+12CnX0K3vBkRSd6Zn+HSzCp+QwUgPwpD/uN0oKNKI9RzlUXPKVedEuMgNG0A=="], - "vue-router": ["vue-router@5.0.7", "", { "dependencies": { "@babel/generator": "^8.0.0-rc.4", "@vue-macros/common": "^3.1.1", "@vue/devtools-api": "^8.1.1", "ast-walker-scope": "^0.8.3", "chokidar": "^5.0.0", "json5": "^2.2.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.0", "muggle-string": "^0.4.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "scule": "^1.3.0", "tinyglobby": "^0.2.15", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1", "yaml": "^2.8.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34", "pinia": "^3.0.4", "vue": "^3.5.34" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia"] }, "sha512-dqfk8kvRbCutmCOCj/XLDqDEYxc1wBdAOGLuVy5M93ifYMsBd5fIjfaPN4tQAbxr5IprdBDIox1gr4wYyOx/SA=="], + "vue-router": ["vue-router@5.1.0", "", { "dependencies": { "@babel/generator": "^8.0.0-rc.4", "@vue-macros/common": "^3.1.1", "@vue/devtools-api": "^8.1.2", "ast-walker-scope": "^0.9.0", "chokidar": "^5.0.0", "json5": "^2.2.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.2", "muggle-string": "^0.4.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "scule": "^1.3.0", "tinyglobby": "^0.2.16", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1", "yaml": "^2.9.0" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34", "pinia": "^3.0.4", "vite": "^7.0.0 || ^8.0.0", "vue": "^3.5.34" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia", "vite"] }, "sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g=="], "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=="], @@ -3028,12 +3031,8 @@ "eslint-plugin-jsdoc/@es-joy/jsdoccomment": ["@es-joy/jsdoccomment@0.86.0", "", { "dependencies": { "@types/estree": "^1.0.8", "@typescript-eslint/types": "^8.58.0", "comment-parser": "1.4.6", "esquery": "^1.7.0", "jsdoc-type-pratt-parser": "~7.2.0" } }, "sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw=="], - "eslint-plugin-jsonc/@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.1", "", { "dependencies": { "@eslint/core": "^1.1.1", "levn": "^0.4.1" } }, "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ=="], - "eslint-plugin-n/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], - "eslint-plugin-toml/@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.1", "", { "dependencies": { "@eslint/core": "^1.1.1", "levn": "^0.4.1" } }, "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ=="], - "eslint-plugin-yml/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -3096,7 +3095,7 @@ "unplugin-vue-macros/unplugin": ["unplugin@1.16.1", "", { "dependencies": { "acorn": "^8.14.0", "webpack-virtual-modules": "^0.6.2" } }, "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w=="], - "vite-plugin-inspect/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "vite-dev-rpc/birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], "vue-i18n/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="], @@ -3244,8 +3243,6 @@ "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "vite-plugin-inspect/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], - "vue-router/@vue-macros/common/ast-kit": ["ast-kit@2.2.0", "", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="], "vue-router/@vue-macros/common/magic-string-ast": ["magic-string-ast@1.0.3", "", { "dependencies": { "magic-string": "^0.30.19" } }, "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA=="], diff --git a/cli/package.json b/cli/package.json index 5b01557549..5407a71eb3 100644 --- a/cli/package.json +++ b/cli/package.json @@ -49,7 +49,7 @@ ], "engines": { "npm": ">=8.0.0", - "node": ">=20.0.0" + "node": ">=22.0.0" }, "scripts": { "build": "tsc && bun build.mjs", @@ -105,11 +105,18 @@ "test:macos-signing": "bun test/test-macos-signing.mjs", "test:apple-api-import-helpers": "bun test/test-apple-api-import-helpers.mjs", "test:manifest-path-encoding": "bun test/test-manifest-path-encoding.mjs", - "test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && 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: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:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && 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-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-markdown", + "test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && 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: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:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && 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-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-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", "test:build-platform-selection": "bun test/test-build-platform-selection.mjs", "test:ai-log-capture": "bun test/test-ai-log-capture.mjs", "test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs", - "test:ai-render-markdown": "bun test/test-ai-render-markdown.mjs" + "test:ai-render-markdown": "bun test/test-ai-render-markdown.mjs", + "test:ai-onboarding-mode": "bun test/test-ai-onboarding-mode.mjs", + "test:ai-fit": "bun test/test-ai-fit.mjs", + "test:platform-layout": "bun test/test-platform-layout.mjs", + "test:frame-fit": "bun test/run-frame-fit.mjs", + "test:onboarding-min-size": "bun test/test-onboarding-min-size.mjs", + "test:min-size-gate": "bun test/test-min-size-gate.mjs", + "test:shell-size-gate": "bun test/test-shell-size-gate.mjs" }, "dependencies": { "@inkjs/ui": "^2.0.0", @@ -142,6 +149,7 @@ "@types/ws": "^8.18.1", "@typescript/native-preview": "7.0.0-dev.20260526.1", "@vercel/ncc": "^0.38.4", + "@xterm/headless": "^6.0.0", "adm-zip": "^0.5.17", "ci-info": "^4.4.0", "commander": "^14.0.3", diff --git a/cli/src/ai/analyze.ts b/cli/src/ai/analyze.ts index b25d2efbf8..8063eaa022 100644 --- a/cli/src/ai/analyze.ts +++ b/cli/src/ai/analyze.ts @@ -1,5 +1,5 @@ import { readFile, stat, writeFile } from 'node:fs/promises' -import { getAiPromptPath, getLogCapturePath } from './log-capture' +import { cleanupCapturedJobFiles, getAiPromptPath, getLogCapturePath } from './log-capture' import { SYSTEM_PROMPT } from './prompt' export type AnalyzeBehavior = 'show_menu' | 'ask_then_menu' | 'auto_upload' | 'skip' @@ -100,3 +100,43 @@ export async function isLogTooBig(jobId: string): Promise { return false } } + +export interface RunCapgoAiAnalysisInput { + apiHost: string + apikey: string + jobId: string + appId: string +} + +// Reads the captured log file for a failed job, then sends it to the Capgo AI +// edge function. Used by callers (e.g. the Ink onboarding TUI) that can't show +// the interactive clack menu in `requestBuildInternal`. +export async function runCapgoAiAnalysis(input: RunCapgoAiAnalysisInput): Promise { + // Check the byte limit before the read so a multi-MB log file doesn't get + // pulled into memory just to be rejected. + if (await isLogTooBig(input.jobId)) + return { kind: 'too_big' } + + let logs: string + try { + logs = await readFile(getLogCapturePath(input.jobId), 'utf8') + } + catch (err) { + return { kind: 'error', message: err instanceof Error ? err.message : 'log_unavailable' } + } + + return postAnalyzeRequest({ + apiHost: input.apiHost, + apikey: input.apikey, + jobId: input.jobId, + appId: input.appId, + logs, + }) +} + +// Best-effort cleanup of captured artifacts for a job. Callers in caller-handled +// mode use this once the user has either viewed the analysis or chosen to skip, +// since `requestBuildInternal` leaves the log file in place for them. +export async function releaseCapturedLogs(jobId: string): Promise { + await cleanupCapturedJobFiles(jobId, { keepAiPromptFile: false }) +} diff --git a/cli/src/ai/telemetry.ts b/cli/src/ai/telemetry.ts index 1610be7509..670054539a 100644 --- a/cli/src/ai/telemetry.ts +++ b/cli/src/ai/telemetry.ts @@ -1,7 +1,7 @@ import { sendEvent } from '../utils.js' -export type AiAnalysisChoice = 'capgo_ai' | 'local_ai' | 'skip' | 'auto_upload' -export type AiAnalysisTriggeredBy = 'menu' | 'ci_flag' +export type AiAnalysisChoice = 'capgo_ai' | 'local_ai' | 'skip' | 'auto_upload' | 'retry' +export type AiAnalysisTriggeredBy = 'menu' | 'ci_flag' | 'onboarding' export type AiAnalysisResult = 'success' | 'already_analyzed' | 'too_big' | 'error' export interface TrackAiAnalysisChoiceInput { diff --git a/cli/src/build/onboarding/ai-fit.ts b/cli/src/build/onboarding/ai-fit.ts new file mode 100644 index 0000000000..f9fd9027ad --- /dev/null +++ b/cli/src/build/onboarding/ai-fit.ts @@ -0,0 +1,211 @@ +/** + * Fit estimation for the AI analysis result step in the onboarding TUI. + * + * The on-failure AI flow can return a multi-screen markdown diagnosis. If + * that text doesn't fit in the user's current terminal viewport we MUST + * route it through the scrollable `FullscreenAiViewer` — otherwise the + * earlier lines scroll out of view and the onboarding wizard ends up in + * an unreadable state. + * + * The estimator deliberately errs on the side of "doesn't fit": a + * false-positive scroll is fine (just one more keystroke for the user), + * but a false-negative inline render is bad UX (text disappears off the + * top of the screen). + */ + +// Rows the inline ai-analysis-result frame spends on chrome AROUND the +// analysis text: compact Header + outer padding + "AI analysis" title + the +// "AI can make mistakes" caution + the retry/skip Select. We route to the +// fullscreen scroll viewer only when the analysis won't fit inline even after +// the frame collapses to its dense (compact) form — so the inline path shows +// the WHOLE analysis whenever the terminal has room. 20 was far too +// conservative: it scrolled even on tall terminals where everything fit. The +// dense + too-small safety net catches anything that still overflows once +// rendered inline, so a tight reserve here is safe. +export const AI_RESULT_CHROME_ROWS = 10 + +// ESC sequence used by `renderMarkdown` and `kleur`/`chalk` to color text. +// The escape byte (0x1B) lives in a private-use region so the regex below +// is exact even for input that includes literal '[' or 'm' bytes. +// eslint-disable-next-line no-control-regex +const ANSI_RE = /\x1B\[[0-9;]*m/g + +/** Strip ANSI SGR escape codes so length matches what the user actually sees. */ +export function stripAnsi(text: string): string { + return text.replace(ANSI_RE, '') +} + +/** + * Estimate how many terminal rows a multi-line, possibly ANSI-styled string + * will occupy when rendered by Ink at the given column width. + * + * Each logical line (split on '\n') becomes `ceil(visibleLen / cols)` rows, + * with a floor of 1 to account for empty lines that still consume a row. + */ +export function estimateRenderedRows(text: string, terminalCols: number): number { + if (!text) + return 0 + const cols = Math.max(1, Math.floor(terminalCols)) + const lines = text.split('\n') + let total = 0 + for (const line of lines) { + const visibleLen = stripAnsi(line).length + total += Math.max(1, Math.ceil(visibleLen / cols)) + } + return total +} + +/** + * Decide whether the AI analysis text should be routed through the + * scrollable fullscreen viewer. Conservative — prefers true (scroll) when + * the estimate is close to the available row budget. + * + * @param text The AI analysis markdown (already rendered to ANSI). + * @param terminalRows Total terminal rows from `useStdout().stdout?.rows`. + * @param terminalCols Total terminal cols from `useStdout().stdout?.columns`. + * @param chromeRows Reserved rows for the surrounding wizard chrome. + * Defaults to `AI_RESULT_CHROME_ROWS`. + */ +export function isAiAnalysisTooTall( + text: string, + terminalRows: number, + terminalCols: number, + chromeRows: number = AI_RESULT_CHROME_ROWS, +): boolean { + if (!text) + return false + const availableRows = Math.max(1, terminalRows - chromeRows) + const estimated = estimateRenderedRows(text, terminalCols) + return estimated > availableRows +} + +// The two AI-analysis-result steps. Both wizards (iOS + Android) use these same +// literal step names, so the routing decision below is platform-agnostic. +export type AiResultStep = 'ai-analysis-result' | 'ai-analysis-result-scroll' + +/** + * Decide which AI-result step should be active for the CURRENT terminal size. + * + * Routing is BIDIRECTIONAL and driven by the single `isAiAnalysisTooTall` + * predicate, so it settles deterministically at any size — at a given size + * exactly one outcome is stable, so it can't oscillate: + * - inline + now too tall (terminal shrank) → scroll + * - scroll + now fits (terminal grew) → inline ← the missing case + * + * Before, only the inline→scroll direction existed: once the viewer opened + * (e.g. after shrinking), growing the terminal never returned to the inline + * render — the user was stuck in the scroll viewer showing "all N lines" with + * empty space. + * + * `viewedFull` (the user manually dismissed the viewer with Esc/Enter) pins the + * inline step so a later resize can't shove a dismissed analysis back into the + * viewer. It only gates the inline→scroll direction; leaving the viewer when it + * fits is always allowed. + * + * @returns the step to switch to, or `null` when the current step is already + * correct (so the caller can skip a no-op `setStep`). + */ +export function resolveAiResultRoute(params: { + current: AiResultStep + text: string | null + viewedFull: boolean + terminalRows: number + terminalCols: number +}): AiResultStep | null { + const { current, text, viewedFull, terminalRows, terminalCols } = params + if (!text) + return null + const tooTall = isAiAnalysisTooTall(text, terminalRows, terminalCols) + if (current === 'ai-analysis-result' && tooTall && !viewedFull) + return 'ai-analysis-result-scroll' + if (current === 'ai-analysis-result-scroll' && !tooTall) + return 'ai-analysis-result' + return null +} + +/** + * Wrap-aware rendered-row count for a single logical line. + * Treats blank/empty lines as one row (Ink still occupies a row for them). + */ +function renderedRowsForLine(line: string, terminalCols: number): number { + const cols = Math.max(1, Math.floor(terminalCols)) + const visibleLen = stripAnsi(line).length + return Math.max(1, Math.ceil(visibleLen / cols)) +} + +/** + * Sum of rendered rows for a list of logical lines. + * + * Used by the scrollable viewer to figure out how many padding rows to + * add below the visible content so the frame height stays constant across + * scroll positions (constant height = Ink renders in-place, no scrollback + * growth on every keystroke). + */ +export function totalRenderedRows(lines: string[], terminalCols: number): number { + let total = 0 + for (const line of lines) + total += renderedRowsForLine(line, terminalCols) + return total +} + +/** + * Pick the slice of `lines` starting at `scrollOffset` that PACKS the + * `viewportRows` rendered rows of a terminal `terminalCols` wide. + * + * Packs lines until the cumulative wrapped row count reaches or exceeds + * `viewportRows`, INCLUDING the line that crosses the boundary. That last line + * may render past the viewport; the viewer clips it with `overflow: hidden` so + * the visible area is always FULL of text when more lines remain. (Stopping + * before the boundary line — the old behaviour — left the unused rows as an + * empty gap when a long line couldn't fully fit.) + * + * Always returns at least one line if the input is non-empty and the + * `scrollOffset` is in-range — even if that line wraps to more rows than the + * viewport. + */ +export function pickVisibleLines( + lines: string[], + scrollOffset: number, + viewportRows: number, + terminalCols: number, +): string[] { + if (lines.length === 0 || scrollOffset >= lines.length) + return [] + const result: string[] = [] + let rowsUsed = 0 + for (let i = scrollOffset; i < lines.length; i++) { + result.push(lines[i]) + rowsUsed += renderedRowsForLine(lines[i], terminalCols) + if (rowsUsed >= viewportRows) + break + } + return result +} + +/** + * Compute the largest `scrollOffset` that still keeps content visible at the + * bottom of the viewport — i.e. the offset where the LAST line is rendered + * within the viewport. Walks backwards from the end, packing as many tail + * lines as fit (accounting for wrap), and returns the offset of the first + * fully-visible tail line. + */ +export function computeMaxScrollOffset( + lines: string[], + viewportRows: number, + terminalCols: number, +): number { + if (lines.length === 0) + return 0 + let rowsUsed = 0 + let kFromEnd = 0 + for (let i = lines.length - 1; i >= 0; i--) { + const rows = renderedRowsForLine(lines[i], terminalCols) + if (kFromEnd > 0 && rowsUsed + rows > viewportRows) + break + rowsUsed += rows + kFromEnd += 1 + if (rowsUsed >= viewportRows) + break + } + return Math.max(0, lines.length - kFromEnd) +} diff --git a/cli/src/build/onboarding/android/types.ts b/cli/src/build/onboarding/android/types.ts index 3ae0d7d902..aac4f72c65 100644 --- a/cli/src/build/onboarding/android/types.ts +++ b/cli/src/build/onboarding/android/types.ts @@ -68,6 +68,11 @@ export type AndroidOnboardingStep | 'writing-workflow-file' | 'ask-build' | 'requesting-build' + // AI debug — only entered when the build fails and logs were captured + | 'ai-analysis-prompt' + | 'ai-analysis-running' + | 'ai-analysis-result' + | 'ai-analysis-result-scroll' | 'build-complete' | 'error' @@ -254,6 +259,10 @@ export const ANDROID_STEP_PROGRESS: Record = { 'writing-workflow-file': 98, 'ask-build': 90, 'requesting-build': 95, + 'ai-analysis-prompt': 96, + 'ai-analysis-running': 98, + 'ai-analysis-result-scroll': 98, + 'ai-analysis-result': 99, 'build-complete': 100, 'error': 0, } @@ -323,6 +332,11 @@ export function getAndroidPhaseLabel(step: AndroidOnboardingStep): string { case 'ask-build': case 'requesting-build': return 'Step 4 of 4 · Save & Build' + case 'ai-analysis-prompt': + case 'ai-analysis-running': + case 'ai-analysis-result': + case 'ai-analysis-result-scroll': + return 'AI debug' case 'build-complete': return 'Complete' case 'error': diff --git a/cli/src/build/onboarding/android/ui/app.tsx b/cli/src/build/onboarding/android/ui/app.tsx index 7bec9094b0..4b5942a3da 100644 --- a/cli/src/build/onboarding/android/ui/app.tsx +++ b/cli/src/build/onboarding/android/ui/app.tsx @@ -19,13 +19,24 @@ import { copyFile, readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join, resolve as resolvePath } from 'node:path' import process from 'node:process' -import { Alert, ProgressBar, Select } from '@inkjs/ui' -import { Box, Newline, Text, useApp, useInput } from 'ink' +import { ProgressBar, Select } from '@inkjs/ui' +import type { DOMElement } from 'ink' +import { Box, measureElement, Newline, Text, useApp, useInput, useStdout } from 'ink' // src/build/onboarding/android/ui/app.tsx import React, { useCallback, useEffect, useRef, useState } from 'react' import { createSupabaseClient, findBuildCommandForProjectType, findProjectType, findSavedKeySilent, getOrganizationId, getPackageScripts, getPMAndCommand } from '../../../../utils.js' import { loadSavedCredentials, updateSavedCredentials } from '../../../credentials.js' +import { releaseCapturedLogs, runCapgoAiAnalysis } from '../../../../ai/analyze.js' +import { renderMarkdown } from '../../../../ai/render-markdown.js' +import { trackAiAnalysisChoice, trackAiAnalysisResult } from '../../../../ai/telemetry.js' import { requestBuildInternal } from '../../../request.js' +import { isAiAnalysisTooTall, resolveAiResultRoute } from '../../ai-fit.js' + +// Upper bound on "I fixed it, retry build" attempts after an AI diagnosis. +// Three total attempts (initial + two retries) caps the AI cost when a model +// suggestion doesn't actually fix the failure mode while still giving the user +// a couple of in-wizard chances to iterate. +const MAX_AI_RETRIES = 2 import { createCiSecretEntries, detectCiSecretTargets, getCiSecretRepoLabelAsync, getCiSecretTargetLabel, listExistingCiSecretKeysAsync, uploadCiSecretsAsync } from '../../ci-secrets.js' import type { CiSecretEntry, CiSecretSetupAdvice, CiSecretTarget } from '../../ci-secrets.js' import { mapAndroidOnboardingError, mapSaValidationKindToCategory } from '../../error-categories.js' @@ -33,10 +44,71 @@ import { defaultExportPath, exportCredentialsToEnv } from '../../env-export.js' import { canUseFilePicker, openKeystorePicker, openServiceAccountJsonPicker } from '../../file-picker.js' import type { BuilderOnboardingAction } from '../../telemetry.js' import { trackBuilderOnboardingAction, trackBuilderOnboardingStep } from '../../telemetry.js' -import { DiffSummary, Divider, ErrorLine, FilteredTextInput, FullscreenDiffViewer, Header, SecretsTable, SpinnerLine, SuccessLine } from '../../ui/components.js' +import { CompletedStepsLog } from '../../ui/completed-steps-log.js' +import { ANDROID_MIN_ROWS, terminalFitsOnboarding } from '../../min-terminal-size.js' +import { sanitizeBuildLogLines } from '../../build-log.js' +import { TerminalTooSmallPrompt } from '../../ui/min-size-gate.js' +import { BOX_HEADER_ROWS, DiffSummary, Divider, FilteredTextInput, FullscreenAiViewer, FullscreenBuildOutput, FullscreenDiffViewer, Header, SecretsTable, SpinnerLine, SuccessLine } from '../../ui/components.js' +import type { AiResultKind } from '../../ui/components.js' +import { logBudgetRows } from '../../ui/frame-fit.js' import { writeWorkflowFile, WORKFLOW_PATH } from '../../workflow-writer.js' import type { BuildScriptChoice, PackageManager } from '../../workflow-generator.js' import type { BuildCredentials } from '../../../../schemas/build.js' +import { + KeystoreExistingAliasSelectStep, + KeystoreExistingAliasStep, + KeystoreExistingDetectingAliasStep, + KeystoreExistingKeyPasswordStep, + KeystoreExistingPathStep, + KeystoreExistingPickerStep, + KeystoreExistingStorePasswordStep, + KeystoreExplainerStep, + KeystoreGeneratingStep, + KeystoreMethodSelectStep, + KeystoreNewAliasStep, + KeystoreNewCommonNameStep, + KeystoreNewKeyPasswordStep, + KeystoreNewPasswordMethodStep, + KeystoreNewStorePasswordStep, +} from '../../ui/steps/android-keystore.js' +import { + AndroidPackageSelectStep, + GcpProjectCreateNameStep, + GcpProjectsLoadingStep, + GcpProjectsSelectStep, + GcpSetupRunningStep, + GoogleSignInLearnMoreStep, + GoogleSignInRunningStep, + GoogleSignInStep, + PlayDeveloperIdActionsStep, + PlayDeveloperIdInputStep, + SaJsonExistingPathStep, + SaJsonExistingPickerStep, + SaJsonValidatingStep, + SaJsonValidationFailedStep, + ServiceAccountMethodSelectStep, +} from '../../ui/steps/android-sa-gcp.js' +import { + AskBuildStep, + AskCiSecretsStep, + CiSecretsFailedStep, + CiSecretsSetupStep, + CiSecretsTargetSelectStep, + ConfirmCiSecretOverwriteStep, + DetectingCiSecretsStep, + SavingCredentialsStep, +} from '../../ui/steps/android-ci.js' +import { + AiAnalysisPromptStep, + AiAnalysisResultStep, + AiAnalysisRunningStep, + BackingUpStep, + BuildCompleteStep, + CredentialsExistStep, + ErrorStep, + NoPlatformStep, + WelcomeStep, +} from '../../ui/steps/android-shared.js' import { findAndroidApplicationIds } from '../gradle-parser.js' import { validateServiceAccountJson } from '../service-account-validation.js' import { diffLines } from '../../diff-utils.js' @@ -91,7 +163,6 @@ interface AppProps { androidDir: string /** Optional Capgo API key passed via -a/--apikey flag; takes precedence over saved key. */ apikey?: string - terminalRows?: number } const RELEASE_ALIAS_DEFAULT = 'release' @@ -128,7 +199,7 @@ function emptyProgress(appId: string): AndroidOnboardingProgress { } } -const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir, apikey, terminalRows = 24 }) => { +const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir, apikey }) => { const { exit } = useApp() const startStep: AndroidOnboardingStep = getAndroidResumeStep(initialProgress) @@ -432,6 +503,16 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir // Phase 6 — build output const [buildUrl, setBuildUrl] = useState('') const [buildOutput, setBuildOutput] = useState([]) + // ── AI-analysis sub-flow (see iOS sibling for full notes). Entered only when + // requestBuildInternal returns aiAnalysis.ready=true on a failed build. + const [aiJobId, setAiJobId] = useState(null) + const [aiAnalysisText, setAiAnalysisText] = useState(null) + // See iOS sibling — non-success outcome banner, mutually exclusive with + // `aiAnalysisText`. One object so kind + message can't drift. + const [aiResult, setAiResult] = useState<{ kind: AiResultKind, message: string } | null>(null) + const [aiRetryCount, setAiRetryCount] = useState(0) + // See iOS sibling for full notes on aiViewedFull. + const [aiViewedFull, setAiViewedFull] = useState(false) const [ciSecretEntries, setCiSecretEntries] = useState([]) const [ciSecretTargets, setCiSecretTargets] = useState([]) const [ciSecretTarget, setCiSecretTarget] = useState(null) @@ -457,6 +538,67 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const [previewTelemetry, setPreviewTelemetry] = useState(null) const [ciSecretError, setCiSecretError] = useState(null) const [ciSecretUploadSummary, setCiSecretUploadSummary] = useState(null) + + // Terminal dimensions in state so the wizard re-renders on resize (see iOS + // sibling — needed for the AI fit check to re-route inline → scroll viewer + // when the user shrinks the terminal). + const { stdout } = useStdout() + const [termSize, setTermSize] = useState<{ rows: number, cols: number }>({ + rows: stdout?.rows ?? 24, + cols: stdout?.columns ?? 80, + }) + useEffect(() => { + if (!stdout) + return + const handler = (): void => setTermSize({ rows: stdout.rows ?? 24, cols: stdout.columns ?? 80 }) + stdout.on('resize', handler) + return () => { + stdout.off('resize', handler) + } + }, [stdout]) + const terminalRows = termSize.rows + const terminalCols = termSize.cols + + // Body heights cached per (step, cols) → adaptive box/compact/too-small, + // decided SYNCHRONOUSLY so a vertical resize doesn't flash. A body's row + // height depends on step/content/WIDTH, not terminal height; caching the + // comfortable height lets a height-resize reuse it and pick the right form on + // the first frame, instead of the old reset→measure→flip round-trip per + // resize tick. See iOS sibling for the full rationale. + const bodyRef = useRef(null) + const [bodyHeights, setBodyHeights] = useState<{ key: string, comfortable: number | null }>( + { key: '', comfortable: null }, + ) + const fitKey = `${step}|${terminalCols}` + const heights = bodyHeights.key === fitKey ? bodyHeights : { key: fitKey, comfortable: null } + + // Always render the comfortable form. The startup size gate (MinSizeGate) + // guarantees the terminal is large enough, so the adaptive dense fallback is + // unreachable. The dense branches in the step components are now dead, and the + // measure machinery below only feeds the completed-steps log budget. + useEffect(() => { + if (!bodyRef.current) + return + const { height } = measureElement(bodyRef.current) + if (height <= 0) + return + setBodyHeights((prev) => { + if (prev.key === fitKey && prev.comfortable === height) + return prev + return { key: fitKey, comfortable: height } + }) + }) + + const bodyHeight = heights.comfortable + + // Rows for the completed-steps log (rendered OUTSIDE the measured body so its + // growth never inflates the fit decision). It fills what the current step + // leaves; capLogRows packs recent entries + a summary. See iOS sibling. The + // header is always boxed (the size gate guarantees room). + const logHeaderRows = BOX_HEADER_ROWS + const logMaxRows = bodyHeight != null + ? logBudgetRows(terminalRows, logHeaderRows, bodyHeight) + : Number.POSITIVE_INFINITY // GitHub Actions workflow setup state. setupMode tracks the 3-way choice at // ask-github-actions-setup. After a successful secrets upload, with-workflow // continues into pick-build-script + writing-workflow-file; secrets-only @@ -493,7 +635,17 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } const addLog = useCallback((text: string, color = 'green') => { - setLogLines(prev => [...prev, { text, color }]) + setLogLines((prev) => { + // Drop a consecutive duplicate: completed-step breadcrumbs are idempotent + // ("✔ GCP project — X" means the same thing however many times the + // hydration replay or a re-render fires it), so the same line twice in a + // row is always spam, never information. Guards against the log filling + // with repeats if an effect re-runs. + const last = prev[prev.length - 1] + if (last && last.text === text && last.color === color) + return prev + return [...prev, { text, color }] + }) }, []) const addSetupStatus = useCallback((text: string) => { @@ -1696,7 +1848,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir error: (msg: string) => setBuildOutput(prev => [...prev, `✖ ${msg}`]), warn: (msg: string) => setBuildOutput(prev => [...prev, `⚠ ${msg}`]), success: (msg: string) => setBuildOutput(prev => [...prev, `✔ ${msg}`]), - buildLog: (msg: string) => setBuildOutput(prev => [...prev, msg]), + buildLog: (msg: string) => setBuildOutput(prev => [...prev, ...sanitizeBuildLogLines(msg)]), uploadProgress: (percent: number) => { setBuildOutput((prev) => { const idx = prev.findIndex(l => l.startsWith('Uploading:')) @@ -1722,6 +1874,11 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const result = await requestBuildInternal(appId, { platform: 'android', apikey: capgoKey, + // The Ink TUI owns the terminal — @clack/prompts inside + // requestBuildInternal would corrupt rendering. Caller-handled mode + // surfaces the captured log path via result.aiAnalysis and lets us + // render the AI flow with Ink-native components. + aiAnalysisMode: 'caller-handled', }, true, buildLogger) if (cancelled) return @@ -1739,6 +1896,13 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } else { setBuildOutput(prev => [...prev, `⚠ ${result.error || 'unknown error'}`]) + // Offer AI-assisted diagnosis when logs were captured. The log file + // stays on disk until releaseCapturedLogs runs in 'build-complete'. + if (result.aiAnalysis?.ready && result.aiAnalysis.jobId) { + setAiJobId(result.aiAnalysis.jobId) + setStep('ai-analysis-prompt') + return + } } setStep('build-complete') } @@ -1752,8 +1916,79 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir })() } + // AI analysis — entered only when requestBuildInternal returned with + // aiAnalysis.ready=true. See iOS sibling for full notes. + if (step === 'ai-analysis-running' && aiJobId) { + ;(async () => { + await trackAiAnalysisChoice({ + apikey: resolvedApiKeyRef.current ?? apikey ?? '', + orgId: resolvedOrgId ?? '', + appId, + platform: 'android', + jobId: aiJobId, + choice: 'capgo_ai', + triggeredBy: 'onboarding', + }).catch(() => { /* telemetry never breaks the wizard */ }) + + const result = await runCapgoAiAnalysis({ + apiHost: 'https://api.capgo.app', + apikey: resolvedApiKeyRef.current ?? apikey ?? '', + jobId: aiJobId, + appId, + }) + + if (cancelled) + return + + const resultTag: 'success' | 'already_analyzed' | 'too_big' | 'error' + = result.kind === 'ok' + ? 'success' + : result.kind === 'already_analyzed' + ? 'already_analyzed' + : result.kind === 'too_big' + ? 'too_big' + : 'error' + + await trackAiAnalysisResult({ + apikey: resolvedApiKeyRef.current ?? apikey ?? '', + orgId: resolvedOrgId ?? '', + appId, + platform: 'android', + jobId: aiJobId, + result: resultTag, + errorStatus: result.kind === 'error' ? result.status : undefined, + }).catch(() => { /* telemetry never breaks the wizard */ }) + + if (result.kind === 'ok') { + setAiAnalysisText(renderMarkdown(result.analysis, true)) + setAiResult(null) + } + else if (result.kind === 'already_analyzed') { + setAiAnalysisText(null) + setAiResult({ kind: 'already_analyzed', message: 'AI analysis was already requested for this build (only one per job).' }) + } + else if (result.kind === 'too_big') { + setAiAnalysisText(null) + setAiResult({ kind: 'too_big', message: 'Build log is too large for Capgo AI (>10 MB). Try a local AI tool with the captured log.' }) + } + else { + setAiAnalysisText(null) + const detail = [ + result.status ? `(status ${result.status})` : null, + result.message, + ].filter(Boolean).join(' ') + setAiResult({ kind: 'error', message: `AI analysis failed${detail ? `: ${detail}` : ''}.` }) + } + setStep('ai-analysis-result') + })() + } + if (step === 'build-complete') { setBuildOutput([]) + // Best-effort cleanup of any leftover captured log. + if (aiJobId) { + void releaseCapturedLogs(aiJobId).catch(() => { /* best-effort */ }) + } const timer = setTimeout(() => { if (!cancelled) exit() @@ -1776,17 +2011,35 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } }, [step]) + // Route between the inline render and the scroll viewer based on the live + // terminal size, BIDIRECTIONALLY (shrink → scroll, grow back → inline). See + // iOS sibling + resolveAiResultRoute for the full rationale. + useEffect(() => { + if (step !== 'ai-analysis-result' && step !== 'ai-analysis-result-scroll') + return + const next = resolveAiResultRoute({ + current: step, + text: aiAnalysisText, + viewedFull: aiViewedFull, + terminalRows, + terminalCols, + }) + if (next) + setStep(next) + }, [step, aiAnalysisText, aiViewedFull, terminalRows, terminalCols]) + const progressPct = ANDROID_STEP_PROGRESS[step] ?? 0 const phaseLabel = getAndroidPhaseLabel(step) - // Steps that need fullscreen room: their content is taller than the wizard - // chrome would leave space for, and Ink can leak previous-frame content on - // transition when the previous frame overflowed. Hide chrome here so the - // tall content fits cleanly. - // GHA flow steps hide Progress + Logs to avoid chrome-in-chrome-out flashing - // between steps. Header stays visible across the whole flow (only - // `requesting-build` hides it, because that step streams live build output). - const tallStep = step === 'requesting-build' - || step === 'detecting-ci-secrets' + // See iOS sibling: conditional Header, visible on every interactive step + // including the AI sub-flow, hidden on `requesting-build`, the scrollable AI + // viewer, and the fullscreen workflow diff so those get the full terminal + // height. + const isAiResultScroll = step === 'ai-analysis-result-scroll' + const isAiStep = step === 'ai-analysis-prompt' || step === 'ai-analysis-running' || step === 'ai-analysis-result' || isAiResultScroll + // Tall fullscreen-style steps from the post-build GitHub Actions / .env + // export flow hide Progress + Logs to avoid chrome-in-chrome-out flashing + // between steps. Header stays visible across the whole flow. + const tallStep = step === 'detecting-ci-secrets' || step === 'checking-ci-secrets' || step === 'ask-github-actions-setup' || step === 'confirm-secrets-push' @@ -1806,13 +2059,62 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir || step === 'overwrite-and-export-env' || step === 'ci-secrets-failed' || step === 'confirm-ci-secret-overwrite' - const showProgress = step !== 'welcome' && step !== 'error' && step !== 'build-complete' && !tallStep - const showHeader = step !== 'requesting-build' && step !== 'view-workflow-diff' - const showLog = step !== 'build-complete' && !tallStep + const showHeader = step !== 'requesting-build' && step !== 'view-workflow-diff' && !isAiResultScroll + const showProgress = step !== 'welcome' && step !== 'error' && step !== 'build-complete' && step !== 'requesting-build' && step !== 'ai-analysis-result' && !isAiResultScroll && !tallStep + const showLog = step !== 'requesting-build' && step !== 'build-complete' && !isAiStep && !tallStep + + // Streaming build output is a fullscreen takeover — see iOS sibling. As an + // early return (before the `tooSmall` guard) it auto-tails inside a viewport + // that always fits, so the unbounded output never trips "terminal too small". + // Size gate (resize-reactive): below the floor, render the resize prompt from + // THIS mounted component so all in-progress state (current step, entered + // values, build output) is preserved — a shrink shows the prompt, a re-grow + // shows the exact same step. Kept as an early return after all hooks so the + // rules of hooks hold. The wizard never clips, and never exits, on resize. + if (!terminalFitsOnboarding(terminalCols, terminalRows, 'android')) + return + + if (step === 'requesting-build') + return + + // (No in-app "terminal too small" guard: the startup MinSizeGate in the shell + // guarantees the terminal is large enough before the wizard mounts, so a + // mid-flow too-small state can't occur.) + + // Fullscreen AI viewer is a takeover — early return so it owns the whole + // terminal and bypasses the body-measurement / dense / too-small logic (see + // iOS sibling). It fills the screen itself via minHeight. + if (isAiResultScroll && aiAnalysisText) + return ( + { + setAiViewedFull(true) + setStep('ai-analysis-result') + }} + /> + ) + + // `minHeight={terminalRows}` fills the viewport so Ink always uses its full + // clear-screen redraw path, which avoids stale rows lingering after the + // terminal shrinks. See iOS sibling for the full explanation. return ( - + {showHeader &&
} - + {/* Banner pinned top; this flex spacer pushes the rest (log + body) to the + bottom. Collapses to zero on a tight terminal (frame-fit unaffected); + absorbs extra rows on a tall one. See iOS sibling. */} + + {/* Completed-steps log — OUTSIDE the measured body, capped to the rows the + current step leaves (see logMaxRows + iOS sibling); CompletedStepsLog + drops its leading gap when it collapses to one line. */} + {showLog && } + {/* Body: the current step (+ progress). Measured via `bodyRef`; the log + above is excluded so the height is independent of completed-step count. */} + {showProgress && ( {phaseLabel} @@ -1828,1025 +2130,674 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir )} - {showLog && logLines.length > 0 && ( - - {logLines.map((entry, i) => ( - {entry.text} - ))} - - )} - - {step === 'welcome' && ( - - - - )} + {step === 'welcome' && } - {step === 'no-platform' && ( - - - - Run npx cap add android first, then re-run onboarding. - - )} + {step === 'no-platform' && } {step === 'credentials-exist' && ( - - ⚠ Android credentials already exist for {appId} - - Onboarding will create new credentials, replacing the existing ones. - - { - if (value === 'learn') { - setStep('keystore-explainer') - } - else if (value === 'existing') { - setKeystoreMethod('existing') - persistAndStep((p) => ({ ...p, keystoreMethod: 'existing' }), 'keystore-existing-path') - } - else { - setKeystoreMethod('generate') - persistAndStep((p) => ({ ...p, keystoreMethod: 'generate' }), 'keystore-new-alias') - } - }} - /> - + { + if (choice === 'learn') { + setStep('keystore-explainer') + } + else if (choice === 'existing') { + setKeystoreMethod('existing') + persistAndStep((p) => ({ ...p, keystoreMethod: 'existing' }), 'keystore-existing-path') + } + else { + setKeystoreMethod('generate') + persistAndStep((p) => ({ ...p, keystoreMethod: 'generate' }), 'keystore-new-alias') + } + }} + /> )} {step === 'keystore-explainer' && ( - - - A keystore is a file that holds a cryptographic key used to sign your Android app. - - - - • Google Play uses the key to verify that every update really came from you. - • You must use the same keystore for every release of this app. - • If you lose it, you lose the ability to publish updates. - • If you've never published this app before, let us create one for you. - - - { - if (value === 'picker') - setStep('keystore-existing-picker') - else - setKeystorePathMode('manual') - }} - /> - - ) - : ( - <> - Tip: drag a file into this window to paste its path. - - { - const cleaned = cleanPath(val) - if (!cleaned) - return - const abs = resolvePath(cleaned) - if (!existsSync(abs)) { - setError(`File not found: ${abs}`) - setRetryStep('keystore-existing-path') - setStep('error') - return - } - setKeystoreExistingPath(abs) - addLog(`✔ Keystore selected · ${abs}`) - persistAndStep((p) => ({ ...p, keystoreExistingPath: abs }), 'keystore-existing-store-password') - }} - /> - - )} - + setStep('keystore-existing-picker')} + onChooseManual={() => setKeystorePathMode('manual')} + onSubmitPath={(val) => { + const cleaned = cleanPath(val) + if (!cleaned) + return + const abs = resolvePath(cleaned) + if (!existsSync(abs)) { + setError(`File not found: ${abs}`) + setRetryStep('keystore-existing-path') + setStep('error') + return + } + setKeystoreExistingPath(abs) + addLog(`✔ Keystore selected · ${abs}`) + persistAndStep((p) => ({ ...p, keystoreExistingPath: abs }), 'keystore-existing-store-password') + }} + /> )} - {step === 'keystore-existing-picker' && ( - - )} + {step === 'keystore-existing-picker' && } {step === 'keystore-existing-store-password' && ( - - Store password: - We'll use this to unlock the keystore and auto-detect the alias. - - { - if (!val) { - setError('Store password cannot be empty') - setRetryStep('keystore-existing-store-password') - setStep('error') - return - } - setKeystoreStorePassword(val) - addLog('✔ Store password set') - persistAndStep((p) => ({ ...p, keystoreStorePassword: val }), 'keystore-existing-detecting-alias') - }} - /> - + { + if (!val) { + setError('Store password cannot be empty') + setRetryStep('keystore-existing-store-password') + setStep('error') + return + } + setKeystoreStorePassword(val) + addLog('✔ Store password set') + persistAndStep((p) => ({ ...p, keystoreStorePassword: val }), 'keystore-existing-detecting-alias') + }} + /> )} - {step === 'keystore-existing-detecting-alias' && ( - - )} + {step === 'keystore-existing-detecting-alias' && } {step === 'keystore-existing-alias-select' && ( - - Multiple aliases in the keystore. Which one do you use for this app? - - { - if (value === 'random') { - const pw = generateRandomPassword() - setKeystoreStorePassword(pw) - setKeystoreKeyPassword(pw) - setRandomPasswordGenerated(true) - addLog('✔ Store + key passwords generated') - persistAndStep((p) => ({ ...p, keystoreStorePassword: pw, keystoreKeyPassword: pw }), 'keystore-new-cn') - } - else { - setStep('keystore-new-store-password') - } - }} - /> - + { + if (choice === 'random') { + const pw = generateRandomPassword() + setKeystoreStorePassword(pw) + setKeystoreKeyPassword(pw) + setRandomPasswordGenerated(true) + addLog('✔ Store + key passwords generated') + persistAndStep((p) => ({ ...p, keystoreStorePassword: pw, keystoreKeyPassword: pw }), 'keystore-new-cn') + } + else { + setStep('keystore-new-store-password') + } + }} + /> )} {step === 'keystore-new-store-password' && ( - - Store password: - - { - if (val.length < 6) { - setError('Password must be at least 6 characters') - setRetryStep('keystore-new-store-password') - setStep('error') - return - } - setKeystoreStorePassword(val) - addLog('✔ Store password set') - persistAndStep((p) => ({ ...p, keystoreStorePassword: val }), 'keystore-new-key-password') - }} - /> - + { + if (val.length < 6) { + setError('Password must be at least 6 characters') + setRetryStep('keystore-new-store-password') + setStep('error') + return + } + setKeystoreStorePassword(val) + addLog('✔ Store password set') + persistAndStep((p) => ({ ...p, keystoreStorePassword: val }), 'keystore-new-key-password') + }} + /> )} {step === 'keystore-new-key-password' && ( - - Key password (press Enter to match store password): - - { - const keyPw = val || keystoreStorePassword - setKeystoreKeyPassword(keyPw) - addLog('✔ Key password set') - persistAndStep((p) => ({ ...p, keystoreKeyPassword: keyPw }), 'keystore-new-cn') - }} - /> - + { + const keyPw = val || keystoreStorePassword + setKeystoreKeyPassword(keyPw) + addLog('✔ Key password set') + persistAndStep((p) => ({ ...p, keystoreKeyPassword: keyPw }), 'keystore-new-cn') + }} + /> )} {step === 'keystore-new-cn' && ( - - Common Name for the certificate (press Enter to use app ID): - Google Play doesn't display this — default is safe. - - { - const cn = val.trim() || appId - setKeystoreCommonName(cn) - addLog(`✔ Common name · ${cn}`) - persistAndStep((p) => ({ ...p, keystoreCommonName: cn }), 'keystore-generating') - }} - /> - + { + const cn = val.trim() || appId + setKeystoreCommonName(cn) + addLog(`✔ Common name · ${cn}`) + persistAndStep((p) => ({ ...p, keystoreCommonName: cn }), 'keystore-generating') + }} + /> )} - {step === 'keystore-generating' && ( - - )} + {step === 'keystore-generating' && } {/* ── Phase 2 — Service account method fork ── */} {step === 'service-account-method-select' && ( - - - Capgo needs a Google Play service account JSON to upload AABs on your behalf. You can bring your own or let Capgo set one up via Google sign-in. - - - Do you already have a service account JSON? - - { - // 'manual' just flips the sub-mode (Select unmounts) and - // is safe from the re-fire bug. 'picker' triggers a step - // transition that takes time — guard against re-fires - // before commit. - if (value === 'picker') { - if (selectFiredRef.current) - return - selectFiredRef.current = true - setStep('sa-json-existing-picker') - } - else { - setSaJsonPathMode('manual') - } - }} - /> - - ) - : ( - <> - Tip: drag a file into this window to paste its path. - - { - const cleaned = cleanPath(val) - if (!cleaned) - return - const abs = resolvePath(cleaned) - if (!existsSync(abs)) { - setError(`File not found: ${abs}`) - setRetryStep('sa-json-existing-path') - setStep('error') - return - } - setServiceAccountJsonPath(abs) - addLog(`✔ Service account JSON · ${abs}`) - persistAndStep( - (p) => ({ ...p, serviceAccountJsonPath: abs }), - 'sa-json-validating', - ) - }} - /> - - )} - + { + // The picker triggers a step transition that takes time — guard + // against the @inkjs/ui re-fire bug before commit. + if (selectFiredRef.current) + return + selectFiredRef.current = true + setStep('sa-json-existing-picker') + }} + onChooseManual={() => { + // 'manual' just flips the sub-mode (Select unmounts) and is safe + // from the re-fire bug. + setSaJsonPathMode('manual') + }} + onSubmitPath={(val) => { + const cleaned = cleanPath(val) + if (!cleaned) + return + const abs = resolvePath(cleaned) + if (!existsSync(abs)) { + setError(`File not found: ${abs}`) + setRetryStep('sa-json-existing-path') + setStep('error') + return + } + setServiceAccountJsonPath(abs) + addLog(`✔ Service account JSON · ${abs}`) + persistAndStep( + (p) => ({ ...p, serviceAccountJsonPath: abs }), + 'sa-json-validating', + ) + }} + /> )} - {step === 'sa-json-existing-picker' && ( - - )} + {step === 'sa-json-existing-picker' && } - {step === 'sa-json-validating' && ( - - - - )} + {step === 'sa-json-validating' && } {step === 'sa-json-validation-failed' && saValidationResult && !saValidationResult.ok && ( - - - Service account validation failed. - - - - {saValidationResult.message} - - - What would you like to do? - - { - if (value === 'go') - setStep('google-sign-in-running') - else if (value === 'learn') - setShowOAuthLearnMore(true) - else - exitOnboarding('Run `capgo build init --platform android` again when ready.') - }} - /> - + { + if (value === 'go') + setStep('google-sign-in-running') + else if (value === 'learn') + setShowOAuthLearnMore(true) + else + exitOnboarding('Run `capgo build init --platform android` again when ready.') + }} + /> )} {step === 'google-sign-in' && showOAuthLearnMore && ( - - - What Capgo can and can't do with the access you're about to grant. - - - - Can Capgo touch other GCP projects on my account? - The scope allows it, but this CLI only calls APIs against the project you'll pick on the next screen. It creates one service account named capgo-native-build in that one project and stops. - - Will Capgo upload anything to Play Store without me knowing? - No. The flow invites one service account into one app (the package you confirm) with release-only permissions. Future builds use that service account, not your OAuth tokens. - - Can Capgo employees access my Google account? - No. The refresh token never leaves your machine. Capgo's servers only serve the OAuth client ID — they never see your tokens. When provisioning finishes, the CLI asks Google to revoke that token, so even your local copy stops working. - - What if I change my mind later? - Revoke anytime at myaccount.google.com/permissions, or just delete the service account in Google Cloud. Neither needs Capgo's involvement. - - Capgo passed Google's OAuth verification on 2026-05-02 for these scopes. Source code: github.com/Cap-go/capgo - - - { - if (value === 'open') { - try { - await open(PLAY_DEVELOPERS_URL) - addLog('🌐 Opened Play Console in your browser', 'cyan') - } - catch { - // Headless / WSL / SSH session — `open` has no display to - // hand off to. Don't pretend it worked. - addLog(`⚠ Couldn't auto-open the browser. Visit ${PLAY_DEVELOPERS_URL} manually.`, 'yellow') - } - setPlayDevIdMode('input') + { + if (value === 'open') { + try { + await open(PLAY_DEVELOPERS_URL) + addLog('🌐 Opened Play Console in your browser', 'cyan') } - else if (value === 'tutorial') { - try { - await open(PLAY_DEV_ID_TUTORIAL_URL) - addLog('🎬 Opened video tutorial in your browser', 'cyan') - } - catch { - addLog(`⚠ Couldn't auto-open the browser. Visit ${PLAY_DEV_ID_TUTORIAL_URL} manually.`, 'yellow') - } - // Stay on the actions screen so the user can still choose - // "Open Play Console" or "I have my developer ID" after - // watching. + catch { + // Headless / WSL / SSH session — `open` has no display to + // hand off to. Don't pretend it worked. + addLog(`⚠ Couldn't auto-open the browser. Visit ${PLAY_DEVELOPERS_URL} manually.`, 'yellow') } - else { - setPlayDevIdMode('input') + setPlayDevIdMode('input') + } + else if (value === 'tutorial') { + try { + await open(PLAY_DEV_ID_TUTORIAL_URL) + addLog('🎬 Opened video tutorial in your browser', 'cyan') } - }} - /> - + catch { + addLog(`⚠ Couldn't auto-open the browser. Visit ${PLAY_DEV_ID_TUTORIAL_URL} manually.`, 'yellow') + } + // Stay on the actions screen so the user can still choose + // "Open Play Console" or "I have my developer ID" after + // watching. + } + else { + setPlayDevIdMode('input') + } + }} + /> )} {step === 'play-developer-id-input' && playDevIdMode === 'input' && ( - - Paste the Play Console URL, or just the developer ID: - Either the whole address bar value or the 16–20 digit number works. - - { - const id = extractDeveloperId(val) - if (!id) { - setError('Could not extract a developer ID. Paste the full Play Console URL or just the numeric ID.') - setRetryStep('play-developer-id-input') - setStep('error') - return - } - const choice: PlayDeveloperAccountChoice = { developerId: id } - setPlayAccountChoice(choice) - addLog(`✔ Play Developer account — ${id}`) - persistAndStep( - (p) => ({ - ...p, - completedSteps: { ...p.completedSteps, playAccountChosen: choice }, - }), - 'gcp-projects-loading', - ) - }} - /> - + { + const id = extractDeveloperId(val) + if (!id) { + setError('Could not extract a developer ID. Paste the full Play Console URL or just the numeric ID.') + setRetryStep('play-developer-id-input') + setStep('error') + return + } + const choice: PlayDeveloperAccountChoice = { developerId: id } + setPlayAccountChoice(choice) + addLog(`✔ Play Developer account — ${id}`) + persistAndStep( + (p) => ({ + ...p, + completedSteps: { ...p.completedSteps, playAccountChosen: choice }, + }), + 'gcp-projects-loading', + ) + }} + /> )} {/* ── Phase 4 — GCP project ── */} - {step === 'gcp-projects-loading' && ( - - )} + {step === 'gcp-projects-loading' && } {step === 'gcp-projects-select' && ( - - Which Google Cloud project should host the service account? - We'll create a `capgo-native-build` service account in the chosen project. - - ({ - label: `📦 ${id}`, - value: id, - })), - { label: '✍️ Type a different package name', value: '__manual__' }, - ]} - onChange={(value) => { - // Mode-switch path unmounts the mounted long enough for the - // bug to spam — gate it with the per-step guard. - if (value === '__manual__') { - setPackageSelectMode('manual') - return - } - if (selectFiredRef.current) - return - selectFiredRef.current = true - const choice: AndroidPackageChoice = { - packageName: value, - source: 'gradle', - } - setAndroidPackageChoice(choice) - addLog(`✔ Android package — ${value}`) - const nextStep: AndroidOnboardingStep - = serviceAccountMethod === 'existing' ? 'sa-json-existing-path' : 'gcp-setup-running' - persistAndStep( - (p) => ({ - ...p, - completedSteps: { ...p.completedSteps, androidPackageChosen: choice }, - }), - nextStep, - ) - }} - /> - - ) - : ( - <> - Android package name: - - { - const name = val.trim() - if (!/^[a-z][\w]*(?:\.[a-z][\w]*)+$/i.test(name)) { - setError(`"${name}" doesn't look like a valid Android package name (e.g. com.example.app).`) - setRetryStep('android-package-select') - setStep('error') - return - } - const choice: AndroidPackageChoice = { - packageName: name, - source: detectedPackageIds.includes(name) ? 'gradle' : 'user-input', - } - setAndroidPackageChoice(choice) - addLog(`✔ Android package — ${name}`) - const nextStep: AndroidOnboardingStep - = serviceAccountMethod === 'existing' ? 'sa-json-existing-path' : 'gcp-setup-running' - persistAndStep( - (p) => ({ - ...p, - completedSteps: { ...p.completedSteps, androidPackageChosen: choice }, - }), - nextStep, - ) - }} - /> - - )} - + 0 && packageSelectMode === 'choose'} + detectedCount={detectedPackageIds.length} + detectedOptions={[ + ...detectedPackageIds.map(id => ({ + label: `📦 ${id}`, + value: id, + })), + { label: '✍️ Type a different package name', value: '__manual__' }, + ]} + onChooseDetected={(value) => { + // Mode-switch path unmounts the + // mounted long enough for the bug to spam — gate it with the + // per-step guard. + if (value === '__manual__') { + setPackageSelectMode('manual') + return + } + if (selectFiredRef.current) + return + selectFiredRef.current = true + const choice: AndroidPackageChoice = { + packageName: value, + source: 'gradle', + } + setAndroidPackageChoice(choice) + addLog(`✔ Android package — ${value}`) + const nextStep: AndroidOnboardingStep + = serviceAccountMethod === 'existing' ? 'sa-json-existing-path' : 'gcp-setup-running' + persistAndStep( + (p) => ({ + ...p, + completedSteps: { ...p.completedSteps, androidPackageChosen: choice }, + }), + nextStep, + ) + }} + onSubmitManual={(val) => { + const name = val.trim() + if (!/^[a-z][\w]*(?:\.[a-z][\w]*)+$/i.test(name)) { + setError(`"${name}" doesn't look like a valid Android package name (e.g. com.example.app).`) + setRetryStep('android-package-select') + setStep('error') + return + } + const choice: AndroidPackageChoice = { + packageName: name, + source: detectedPackageIds.includes(name) ? 'gradle' : 'user-input', + } + setAndroidPackageChoice(choice) + addLog(`✔ Android package — ${name}`) + const nextStep: AndroidOnboardingStep + = serviceAccountMethod === 'existing' ? 'sa-json-existing-path' : 'gcp-setup-running' + persistAndStep( + (p) => ({ + ...p, + completedSteps: { ...p.completedSteps, androidPackageChosen: choice }, + }), + nextStep, + ) + }} + /> )} {step === 'gcp-setup-running' && ( - - - {setupStatus.length > 0 && ( - - {setupStatus.map((msg, i) => ({msg}))} - - )} - + )} {/* ── Phase 6 ── */} {step === 'saving-credentials' && ( - + )} {step === 'detecting-ci-secrets' && ( - + )} {step === 'ci-secrets-setup' && ( - - Set up your git hosting CLI to upload env vars - - {ciSecretSetupAdvice.map(advice => ( - - {advice.target.label} - {advice.message} - {advice.commands.map(command => ( - {command} - ))} - - ))} - Run this in another terminal, then come back here. - - ({ - label: target.provider === 'github' ? 'GitHub Actions repository secrets' : 'GitLab CI/CD variables', - value: target.provider, - })), - { label: 'Skip', value: 'skip' }, - ]} - onChange={(value) => { - if (value === 'skip') { - setStep('build-complete') - return - } - const target = ciSecretTargets.find(candidate => candidate.provider === value) || null - setCiSecretTarget(target) - if (!target) { - setStep('build-complete') - return - } - setStep(target.provider === 'github' ? 'ask-github-actions-setup' : 'ask-ci-secrets') - }} - /> - + ({ + label: target.provider === 'github' ? 'GitHub Actions repository secrets' : 'GitLab CI/CD variables', + value: target.provider, + })), + { label: 'Skip', value: 'skip' }, + ]} + onChange={(value) => { + if (value === 'skip') { + setStep('build-complete') + return + } + const target = ciSecretTargets.find(candidate => candidate.provider === value) || null + setCiSecretTarget(target) + if (!target) { + setStep('build-complete') + return + } + // GitHub routes into the new 3-option GitHub Actions prompt + // (secrets + workflow / secrets-only / no); GitLab keeps the legacy + // 2-option ask-ci-secrets flow. + setStep(target.provider === 'github' ? 'ask-github-actions-setup' : 'ask-ci-secrets') + }} + /> )} {step === 'ask-ci-secrets' && ( - - - - - Upload - {' '} - {ciSecretEntries.length} - {' '} - build env var - {ciSecretEntries.length === 1 ? '' : 's'} - {' '} - to - {' '} - {getCiSecretTargetLabel(ciSecretTarget)} - ? - - Capgo will check for existing names first and ask before replacing anything. - - { - setStep(value === 'replace' ? 'uploading-ci-secrets' : 'build-complete') - }} - /> - + { + setStep(choice === 'replace' ? 'uploading-ci-secrets' : 'build-complete') + }} + /> )} {step === 'uploading-ci-secrets' && ( @@ -3193,133 +3133,129 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir )} {step === 'ci-secrets-failed' && ( - - - - You can continue; credentials are already saved locally. - - { - if (value === 'yes') - setStep('requesting-build') - else - setStep('build-complete') - }} - /> - + { + if (choice === 'yes') + setStep('requesting-build') + else + setStep('build-complete') + }} + /> )} - {step === 'requesting-build' && ( - - {buildOutput.slice(-Math.max(terminalRows - 6, 5)).map((line, i) => ({line}))} - - )} + {/* Requesting build: handled by the FullscreenBuildOutput early return + above — nothing renders here in the measured body. */} {step === 'build-complete' && ( - - - {ciSecretUploadSummary && ( - <> - - {ciSecretUploadSummary}. - - )} - {workflowWrittenPath && ( - <> - - - ✔ Workflow file written: - {' '} - {workflowWrittenPath} - - Dispatch it from GitHub Actions to kick off an Android build. - - )} - {envExportPath && ( - <> - - - ✔ Credentials exported to: - {' '} - {envExportPath} - - - When you're ready, push them with - {' '} - {`gh secret set -f ${envExportPath.split('/').slice(-1)[0]}`} - . Add the file to - {' '} - .gitignore - {' '} - — never commit it. - - - )} - {envExportError && ( - <> - - - ⚠ Could not export .env: - {' '} - {envExportError} - - - )} - {buildUrl && ( - <> - - Track your build: {buildUrl} - - )} - + )} - {step === 'error' && error && retryStep && ( - - - - { - if (value === 'android') { - // The Android flow lives in a separate Ink app — this iOS app - // can't host it inline. Exit cleanly and tell the user to - // re-run with --platform android. - addLog('Re-run with `npx @capgo/cli@latest build init --platform android` to set up Android.', 'cyan') - exitOnboarding() - return - } - // Check for existing credentials before proceeding - const existing = await loadSavedCredentials(appId) - if (existing?.ios) { - setStep('credentials-exist') - } - else if (isMacOS()) { - // macOS users see the fork: import existing or create new - setStep('setup-method-select') - } - else { - // Non-macOS hosts can only create new (importing requires Keychain) - setStep('api-key-instructions') - } - }} - /> - + { + if (value === 'android') { + // The Android flow lives in a separate Ink app — this iOS app + // can't host it inline. Exit cleanly and tell the user to + // re-run with --platform android. + addLog('Re-run with `npx @capgo/cli@latest build init --platform android` to set up Android.', 'cyan') + exitOnboarding() + return + } + // Check for existing credentials before proceeding + const existing = await loadSavedCredentials(appId) + if (existing?.ios) { + setStep('credentials-exist') + } + else if (isMacOS()) { + // macOS users see the fork: import existing or create new + setStep('setup-method-select') + } + else { + // Non-macOS hosts can only create new (importing requires Keychain) + setStep('api-key-instructions') + } + }} + /> )} {/* No platform directory */} {step === 'no-platform' && ( - - - - This onboarding flow needs a generated native iOS project before credentials can be created. - - {`Suggested commands: ${addIosCommand} && ${syncIosCommand}`} - - { - if (value === 'backup') { - setStep('backing-up') - } - else { - addLog('Exiting onboarding.', 'yellow') - exitOnboarding() - } - }} - /> - + { + if (value === 'backup') { + setStep('backing-up') + } + else { + addLog('Exiting onboarding.', 'yellow') + exitOnboarding() + } + }} + /> )} {/* Backing up credentials */} - {step === 'backing-up' && ( - - - - )} + {step === 'backing-up' && } {/* Setup-method fork (macOS only) */} {step === 'setup-method-select' && ( - - - How do you want to set up iOS credentials? - - - { - if (value === '__cancel__') { - setImportMode(false) - // Clear the persisted import-distribution and setupMethod since - // the user is bailing to the create-new path. - const existing = await loadProgress(appId) - if (existing) { - existing.setupMethod = 'create-new' - delete existing.importDistribution - await saveProgress(appId, existing) - } - setStep('api-key-instructions') - return - } - const mode = value as 'app_store' | 'ad_hoc' - setImportDistribution(mode) - // Persist so a CLI restart at any later step (incl. verifying-key - // or saving-credentials) knows we're in app_store vs ad_hoc. - // Codex caught a bug where without this, resumed sessions - // re-entered the create-new path via the stale `importMode=false` - // default — fixed here by hydrating both fields on mount. - const existing = await loadProgress(appId) || { - platform: 'ios' as const, - appId, - startedAt: new Date().toISOString(), - completedSteps: {}, - } - existing.setupMethod = 'import-existing' - existing.importDistribution = mode - await saveProgress(appId, existing) - addLog(`✔ Distribution · ${mode}`) - if (mode === 'app_store') { - // Need .p8 for TestFlight upload AND for any profile auto-recovery. - // After verifying-key the import-mode branch routes back to import-pick-identity. - // Skip the .p8 input chain entirely if the key was already - // verified on a previous attempt (resume) — otherwise we - // re-ask "How do you want to provide the .p8 file?" even - // though APPLE_KEY_CONTENT is already known. Use the same - // routing decision as the post-scan entry point. - setStep(getImportEntryStep(existing)) + { + if (value === '__cancel__') { + setImportMode(false) + // Clear the persisted import-distribution and setupMethod since + // the user is bailing to the create-new path. + const existing = await loadProgress(appId) + if (existing) { + existing.setupMethod = 'create-new' + delete existing.importDistribution + await saveProgress(appId, existing) } - else { - // ad_hoc skips .p8; can opt into it later from no-match recovery. - setStep('import-pick-identity') - } - }} - /> - + setStep('api-key-instructions') + return + } + const mode = value as 'app_store' | 'ad_hoc' + setImportDistribution(mode) + // Persist so a CLI restart at any later step (incl. verifying-key + // or saving-credentials) knows we're in app_store vs ad_hoc. + // Codex caught a bug where without this, resumed sessions + // re-entered the create-new path via the stale `importMode=false` + // default — fixed here by hydrating both fields on mount. + const existing = await loadProgress(appId) || { + platform: 'ios' as const, + appId, + startedAt: new Date().toISOString(), + completedSteps: {}, + } + existing.setupMethod = 'import-existing' + existing.importDistribution = mode + await saveProgress(appId, existing) + addLog(`✔ Distribution · ${mode}`) + if (mode === 'app_store') { + // Need .p8 for TestFlight upload AND for any profile auto-recovery. + // After verifying-key the import-mode branch routes back to import-pick-identity. + // Skip the .p8 input chain entirely if the key was already + // verified on a previous attempt (resume) — otherwise we + // re-ask "How do you want to provide the .p8 file?" even + // though APPLE_KEY_CONTENT is already known. Use the same + // routing decision as the post-scan entry point. + setStep(getImportEntryStep(existing)) + } + else { + // ad_hoc skips .p8; can opt into it later from no-match recovery. + setStep('import-pick-identity') + } + }} + /> )} {/* Import: pick identity */} {step === 'import-pick-identity' && ( - - - Found - {' '} - {importMatches.length} - {' '} - distribution identity - {importMatches.length === 1 ? '' : 'ies'} - {' '} - in your Keychain. Pick one: - - - ({ - label: `📜 ${p.name} · bundle ${p.bundleId} · ${p.profileType} · expires ${p.expirationDate.split('T')[0]}`, - // Key by UUID, NOT path. Disk-discovered profiles have a - // unique path, but Apple-fetched profiles (from the D - // no-match-recovery path) are synthesized with path=''. - // UUID is unique for both kinds: disk profiles use the - // mobileprovision UUID, synthesized ones use Apple's - // profile resource ID. - value: p.uuid, - })), - { label: '↩️ Back to identity selection', value: '__back__' }, - ]} - onChange={(value) => { - if (value === '__back__') { - setStep('import-pick-identity') - return - } - const profile = matchedProfiles.find(p => p.uuid === value) - if (!profile) - return - // Defense in depth: verify bundleId + profileType match before - // committing. The filter above should make this unreachable, - // but if the filter regresses, we'd rather hard-fail than - // silently save bad creds. - if (profile.bundleId !== appId - || (importDistribution && profile.profileType !== importDistribution)) { - handleError( - new Error( - `Profile "${profile.name}" doesn't match this app: ` - + `bundle ${profile.bundleId} (expected ${appId}), ` - + `type ${profile.profileType} (expected ${importDistribution ?? 'any'}).`, - ), - 'import-pick-profile', - ) - return - } - setChosenProfile(profile) - addLog(`✔ Profile · ${profile.name}`) - setStep('import-export-warning') - }} - /> - + ({ + label: `📜 ${p.name} · bundle ${p.bundleId} · ${p.profileType} · expires ${p.expirationDate.split('T')[0]}`, + // Key by UUID, NOT path. Disk-discovered profiles have a + // unique path, but Apple-fetched profiles (from the D + // no-match-recovery path) are synthesized with path=''. + // UUID is unique for both kinds: disk profiles use the + // mobileprovision UUID, synthesized ones use Apple's + // profile resource ID. + value: p.uuid, + })), + { label: '↩️ Back to identity selection', value: '__back__' }, + ]} + onChange={(value) => { + if (value === '__back__') { + setStep('import-pick-identity') + return + } + const profile = matchedProfiles.find(p => p.uuid === value) + if (!profile) + return + // Defense in depth: verify bundleId + profileType match before + // committing. The filter above should make this unreachable, + // but if the filter regresses, we'd rather hard-fail than + // silently save bad creds. + if (profile.bundleId !== appId + || (importDistribution && profile.profileType !== importDistribution)) { + handleError( + new Error( + `Profile "${profile.name}" doesn't match this app: ` + + `bundle ${profile.bundleId} (expected ${appId}), ` + + `type ${profile.profileType} (expected ${importDistribution ?? 'any'}).`, + ), + 'import-pick-profile', + ) + return + } + setChosenProfile(profile) + addLog(`✔ Profile · ${profile.name}`) + setStep('import-export-warning') + }} + /> ) })()} @@ -2059,602 +2368,309 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, t // CAPGO_IOS_DISTRIBUTION='ad_hoc'. Browser + Fetch still work. const canCreateProfile = importDistribution !== 'ad_hoc' return ( - - - No provisioning profile on this Mac is linked to " - {chosenIdentity.name} - ". - - - - The cert is in your Keychain but the matching profile isn't on disk. Pick a recovery path: - - - { - if (value === 'go') { - // First run on this CLI version: compile the Swift helper - // explicitly so the user sees what's happening, instead of - // staring at the "look for the macOS dialog" spinner while - // we silently do a 2-3s swiftc invocation. Cache hit skips - // straight to export. - setStep(isHelperCached() ? 'import-exporting' : 'import-compiling-helper') - } - else if (value === 'back') { - // Back goes to profile selection (distribution mode is now upstream of this step) - setStep('import-pick-profile') - } - else { - exitOnboarding('Exiting. Re-run `build init` whenever you\'re ready.') - } - }} - /> - + { + if (value === 'go') { + // First run on this CLI version: compile the Swift helper + // explicitly so the user sees what's happening, instead of + // staring at the "look for the macOS dialog" spinner while + // we silently do a 2-3s swiftc invocation. Cache hit skips + // straight to export. + setStep(isHelperCached() ? 'import-exporting' : 'import-compiling-helper') + } + else if (value === 'back') { + // Back goes to profile selection (distribution mode is now upstream of this step) + setStep('import-pick-profile') + } + else { + exitOnboarding('Exiting. Re-run `build init` whenever you\'re ready.') + } + }} + /> )} {/* Import: compiling helper (one-time per CLI version) */} - {step === 'import-compiling-helper' && ( - - - - - - We ship a small Swift program (~350 lines) that wraps Apple's - Security framework. It compiles via - {' '} - swiftc - {' '} - into your OS temp folder. - - - The result is cached for this CLI version — future runs of - {' '} - build init - {' '} - skip this step. - - - - )} + {step === 'import-compiling-helper' && } {/* Import: exporting (the one Keychain prompt happens here) */} - {step === 'import-exporting' && ( - - - - If you don't see a dialog, look behind other windows or check the menu bar. - - - )} + {step === 'import-exporting' && } {/* API key instructions + .p8 input */} {step === 'api-key-instructions' && ( - - - We need an App Store Connect API key to manage certificates and profiles for you. - - - - - 1. - {' '} - Go to - {' '} - appstoreconnect.apple.com/access/integrations/api - - - 2. - {' '} - Click - {' '} - "Generate API Key" - - - 3. - {' '} - Name it - {' '} - "Capgo Builder" - {' '} - · Access: - {' '} - "Admin" - - - 4. - {' '} - Download the - {' '} - .p8 - {' '} - file - - - - - Press - Ctrl+O - to open App Store Connect in your browser - - - - - {canUseFilePicker() && ( - <> - How do you want to provide the .p8 file? - - { - const ourCertId = certData?.certificateId || initialProgress?.completedSteps.certificateCreated?.certificateId - const isOurs = ourCertId === c.id - const creator = isOurs ? ' · 🔧 Created by Capgo' : '' - return { - label: `🗑️ ${c.name} · expires ${c.expirationDate.split('T')[0]}${creator}`, - value: c.id, - } - }), - { label: '✖ Exit onboarding', value: '__exit__' }, - ]} - onChange={(value) => { - if (value === '__exit__') { - addLog(`Exiting. Revoke a certificate manually in App Store Connect, then resume with ${buildInitCommand}.`, 'yellow') - exitOnboarding() - } - else { - setCertToRevoke(value) - setStep('revoking-certificate') + { + const ourCertId = certData?.certificateId || initialProgress?.completedSteps.certificateCreated?.certificateId + const isOurs = ourCertId === c.id + const creator = isOurs ? ' · 🔧 Created by Capgo' : '' + return { + label: `🗑️ ${c.name} · expires ${c.expirationDate.split('T')[0]}${creator}`, + value: c.id, } - }} - /> - + }), + { label: '✖ Exit onboarding', value: '__exit__' }, + ]} + onChange={(value) => { + if (value === '__exit__') { + addLog(`Exiting. Revoke a certificate manually in App Store Connect, then resume with ${buildInitCommand}.`, 'yellow') + exitOnboarding() + } + else { + setCertToRevoke(value) + setStep('revoking-certificate') + } + }} + /> )} {/* Revoking certificate */} - {step === 'revoking-certificate' && ( - - - - )} + {step === 'revoking-certificate' && } {/* Creating profile */} - {step === 'creating-profile' && ( - - - - - - )} + {step === 'creating-profile' && } {/* Duplicate profile prompt */} {step === 'duplicate-profile-prompt' && ( - - - - Delete old profiles and create a new one? - - { - setStep(value === 'retry' ? 'detecting-ci-secrets' : 'build-complete') - }} - /> - + { + setStep(value === 'retry' ? 'detecting-ci-secrets' : 'build-complete') + }} + /> )} {step === 'ci-secrets-target-select' && ( - - Where should Capgo upload the build env vars? - - { - setStep(value === 'yes' ? 'checking-ci-secrets' : 'build-complete') - }} - /> - + { + setStep(value === 'yes' ? 'checking-ci-secrets' : 'build-complete') + }} + /> )} {step === 'ask-github-actions-setup' && ( @@ -2974,24 +2990,13 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, t )} {step === 'confirm-ci-secret-overwrite' && ( - - These env vars already exist and will be replaced: - - {ciSecretExistingKeys.map(key => ( - {`• ${key}`} - ))} - - - { - setStep(value === 'retry' ? (ciSecretTarget ? 'checking-ci-secrets' : 'detecting-ci-secrets') : 'build-complete') - }} - /> - + { + setStep(value === 'retry' ? (ciSecretTarget ? 'checking-ci-secrets' : 'detecting-ci-secrets') : 'build-complete') + }} + /> )} {/* Ask to build */} {step === 'ask-build' && ( - - - - Start your first cloud build now? - - { - if (value === 'retry') { - setError(null) - errorCategoryRef.current = undefined - pickerOpenedRef.current = false - setStep(retryStep) - } - else if (value === 'restart') { - // Wipe persisted progress so the next run starts truly fresh. - // Without this, getResumeStep would skip the user back to - // wherever they were — re-triggering the same broken state. - await deleteProgress(appId).catch(() => { /* best-effort */ }) - // Also reset all in-memory import-flow state so a previously- - // chosen identity/profile/distribution doesn't leak across. - setImportMode(false) - setImportMatches([]) - setImportProfiles([]) - setChosenIdentity(null) - setChosenProfile(null) - setImportDistribution(null) - setImportedP12Password('') - setPendingRecoveryAction(null) - setCertData(null) - setProfileData(null) - setError(null) - errorCategoryRef.current = undefined - setRetryCount(0) - pickerOpenedRef.current = false - setSupportBundlePath(null) - addLog('↩️ Onboarding reset — starting fresh', 'yellow') - setStep('welcome') - } - else { - setError(`Run \`${buildInitCommand}\` to resume.`) - exitOnboarding() - } - }} - /> - - )} - + { + if (value === 'retry') { + setError(null) + errorCategoryRef.current = undefined + pickerOpenedRef.current = false + if (retryStep) + setStep(retryStep) + } + else if (value === 'restart') { + // Wipe persisted progress so the next run starts truly fresh. + // Without this, getResumeStep would skip the user back to + // wherever they were — re-triggering the same broken state. + await deleteProgress(appId).catch(() => { /* best-effort */ }) + // Also reset all in-memory import-flow state so a previously- + // chosen identity/profile/distribution doesn't leak across. + setImportMode(false) + setImportMatches([]) + setImportProfiles([]) + setChosenIdentity(null) + setChosenProfile(null) + setImportDistribution(null) + setImportedP12Password('') + setPendingRecoveryAction(null) + setCertData(null) + setProfileData(null) + setError(null) + errorCategoryRef.current = undefined + setRetryCount(0) + pickerOpenedRef.current = false + setSupportBundlePath(null) + addLog('↩️ Onboarding reset — starting fresh', 'yellow') + setStep('welcome') + } + else { + setError(`Run \`${buildInitCommand}\` to resume.`) + exitOnboarding() + } + }} + /> )} {/* Done */} {step === 'build-complete' && ( - - - - - 🎉 You're all set! - - - {buildUrl - ? ( - <> - Your iOS app is building in the cloud. - - Track it at - {' '} - {buildUrl} - - - ) - : ( - Your iOS credentials are saved and ready to use. - )} - - {ciSecretUploadSummary && ( - <> - {ciSecretUploadSummary}. - - - )} - {workflowWrittenPath && ( - <> - - ✔ Workflow file written: - {' '} - {workflowWrittenPath} - - - Dispatch it from GitHub Actions to kick off a build, or run - {' '} - {buildRequestCommand} - {' '} - locally. - - - - )} - {envExportPath && ( - <> - - ✔ Credentials exported to: - {' '} - {envExportPath} - - - When you're ready, push them with - {' '} - {`gh secret set -f ${envExportPath.split('/').slice(-1)[0]}`} - {' '} - (or your CI's equivalent). Add the file to - {' '} - .gitignore - {' '} - — never commit it. - - - - )} - {envExportError && ( - <> - - ⚠ Could not export .env: - {' '} - {envExportError} - - - - )} - - Run - {' '} - {buildRequestCommand} - {' '} - anytime to start a build. - - - - + )} + ) } diff --git a/cli/src/build/onboarding/ui/completed-steps-log.tsx b/cli/src/build/onboarding/ui/completed-steps-log.tsx new file mode 100644 index 0000000000..0fb0c4647b --- /dev/null +++ b/cli/src/build/onboarding/ui/completed-steps-log.tsx @@ -0,0 +1,53 @@ +import type { FC } from 'react' +// src/build/onboarding/ui/completed-steps-log.tsx +// +// The completed-steps log shown between the wizard header and the current step. +// Lives in its own module (not components.tsx) on purpose: it depends on +// capLogRows from frame-fit.ts, and frame-fit.ts already imports the header +// constants FROM components.tsx — so putting this in components.tsx would create +// a components ↔ frame-fit import cycle (and a runtime TDZ crash, since +// frame-fit evaluates those constants at module load). A leaf file consuming +// frame-fit keeps the dependency one-directional. +import { Box, Text } from 'ink' +import React from 'react' +import { capLogRows } from './frame-fit.js' + +// A completed step shown in the wizard's running log. +export interface LogEntry { + text: string + color?: string +} + +// Capped to `maxRows` (see capLogRows): the most recent entries newest-last, +// with a summary line that stands in for older steps that don't fit. That +// summary is NEVER hidden when steps overflow — even at a one-row budget it +// wins the row (shown alone), because hiding "there are more completed steps" +// is worse than not showing the single newest line. +// +// The top-margin gap separates the block from the header — but ONLY when the +// block renders two or more lines. When it collapses to a single line (a lone +// step, or a lone summary), the gap would be orphaned, so it's dropped and the +// line sits directly under the header. +export const CompletedStepsLog: FC<{ entries: LogEntry[], maxRows: number }> = ({ entries, maxRows }) => { + if (maxRows < 1 || entries.length === 0) + return null + const { hidden, visible } = capLogRows(entries, maxRows) + if (hidden === 0 && visible.length === 0) + return null + // "…and N earlier steps done" when concrete steps are shown below it; when the + // budget is so tight only the summary fits, it stands alone as "N steps done". + const summary = visible.length > 0 + ? `…and ${hidden} earlier steps done (resize taller to see all)` + : `${hidden} steps done (resize taller to see all)` + const renderedLines = (hidden > 0 ? 1 : 0) + visible.length + return ( + 1 ? 1 : 0}> + {hidden > 0 && ( + {summary} + )} + {visible.map((entry, i) => ( + {entry.text} + ))} + + ) +} diff --git a/cli/src/build/onboarding/ui/components.tsx b/cli/src/build/onboarding/ui/components.tsx index 2a00ec0265..8d284c65be 100644 --- a/cli/src/build/onboarding/ui/components.tsx +++ b/cli/src/build/onboarding/ui/components.tsx @@ -1,14 +1,50 @@ import type { FC } from 'react' -import { Box, Text, useInput } from 'ink' +import { Box, Text, useInput, useStdout } from 'ink' import Spinner from 'ink-spinner' // src/build/onboarding/ui/components.tsx -import type { DiffLine } from '../diff-utils.js' import React, { useEffect, useState } from 'react' +import { computeMaxScrollOffset, pickVisibleLines } from '../ai-fit.js' +import type { DiffLine } from '../diff-utils.js' export const Divider: FC<{ width?: number }> = ({ width = 60 }) => ( {'─'.repeat(width)} ) +// Rendered row cost of each Header variant + the wizard's outer padding. +// The wizards use these against a live `measureElement` of the step body to +// decide — with no hardcoded height threshold — whether (a) the bordered box +// fits, (b) only the one-line header fits, or (c) not even the one-line +// header + content fits, in which case the resize prompt is shown. +// box = double border (2) + paddingY (2) + text (1) +export const BOX_HEADER_ROWS = 5 +export const COMPACT_HEADER_ROWS = 1 +// The outer wizard uses padding={1} → one row top + one row bottom. +export const WIZARD_PADDING_ROWS = 2 + +// Frame-fit contract. Every rendered frame must fit within MAX_FRAME_ROWS +// terminal rows so the wizard never surprises the user with a "terminal too +// small" block after a step that fit. A frame = adaptive header + body + +// padding; with the one-line compact header the body's row budget is the +// constant below. Each step BODY component is unit-tested (see +// test/helpers/frame-fit.mjs) to render within BODY_BUDGET_ROWS at the +// reference widths, so a too-tall step can never silently regress. +export const MAX_FRAME_ROWS = 16 +export const BODY_BUDGET_ROWS = MAX_FRAME_ROWS - COMPACT_HEADER_ROWS - WIZARD_PADDING_ROWS // 13 + +// Shown in place of the step content when even the one-line header + the +// step's content won't fit the current viewport. Kept to TWO rows with no +// padding: in the alt buffer the TOP of overflowing content is what gets +// clipped, so the fewer rows this occupies the more likely the user sees the +// actionable instruction even on a very short terminal. `neededRows` is the +// measured target height (body + one-line header + padding) so the message +// can tell the user concretely how tall to make the window. +export const TerminalTooSmall: FC<{ rows: number, neededRows: number }> = ({ rows, neededRows }) => ( + + {`⚠ Terminal too small (${rows} row${rows === 1 ? '' : 's'})`} + {`Resize taller — at least ${neededRows} rows — to continue onboarding.`} + +) + export const SpinnerLine: FC<{ text: string }> = ({ text }) => ( @@ -41,6 +77,47 @@ export const ErrorLine: FC<{ text: string }> = ({ text }) => ( ) +// Non-success outcomes of a Capgo AI analysis request. `error` is a genuine +// failure (network/backend); `already_analyzed` and `too_big` are blocking +// "can't proceed" states the backend reports deliberately. +export type AiResultKind = 'already_analyzed' | 'too_big' | 'error' + +// Renders a non-success AI-analysis outcome as a prominent, coloured banner so +// the user reads it as a distinct blocking state instead of mistaking it for +// part of the neutral analysis text (these used to render as a plain +// line and blended in — users couldn't tell the request had been rejected). +// `error` is red (✖); `already_analyzed` / `too_big` are yellow (⚠) since +// they're expected, non-crash outcomes. +// +// Adaptive: the comfortable form (default) is a bordered box — the original, +// most-prominent design. The `dense` form drops the box (saving ~2 rows) for +// terminals too short to fit the boxed version within the 16-row contract; the +// parent sets `dense` only when the measured comfortable frame won't fit. +export const AiResultBanner: FC<{ kind: AiResultKind, message: string, dense?: boolean }> = ({ kind, message, dense = false }) => { + const isError = kind === 'error' + const color = isError ? 'red' : 'yellow' + const icon = isError ? '✖' : '⚠' + const label = kind === 'error' + ? 'Analysis failed' + : kind === 'too_big' + ? 'Build log too large' + : 'Already analyzed' + const inner = ( + <> + {`${icon} ${label}`} + {message} + + ) + if (dense) { + return {inner} + } + return ( + + {inner} + + ) +} + /** * Custom TextInput that filters out specific characters (e.g. '='). * @inkjs/ui's TextInput is uncontrolled and can't filter keystrokes, @@ -86,19 +163,350 @@ export const FilteredTextInput: FC<{ ) } -export const Header: FC = () => ( - - - 🚀 Capgo Cloud Build · Onboarding - - -) +// `compact` collapses the banner to a single borderless line. Callers pass +// it when the terminal is too short (< HEADER_BOX_MIN_ROWS) to spend ~5 rows +// on the bordered box — the branding stays, the vertical cost drops to 1 row. +// The banner is ALWAYS the full boxed form now. The startup size gate +// (min-size-gate.tsx) guarantees enough rows for it, so there's no reason to +// degrade to the one-line variant on short terminals. `compact` is accepted but +// ignored so existing call sites keep compiling; the prop + its arguments are +// removed in the dense-cleanup follow-up. +export const Header: FC<{ compact?: boolean }> = () => { + return ( + + + 🚀 Capgo Cloud Build · Onboarding + + + ) +} + +/** + * Scrollable, fullscreen viewer for the AI build-analysis markdown when it + * is taller than the user's terminal viewport. Mirrors the shape of the + * workflow-file diff viewer on main, but for pre-rendered ANSI lines (no + * `add`/`del` colouring — the markdown renderer already styled them). + * + * Keybindings: + * ↑/k scroll one line up + * ↓/j scroll one line down + * PgUp/u jump up one viewport + * PgDn/d/␣ jump down one viewport + * Home/g jump to top + * End/G jump to bottom + * Esc/Enter dismiss the viewer (returns control to the parent step) + */ +export const FullscreenAiViewer: FC<{ + title: string + subtitle?: string + lines: string[] + terminalRows: number + onExit: () => void +}> = ({ title, subtitle, lines, terminalRows, onExit }) => { + // Track terminal dimensions in state so the component re-renders on resize. + // Without this, the viewport was computed at mount and the body could + // overflow the live screen if the user enlarged or shrank the terminal — + // forcing the user to scroll their terminal emulator to see content the + // viewer should have paginated. + const { stdout } = useStdout() + // Read the live size DIRECTLY each render + force a re-render on resize (same + // reasoning as FullscreenBuildOutput / the shell's useTerminalSize): holding it + // in state lags one frame, so a resize briefly renders at the old size and + // leaves ghost rows on a shrink. + const [, forceResize] = useState(0) + useEffect(() => { + if (!stdout) + return + const onResize = (): void => forceResize(n => n + 1) + stdout.on('resize', onResize) + return () => { + stdout.off('resize', onResize) + } + }, [stdout]) + const dims = { rows: stdout?.rows ?? terminalRows, cols: stdout?.columns ?? 80 } + + // The viewer is a fullscreen takeover: the parent renders it as an early + // return that fills the whole terminal (no outer Header, no wizard padding), + // so its available height is the full terminal. Its own chrome is exactly 6 + // rows — title + optional subtitle + two dividers + position line + exit hint + // — and the rest is the scrollable viewport. (Previously this reserved 10 to + // stay short enough not to trip the parent's body-measurement; now the early + // return bypasses that, so we reserve only the real chrome and a flex spacer + // fills any remainder — no dead space, and more lines visible per screen.) + const VIEWER_CHROME_ROWS = 6 + const viewportRows = Math.max(1, dims.rows - VIEWER_CHROME_ROWS) + const total = lines.length + // Wrap-aware bound: maximum offset that still places the last logical line + // inside the viewport. Without per-line wrap accounting the user could + // scroll past the end on narrow terminals. + const maxScrollOffset = computeMaxScrollOffset(lines, viewportRows, dims.cols) + const [scrollOffset, setScrollOffset] = useState(0) + + // Clamp the scroll if the viewport grew past the bottom (e.g. terminal + // resized larger after the user scrolled to the bottom). + useEffect(() => { + setScrollOffset(prev => Math.min(prev, maxScrollOffset)) + }, [maxScrollOffset]) + + useInput((input, key) => { + if (key.escape || key.return) { + onExit() + return + } + if (key.downArrow || input === 'j') { + setScrollOffset(prev => Math.min(prev + 1, maxScrollOffset)) + return + } + if (key.upArrow || input === 'k') { + setScrollOffset(prev => Math.max(prev - 1, 0)) + return + } + if (key.pageDown || input === 'd' || input === ' ') { + setScrollOffset(prev => Math.min(prev + viewportRows, maxScrollOffset)) + return + } + if (key.pageUp || input === 'u') { + setScrollOffset(prev => Math.max(prev - viewportRows, 0)) + return + } + if (input === 'g') { + setScrollOffset(0) + return + } + if (input === 'G') { + setScrollOffset(maxScrollOffset) + } + }) + + // Wrap-aware visible slice. `pickVisibleLines` stops adding logical lines + // once their cumulative wrapped row count would overflow `viewportRows`, + // so we never render past the bottom of the live terminal. + const visibleLines = pickVisibleLines(lines, scrollOffset, viewportRows, dims.cols) + const firstVisibleLine = total === 0 ? 0 : scrollOffset + 1 + const lastVisibleLine = Math.min(total, scrollOffset + visibleLines.length) + const atBottom = scrollOffset >= maxScrollOffset + // Divider widths scale to the terminal so the cosmetic border doesn't + // wrap on narrow terminals (which would silently eat a viewport row). + const dividerWidth = Math.max(10, Math.min(60, dims.cols - 1)) + + // Suppress every scroll-related hint when the analysis fits the viewport + // outright. The conservative `isAiAnalysisTooTall` estimator in the parent + // sometimes routes us here even though `pickVisibleLines` ends up showing + // every logical line — telling the user to "↑/↓ to scroll" when scrolling + // is a no-op is just noise. The subtitle is also suppressed in that case + // because its only job is to advertise "this is scrollable". + const hasMoreToScroll = maxScrollOffset > 0 + + return ( + // minHeight fills the whole terminal and the flexGrow spacer below pushes + // the bottom divider + hints to the very bottom — so the frame height is + // constant across scroll positions AND there's no dead space, regardless + // of how many lines are currently visible. + + {title} + {subtitle && hasMoreToScroll && {subtitle}} + {'─'.repeat(dividerWidth)} + {/* Fixed-height, clipped content area. `pickVisibleLines` packs it full + (including a line that crosses the bottom), so when more lines remain + the viewport is full of text — no empty gap — and the overflowing + last line is clipped here rather than pushing the footer off-screen. + A fixed `height` (not flexGrow, which has no max and so won't clip) + is what makes overflow:hidden actually trim the excess. */} + + {visibleLines.map((line, index) => ( + // Render empty lines as a single space so they occupy ONE row — + // matching renderedRowsForLine's floor of 1. Ink collapses an empty + // to zero rows, but pickVisibleLines counts each blank as 1; + // that mismatch made the packer stop early and the fixed-height box + // pad the shortfall as blank rows at the bottom (the "gap"), while + // excluding real content below. Keeping blanks 1 row aligns the two. + {line === '' ? ' ' : line} + ))} + + {'─'.repeat(dividerWidth)} + + {hasMoreToScroll + ? `Showing ${firstVisibleLine}-${lastVisibleLine} of ${total} lines. ↑/↓ or PgUp/PgDn to scroll.` + : `Showing all ${total} lines.`} + + + {hasMoreToScroll && !atBottom + ? 'Press Esc or Enter when done to continue.' + : 'Press Esc or Enter to continue to the retry/skip prompt.'} + + + ) +} + +// Pure keypress → scroll/follow transition for the streaming build viewer +// (extracted so the scroll logic is unit-testable without rendering, like +// platformKeyAction). Returns the next { scrollOffset, follow } or null for an +// unhandled key. Scrolling down to the bottom (re)enables follow; any upward +// move pauses it; G jumps to the bottom and follows, g jumps to the top. +export interface BuildScrollState { scrollOffset: number, follow: boolean } +export function buildScrollAction( + input: string, + key: { upArrow?: boolean, downArrow?: boolean, pageUp?: boolean, pageDown?: boolean }, + state: { scrollOffset: number, maxScrollOffset: number, viewportRows: number }, +): BuildScrollState | null { + const { scrollOffset, maxScrollOffset, viewportRows } = state + if (key.downArrow || input === 'j') { + const next = Math.min(scrollOffset + 1, maxScrollOffset) + return { scrollOffset: next, follow: next >= maxScrollOffset } + } + if (key.upArrow || input === 'k') + return { scrollOffset: Math.max(scrollOffset - 1, 0), follow: false } + if (key.pageDown || input === 'd' || input === ' ') { + const next = Math.min(scrollOffset + viewportRows, maxScrollOffset) + return { scrollOffset: next, follow: next >= maxScrollOffset } + } + if (key.pageUp || input === 'u') + return { scrollOffset: Math.max(scrollOffset - viewportRows, 0), follow: false } + if (input === 'g') + return { scrollOffset: 0, follow: false } + if (input === 'G') + return { scrollOffset: maxScrollOffset, follow: true } + return null +} + +// Compact elapsed-time label for the build timer: "42s" under a minute, +// "1m 05s" above (seconds zero-padded so the width is stable). Negative inputs +// clamp to 0s. +export function formatElapsed(ms: number): string { + const totalSec = Math.max(0, Math.floor(ms / 1000)) + const minutes = Math.floor(totalSec / 60) + const seconds = totalSec % 60 + return minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, '0')}s` : `${seconds}s` +} + +// Streaming build-output viewer — a fullscreen takeover (like FullscreenAiViewer) +// the parent renders as an EARLY RETURN so it owns the whole terminal and +// BYPASSES the wizard's body-measurement / dense / too-small logic. The +// `requesting-build` step's output grows unbounded; rendered inside the measured +// body it inflated bodyHeight and tripped the "terminal too small" gate. Here it +// can't: the output lives in a fixed-height viewport that always fits the live +// screen, exactly as the AI analysis viewer paginates tall content. +// +// Follow mode (like `less +F`): by default the viewport tails the stream, +// sticking to the bottom as new lines arrive. Scrolling up (↑/k, PgUp/u) PAUSES +// the tail so earlier output can be read; scrolling back to the bottom (↓/G) +// resumes following. Chrome is two rows (a divider + a status line with the +// spinner, line count, and a follow/scroll hint); the rest is the clipped +// viewport, which resizes with the terminal. +export const FullscreenBuildOutput: FC<{ + title: string + lines: string[] + terminalRows: number +}> = ({ title, lines, terminalRows }) => { + const { stdout } = useStdout() + // Read the live terminal size DIRECTLY each render — Node updates + // stdout.rows/columns BEFORE emitting 'resize' and Ink re-renders on resize, so + // a direct read is already current. The listener only forces a re-render. + // Holding the size in state (setDims on resize) lags one frame: the resize + // re-render runs with the STALE size, so minHeight is briefly the OLD height — + // and on a shrink that over-tall frame overflows the smaller terminal, leaving + // ghost rows until the next frame corrects (the "resize shifts things around" + // glitch). Same pattern as the shell's useTerminalSize. + const [, forceResize] = useState(0) + useEffect(() => { + if (!stdout) + return + const onResize = (): void => forceResize(n => n + 1) + stdout.on('resize', onResize) + return () => { + stdout.off('resize', onResize) + } + }, [stdout]) + const dims = { rows: stdout?.rows ?? terminalRows, cols: stdout?.columns ?? 80 } + + // Live elapsed-time clock so the user sees how long the build has been + // running. Counts from mount (the start of the requesting-build phase) and + // resets if the step remounts on a retry. Ticks independently of follow/scroll. + const [startedAt] = useState(() => Date.now()) + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(id) + }, []) + const elapsed = formatElapsed(now - startedAt) + + const CHROME_ROWS = 2 // bottom divider + status line + const viewportRows = Math.max(1, dims.rows - CHROME_ROWS) + // Each log line renders as exactly ONE row (truncated to the terminal width in + // the viewport below), so the viewport is a plain 1:1 slice — NOT the + // wrap-aware packing the AI viewer needs. We deliberately do not wrap: an + // un-truncated line — e.g. a multi-KB base64 provisioning/key blob streamed in + // the build env — wraps to dozens of rows, which (a) lets one line dominate or + // overflow the viewport, and (b) desyncs Ink's per-line row accounting from + // what the terminal actually draws, leaving stale "dead space" rows that don't + // repaint on stream/scroll/resize. One row per line keeps the frame exactly + // dims.rows tall so Ink always takes its full clear-screen redraw path. A + // tail-follow build log is read vertically, not horizontally, so truncating + // over-long lines is the right model (the full log is also captured to disk). + const maxScrollOffset = Math.max(0, lines.length - viewportRows) + + // `follow` is the SINGLE source of truth for the tail state — never a + // comparison of scrollOffset vs maxScrollOffset. When following, the offset is + // DERIVED as the live maxScrollOffset every render (no chasing useEffect), so a + // newly streamed line can't open a one-frame window where a lagging scrollOffset + // reads as "scrolled up" and flashes the paused hint / flips the alignment. + // `pausedOffset` only matters while paused (clamped so a resize-larger can't + // strand us past the end). + const [follow, setFollow] = useState(true) + const [pausedOffset, setPausedOffset] = useState(0) + const scrollOffset = follow ? maxScrollOffset : Math.min(pausedOffset, maxScrollOffset) + + useInput((input, key) => { + const action = buildScrollAction(input, key, { scrollOffset, maxScrollOffset, viewportRows }) + if (!action) + return + setFollow(action.follow) + setPausedOffset(action.scrollOffset) + }) + + const visibleLines = lines.slice(scrollOffset, scrollOffset + viewportRows) + const dividerWidth = Math.max(10, Math.min(60, dims.cols - 1)) + const hint = maxScrollOffset === 0 + ? '' + : follow + ? ' · ↑ scroll back' + : ' · paused — ↓/G to resume' + + return ( + + {/* Fixed-height clipped viewport. At the bottom (following) the lines are + bottom-aligned — newest just above the status bar, like a terminal + tail; scrolled up they read top-down from the scroll position. Either + way a single over-long wrapped line is clipped rather than pushing the + footer off-screen. */} + + {visibleLines.map((line, index) => { + const isSuccess = line.startsWith('✔') + const isError = line.startsWith('✖') || line.startsWith('❌') + const isWarn = line.startsWith('⚠') + const isBold = line.startsWith('✔ Build') || line.startsWith('✔ Created') || line.startsWith('Uploading:') + const color = isSuccess ? 'green' : isError ? 'red' : isWarn ? 'yellow' : undefined + return ( + + {line === '' ? ' ' : line} + + ) + })} + + {'─'.repeat(dividerWidth)} + + + {` · ${elapsed} (${lines.length} lines)${hint}`} + + + ) +} /** * Minimal bordered table component for the confirm-secrets-push step. diff --git a/cli/src/build/onboarding/ui/frame-fit.ts b/cli/src/build/onboarding/ui/frame-fit.ts new file mode 100644 index 0000000000..470242174f --- /dev/null +++ b/cli/src/build/onboarding/ui/frame-fit.ts @@ -0,0 +1,101 @@ +// Pure frame-fit DECISION logic for the onboarding wizard's adaptive spacing. +// Extracted from the parent `app.tsx` state machine so it can be unit-tested +// independently of Ink rendering (see test/test-frame-fit-decision.mjs). +import { COMPACT_HEADER_ROWS, WIZARD_PADDING_ROWS } from './components.js' + +// Rows the one-line compact header + the wizard's outer padding occupy. +export const COMPACT_HEADER_TOTAL_ROWS = COMPACT_HEADER_ROWS + WIZARD_PADDING_ROWS + +export interface FrameFitInput { + // Measured body height FOR THE CURRENT DENSITY, or null when unknown — before + // the first measure, or right after a density flip when the only measurement + // we have was taken at the other density (stale). + bodyRows: number | null + // Whether the body is currently rendering its compact (dense) form. + dense: boolean + terminalRows: number +} + +// Decide whether to show the "terminal too small" resize prompt. +// +// CRITICAL: only block when we are ALREADY dense — i.e. there is no smaller +// form left to fall back to. When the COMFORTABLE form overflows we must NOT +// block; the parent collapses to dense (see shouldCollapseToDense) and +// re-measures. Blocking while a denser form is still available unmounts the +// body, which kills the measure→re-measure loop and wedges the wizard on the +// resize prompt forever. A null bodyRows means "not yet measured at the +// current density" (pre-measure, or stale right after a flip) → render +// optimistically so the body can be measured; only a terminal too short for +// the one-line header + padding + a single content row is blocked pre-measure. +export function isFrameTooSmall({ bodyRows, dense, terminalRows }: FrameFitInput): boolean { + if (bodyRows == null) + return terminalRows < COMPACT_HEADER_TOTAL_ROWS + 1 + return dense && bodyRows + COMPACT_HEADER_TOTAL_ROWS > terminalRows +} + +// A comfortable (non-dense) body should collapse to its dense form when it +// overflows the viewport even with the one-line compact header. +export function shouldCollapseToDense({ bodyRows, terminalRows }: { bodyRows: number, terminalRows: number }): boolean { + return bodyRows + COMPACT_HEADER_TOTAL_ROWS > terminalRows +} + +// ── Platform picker layout ─────────────────────────────────────────────────── +// The platform picker renders two bordered "cards" side-by-side when there's +// room, else the same vertical Select used elsewhere. Cards need horizontal +// room for two boxes + gap AND vertical room to fit within the frame budget. +export type PlatformPickerLayout = 'cards' | 'list' + +// Two cards (~19 cols each: "Apple App Store" + paddingX(2) + border) + a 3-col +// gap ≈ 41; round up for safety. Below this, stack them as the vertical list. +export const PLATFORM_CARDS_MIN_COLS = 44 +// The cards layout uses the BOXED header (5 rows), so the full frame — +// boxed header + wizard padding + heading + cards + legend — measures ~15 +// rows. Require the whole 16-row contract before showing cards; otherwise the +// alt buffer would clip the top (the banner) instead of falling back. Below +// this, the compact list (boxless header, ~6 rows total) is used. +export const PLATFORM_CARDS_MIN_ROWS = 16 + +export function pickPlatformLayout(cols: number, rows: number): PlatformPickerLayout { + return cols >= PLATFORM_CARDS_MIN_COLS && rows >= PLATFORM_CARDS_MIN_ROWS ? 'cards' : 'list' +} + +// ── Completed-steps log capping ────────────────────────────────────────────── +// The "✔ step done" log grows on every completed step, so left unbounded it +// eventually pushes the current step off-screen (or trips the resize prompt). +// The log is rendered OUTSIDE the measured step body and capped here to the +// rows it's allowed. + +// Rows available for the log: the terminal minus the header, the wizard +// padding, the measured step body, and the log block's own top margin (1). +// Clamped at 0. By construction `logBudgetRows + headerRows + WIZARD_PADDING_ROWS +// + bodyHeight + 1 ≤ terminalRows`, so a log capped to this budget can never +// push the frame past the terminal — i.e. the log can't cause a "too small". +export function logBudgetRows(terminalRows: number, headerRows: number, bodyHeight: number): number { + return Math.max(0, terminalRows - headerRows - WIZARD_PADDING_ROWS - bodyHeight - 1) +} + +export interface CappedLog { + hidden: number + visible: T[] +} + +// Pick the most-recent entries that fit `maxRows`. Each entry occupies EXACTLY +// ONE row (the caller truncates long lines like file paths), so this is a plain +// row count. +// +// When the entries overflow `maxRows`, the summary line ("…and N earlier steps +// done") is MANDATORY — we never hide the fact that more completed steps exist. +// It takes one row; the remaining rows show the most-recent entries (newest- +// last). With `maxRows === 1` the summary is therefore the only line (no +// concrete step shown) rather than a step that silently drops the "there's more" +// indicator. `hidden` is always ≥ 2 in the overflow case (entries.length > +// maxRows ⇒ length − (maxRows − 1) ≥ 2), so we never render a summary for a +// single hidden step ("…and 1 earlier step done"). +export function capLogRows(entries: T[], maxRows: number): CappedLog { + if (maxRows <= 0) + return { hidden: 0, visible: [] } + if (entries.length <= maxRows) + return { hidden: 0, visible: entries } + const visibleCount = maxRows - 1 + return { hidden: entries.length - visibleCount, visible: entries.slice(entries.length - visibleCount) } +} diff --git a/cli/src/build/onboarding/ui/min-size-gate.tsx b/cli/src/build/onboarding/ui/min-size-gate.tsx new file mode 100644 index 0000000000..886843bcda --- /dev/null +++ b/cli/src/build/onboarding/ui/min-size-gate.tsx @@ -0,0 +1,91 @@ +import type { FC, ReactNode } from 'react' +// src/build/onboarding/ui/min-size-gate.tsx +// +// Onboarding's full (comfortable) step forms need a minimum terminal size (see +// min-terminal-size.ts, measured by the VT harness). Below that floor we show a +// resize prompt instead of the wizard; at/above it the wizard renders. This is +// the ONE place onboarding shows "terminal too small". +// +// Two consumers: +// • TerminalTooSmallPrompt — the prompt body. The platform apps render it +// DIRECTLY at the top of their own render when the terminal is too small, so +// the app component STAYS MOUNTED (only its returned JSX swaps). That avoids +// unmounting the wizard on a mid-flow resize — unmounting would tear down +// in-progress step state and (via Ink teardown effects) could exit the whole +// wizard. Keeping it mounted means a shrink shows the prompt and a re-grow +// shows the exact same step, with no lost state and no exit. +// • MinSizeGate — a convenience wrapper (fits ? children : prompt) for callers +// with no precious state to preserve (e.g. the shell's pre-platform picker), +// where unmounting children on resize is harmless. +// +// Both are resize-reactive: callers pass cols/rows from useTerminalSize, which +// re-renders on every resize event. +import process from 'node:process' +import { Box, Text, useInput } from 'ink' +import React from 'react' +import { MIN_COLS, MIN_ROWS, terminalFitsOnboarding } from '../min-terminal-size.js' + +export interface TerminalTooSmallPromptProps { + cols: number + rows: number + /** Minimum columns this screen needs. Defaults to the full onboarding floor; + * the platform picker passes its smaller PICKER_MIN_COLS. */ + minCols?: number + /** Minimum rows this screen needs. Defaults to the full onboarding floor; the + * platform picker passes its smaller PICKER_MIN_ROWS. So the prompt always + * states the floor of the screen that's actually too small (e.g. "11 rows" on + * the picker, not the wizard's 49). */ + minRows?: number +} + +// The "terminal too small" resize prompt. minHeight fills the viewport so Ink +// uses its full clear-screen path (no stale rows from a previous frame on +// resize), and it names whichever dimension is short — against the floor of the +// CALLING screen (minCols/minRows), so the numbers always match what the user is +// looking at. +export const TerminalTooSmallPrompt: FC = ({ cols, rows, minCols = MIN_COLS, minRows = MIN_ROWS }) => { + // Keep a stdin reader alive while the prompt is shown. This is load-bearing: + // on the picker path the ONLY useInput lives in PlatformPicker, so swapping it + // for this prompt would leave Ink with zero input subscribers — under + // alternateScreen + a real TTY that lets waitUntilExit() resolve and the whole + // wizard exits ("✔ onboarding complete" + quit) the instant you shrink past + // the floor. Registering a useInput here keeps Ink reading input, so the + // prompt just sits there until the user resizes back. Ctrl+C still quits. + useInput((input, key) => { + if (key.ctrl && input === 'c') + process.kill(process.pid, 'SIGINT') + }) + const needWider = cols < minCols + const needTaller = rows < minRows + return ( + + 🚀 Capgo Cloud Build · Onboarding + + {`⚠ Terminal too small (${cols}×${rows}).`} + {`This screen needs at least ${minCols}×${minRows} (columns × rows).`} + + {needWider && {`• Widen to at least ${minCols} columns (currently ${cols}).`}} + {needTaller && {`• Make it taller — at least ${minRows} rows (currently ${rows}).`}} + + + Resize this window and onboarding will continue automatically — no need to restart. + + + + ) +} + +export interface MinSizeGateProps { + cols: number + rows: number + children: ReactNode +} + +// fits ? children : prompt. Use only where unmounting `children` on resize is +// harmless (no in-progress state). For the stateful wizard apps, render +// TerminalTooSmallPrompt directly instead (see above). +export const MinSizeGate: FC = ({ cols, rows, children }) => { + if (terminalFitsOnboarding(cols, rows)) + return <>{children} + return +} diff --git a/cli/src/build/onboarding/ui/platform-picker.tsx b/cli/src/build/onboarding/ui/platform-picker.tsx new file mode 100644 index 0000000000..4943484ac4 --- /dev/null +++ b/cli/src/build/onboarding/ui/platform-picker.tsx @@ -0,0 +1,111 @@ +import type { FC } from 'react' +import type { Platform } from '../types.js' +import type { PlatformPickerLayout } from './frame-fit.js' +// src/build/onboarding/ui/platform-picker.tsx +// +// The "Which platform do you want to set up?" picker, rendered INSIDE the +// alt-screen wizard (by OnboardingShell). Responsive: +// • `cards` — two bordered cards side-by-side; ←/→ (or 1/2) move the +// selection, Enter confirms. Used when the terminal has room. +// • `list` — the same @inkjs/ui Select used everywhere else; used on narrow +// or short terminals. The layout is chosen by the shell via +// `pickPlatformLayout` so this component stays pure (props in → JSX out). +import { Select } from '@inkjs/ui' +import { Box, Text, useInput } from 'ink' +import React, { useState } from 'react' + +// Pure mapping from a keypress to a picker action (extracted so the +// arrow/Enter logic is unit-testable without rendering). ←/h/1 → iOS, +// →/l/2 → Android, Enter → confirm the current selection. +export type PlatformKeyAction + = | { type: 'select', platform: Platform } + | { type: 'confirm' } + | null + +export function platformKeyAction( + input: string, + key: { leftArrow?: boolean, rightArrow?: boolean, return?: boolean }, +): PlatformKeyAction { + if (key.return) + return { type: 'confirm' } + if (key.leftArrow || input === 'h' || input === '1') + return { type: 'select', platform: 'ios' } + if (key.rightArrow || input === 'l' || input === '2') + return { type: 'select', platform: 'android' } + return null +} + +interface PlatformCardProps { + emoji: string + name: string + hint: string + selected: boolean +} + +const PlatformCard: FC = ({ emoji, name, hint, selected }) => ( + + {`${emoji} ${name}`} + {hint} + +) + +export interface PlatformPickerProps { + layout: PlatformPickerLayout + onSelect: (platform: Platform) => void +} + +export const PlatformPicker: FC = ({ layout, onSelect }) => { + const [selected, setSelected] = useState('ios') + + // Arrow/Enter driving for the cards layout. In list layout the @inkjs/ui + // Select owns input, so this handler no-ops (it stays registered to satisfy + // the rules of hooks, but ignores keys). + useInput((input, key) => { + if (layout !== 'cards') + return + const action = platformKeyAction(input, key) + if (!action) + return + if (action.type === 'select') + setSelected(action.platform) + else + onSelect(selected) + }) + + if (layout === 'list') { + return ( + + Which platform do you want to set up? + onChoose(value as 'retry' | 'skip')} + /> + + ) +} + +// ── ci-secrets-target-select ────────────────────────────────────────────────── +// The parent builds the option list (one row per detected target + a "Skip" +// row) and owns the route handler. Comfortable: the original bold heading + a +// + the un-capped Select (in practice there are at most two +// providers + skip). Dense: the blank line is dropped and visibility is capped +// via Select's `visibleOptionCount` with a "+N more" hint so it can never blow +// the budget. + +export interface CiSecretsTargetSelectStepProps { + options: SelectOption[] + onChange: (value: string) => void + dense?: boolean +} + +export const CiSecretsTargetSelectStep: FC = ({ options, onChange, dense = false }) => { + return ( + + Where should Capgo upload the build env vars? + + onChoose(value as 'yes' | 'no')} + /> + +) + +// ── checking-ci-secrets (spinner) ───────────────────────────────────────────── + +export interface CheckingCiSecretsStepProps { + targetLabel: string +} + +export const CheckingCiSecretsStep: FC = ({ targetLabel }) => ( + +) + +// ── confirm-ci-secret-overwrite ─────────────────────────────────────────────── +// `existingKeys` are the env-var names already present on the target that the +// upload would replace. Comfortable: the original listed every key indented +// under the heading (in a `marginTop={1}` box) with a before the +// Select (the original look — rendered only after the parent measured it fits). +// Dense: the box's top margin and the are dropped and only the last +// few keys are shown with a "… +N more" line above them, so a realistic 6+-key +// list can't push the heading, list or replace/skip control off-screen. + +export interface ConfirmCiSecretOverwriteStepProps { + existingKeys: string[] + onChoose: (choice: 'replace' | 'skip') => void + dense?: boolean +} + +export const ConfirmCiSecretOverwriteStep: FC = ({ existingKeys, onChoose, dense = false }) => { + return ( + + These env vars already exist and will be replaced: + + {existingKeys.map(key => ( + {`• ${key}`} + ))} + + + onChoose(value as 'retry' | 'continue')} + /> + +) + +// ── ask-build ───────────────────────────────────────────────────────────────── +// Final prompt of the Android flow. Comfortable: the original success line + a +// + the bold "Request a build now?" prompt + a + the +// yes/no Select. Dense: both s are dropped so the success line, +// prompt and control fit within budget. + +export interface AskBuildStepProps { + onChoose: (choice: 'yes' | 'no') => void + dense?: boolean +} + +export const AskBuildStep: FC = ({ onChoose, dense = false }) => ( + + + {!dense && } + Request a build now? + {!dense && } + onChoose(value as KeystoreMethodChoice)} + /> + +) + +// ── keystore-explainer ─────────────────────────────────────────────────────── + +export interface KeystoreExplainerStepProps { + onBack: () => void + dense?: boolean +} + +// Comfortable: the original info Alert + a + four indented, +// full-sentence bullets in a marginLeft box + a + the Back control. +// Dense: the Alert/box/blank-lines are dropped in favour of terse single-line +// bullets so every line stays un-wrapped within the 13-row budget at 60 cols +// (the original wrapping bullets blew the budget there). +export const KeystoreExplainerStep: FC = ({ onBack, dense = false }) => { + return ( + + + A keystore is a file that holds a cryptographic key used to sign your Android app. + + + + • Google Play uses the key to verify that every update really came from you. + + • You must use the + {' '} + same + {' '} + keystore for every release of this app. + + • If you lose it, you lose the ability to publish updates. + • If you've never published this app before, let us create one for you. + + + { + if (value === 'picker') + onChoosePicker() + else + onChooseManual() + }} + /> + + ) + : ( + <> + Tip: drag a file into this window to paste its path. + {!dense && } + + + )} + +) + +// ── keystore-existing-picker ───────────────────────────────────────────────── + +export const KeystoreExistingPickerStep: FC = () => ( + +) + +// ── keystore-existing-store-password ───────────────────────────────────────── + +export interface KeystoreExistingStorePasswordStepProps { + onSubmit: (value: string) => void + dense?: boolean +} + +// Comfortable: bold label, the dim helper line, a , then the masked +// input (the original look). Dense: the blank line is dropped. +export const KeystoreExistingStorePasswordStep: FC = ({ onSubmit, dense = false }) => ( + + Store password: + We'll use this to unlock the keystore and auto-detect the alias. + {!dense && } + + +) + +// ── keystore-existing-detecting-alias ──────────────────────────────────────── + +export const KeystoreExistingDetectingAliasStep: FC = () => ( + +) + +// ── keystore-existing-alias-select ─────────────────────────────────────────── + +export interface KeystoreExistingAliasSelectStepProps { + aliases: string[] + onSelect: (alias: string) => void + dense?: boolean +} + +// The original full heading + a + an UN-capped Select (shows every +// alias — the parent only renders this after measuring it fits the viewport). +export const KeystoreExistingAliasSelectStep: FC = ({ aliases, onSelect, dense = false }) => { + return ( + + Multiple aliases in the keystore. Which one do you use for this app? + + onChoose(value as KeystorePasswordMethodChoice)} + /> + +) + +// ── keystore-new-store-password ────────────────────────────────────────────── + +export interface KeystoreNewStorePasswordStepProps { + onSubmit: (value: string) => void + dense?: boolean +} + +// Comfortable: bold label + a + the masked input. Dense: the blank +// line is dropped. +export const KeystoreNewStorePasswordStep: FC = ({ onSubmit, dense = false }) => ( + + Store password: + {!dense && } + + +) + +// ── keystore-new-key-password ──────────────────────────────────────────────── + +export interface KeystoreNewKeyPasswordStepProps { + onSubmit: (value: string) => void + dense?: boolean +} + +// Comfortable: bold label + a + the masked input. Dense: the blank +// line is dropped. +export const KeystoreNewKeyPasswordStep: FC = ({ onSubmit, dense = false }) => ( + + Key password (press Enter to match store password): + {!dense && } + + +) + +// ── keystore-new-cn ────────────────────────────────────────────────────────── + +export interface KeystoreNewCommonNameStepProps { + /** Placeholder shown for the default (the app id). */ + appId: string + onSubmit: (value: string) => void + dense?: boolean +} + +// Comfortable: the full label + the dim helper line + a + the input. +// Dense: the blank line is dropped and the label trimmed. +export const KeystoreNewCommonNameStep: FC = ({ appId, onSubmit, dense = false }) => ( + + + {dense + ? 'Common Name for the certificate (Enter to use app ID):' + : 'Common Name for the certificate (press Enter to use app ID):'} + + Google Play doesn't display this — default is safe. + {!dense && } + + +) + +// ── keystore-generating ────────────────────────────────────────────────────── + +export const KeystoreGeneratingStep: FC = () => ( + +) diff --git a/cli/src/build/onboarding/ui/steps/android-sa-gcp.tsx b/cli/src/build/onboarding/ui/steps/android-sa-gcp.tsx new file mode 100644 index 0000000000..29c0767dc7 --- /dev/null +++ b/cli/src/build/onboarding/ui/steps/android-sa-gcp.tsx @@ -0,0 +1,574 @@ +// src/build/onboarding/ui/steps/android-sa-gcp.tsx +// +// Pure presentational step bodies for the Android service-account / Google +// sign-in / GCP project / Play-developer sub-flow of the `build init` +// onboarding wizard (Phase 2–5 of android/ui/app.tsx). Each component is +// "props in → JSX out": every dynamic value and event handler is an explicit, +// typed prop. The parent wizard owns all state, routing, async work, telemetry +// and terminal-size measurement; these components never touch +// `useStdout` / `measureElement`. `useInput` inside the shared FilteredTextInput +// widget is fine — that's a leaf control, not layout measurement. +// +// Adaptive spacing — each body renders its COMFORTABLE form by default (the +// original design: bordered Alert banners where applicable + decorative +// blank-line spacing + full multi-line copy + un-capped lists). The +// 16-row frame contract (see ui/components.tsx + test/helpers/frame-fit.mjs) is +// a FLOOR we must survive on short terminals, not a cap on every terminal: when +// the parent measures that the comfortable body can't fit the viewport it flips +// the sticky `dense` signal and threads `dense={true}` here, collapsing each +// body to the terse, budget-fitting form (blank lines dropped, banners reduced +// to single-line copy, the full project/package lists capped via Select's +// `visibleOptionCount` with a "+N more" hint, and the variable-length live +// status streams tailed to the last few lines). `dense` defaults to `false` so +// a component rendered without the prop (e.g. a test asserting the comfortable +// form) gets the original look. All props/handlers/behaviour are identical +// across both modes. +import type { FC } from 'react' +import { Alert, Select } from '@inkjs/ui' +import { Box, Newline, Text } from 'ink' +import React from 'react' +import { FilteredTextInput, SpinnerLine } from '../components.js' + +// A single Select option. Mirrors the shape @inkjs/ui's Select expects so the +// parent can build dynamic option lists and pass them straight through. +export interface SelectOption { + label: string + value: string +} + +// How many trailing lines of a live status stream to show in DENSE mode. The +// comfortable form prints the whole stream; the dense form tails it so a long +// stream can never push the spinner / frame past budget. +const STATUS_TAIL = 4 + +// ── service-account-method-select ──────────────────────────────────────────── + +export type ServiceAccountMethodChoice = 'existing' | 'generate' + +export interface ServiceAccountMethodSelectStepProps { + onChoose: (choice: ServiceAccountMethodChoice) => void + dense?: boolean +} + +// Comfortable: the info Alert (full copy), a , the bold question, +// another , then the Select. Dense: the blank lines are dropped and +// the Alert copy trimmed so the prompt + two choices stay within budget. +export const ServiceAccountMethodSelectStep: FC = ({ onChoose, dense = false }) => ( + + + {dense + ? 'Capgo needs a Google Play service account JSON to upload AABs. Bring your own, or let Capgo set one up via Google.' + : 'Capgo needs a Google Play service account JSON to upload AABs on your behalf. You can bring your own or let Capgo set one up via Google sign-in.'} + + {!dense && } + Do you already have a service account JSON? + {!dense && } + { + if (value === 'picker') + onChoosePicker() + else + onChooseManual() + }} + /> + + ) + : ( + <> + Tip: drag a file into this window to paste its path. + {!dense && } + + + )} + +) + +// ── sa-json-existing-picker (spinner) ───────────────────────────────────────── +// Single spinner line — identical comfortable / dense (no spacing to collapse). + +export const SaJsonExistingPickerStep: FC = () => ( + +) + +// ── sa-json-validating (spinner) ────────────────────────────────────────────── +// Single spinner line — identical comfortable / dense. + +export const SaJsonValidatingStep: FC = () => ( + + + +) + +// ── sa-json-validation-failed ───────────────────────────────────────────────── +// `message` is the backend/validation failure detail (can be long — e.g. a +// no-app-access explanation). Comfortable: the original warning Alert + the +// indented full message + a + the bold "What would you like to do?" +// prompt + a + the un-capped Select(3) (the original look — rendered +// only after the parent measured it fits). Dense: the Alert / blank lines are +// dropped — the failure is conveyed by a single red ✖-style line above the +// choices — and the Select keeps its window capped so a long message can't push +// the control off-screen. + +export interface SaJsonValidationFailedStepProps { + message: string + onChoose: (choice: 'retry' | 'save-anyway' | 'oauth') => void + dense?: boolean +} + +export const SaJsonValidationFailedStep: FC = ({ message, onChoose, dense = false }) => { + const options = [ + { label: '🔄 Try a different service account file', value: 'retry' }, + { label: '💾 Save credentials anyway (skip validation)', value: 'save-anyway' }, + { label: '🆕 Set up a new service account via Google', value: 'oauth' }, + ] + return ( + + + Service account validation failed. + + + + {message} + + + What would you like to do? + + onChoose(value as GoogleSignInChoice)} + /> + ) + return ( + + {SIGN_IN_TRUST} + + {SIGN_IN_INTRO} + + + + + {select} + + ) +} + +// ── google-sign-in (learn-more) ─────────────────────────────────────────────── + +export interface GoogleSignInLearnMoreStepProps { + onBack: () => void + dense?: boolean +} + +// Comfortable: the original long-form trust explainer — an info Alert + an +// indented box of four bold question / wrapping-answer pairs separated by +// s + a dim provenance line + a + the Back control. Dense: +// the whole Q&A is condensed to four terse single-line reassurances (the deep +// detail lives in the docs/source the last line points to) so the explainer + +// the Back control fit within budget. +export const GoogleSignInLearnMoreStep: FC = ({ onBack, dense = false }) => { + return ( + + + What Capgo can and can't do with the access you're about to grant. + + + + Can Capgo touch other GCP projects on my account? + + The scope allows it, but this CLI only calls APIs against the project you'll pick on the next screen. It creates one service account named + {' '} + capgo-native-build + {' '} + in that one project and stops. + + + Will Capgo upload anything to Play Store without me knowing? + No. The flow invites one service account into one app (the package you confirm) with release-only permissions. Future builds use that service account, not your OAuth tokens. + + Can Capgo employees access my Google account? + No. The refresh token never leaves your machine. Capgo's servers only serve the OAuth client ID — they never see your tokens. When provisioning finishes, the CLI asks Google to revoke that token, so even your local copy stops working. + + What if I change my mind later? + + Revoke anytime at + {' '} + myaccount.google.com/permissions + , or just delete the service account in Google Cloud. Neither needs Capgo's involvement. + + + Capgo passed Google's OAuth verification on 2026-05-02 for these scopes. Source code: github.com/Cap-go/capgo + + + onChoose(value as PlayDevIdActionChoice)} + /> + + ) +} + +// ── play-developer-id-input (input) ─────────────────────────────────────────── + +export interface PlayDeveloperIdInputStepProps { + onSubmit: (value: string) => void + dense?: boolean +} + +// Comfortable: bold label + the dim helper line + a + the input (the +// original look). Dense: the blank line is dropped. +export const PlayDeveloperIdInputStep: FC = ({ onSubmit, dense = false }) => ( + + Paste the Play Console URL, or just the developer ID: + Either the whole address bar value or the 16–20 digit number works. + {!dense && } + + +) + +// ── gcp-projects-loading (spinner) ──────────────────────────────────────────── +// Single spinner line — identical comfortable / dense. + +export const GcpProjectsLoadingStep: FC = () => ( + +) + +// ── gcp-projects-select ─────────────────────────────────────────────────────── +// The parent builds the option list (a "Create a new project" row prepended to +// one row per existing GCP project) and owns the route handler. Comfortable: +// the original bold heading + the dim helper line + a + the un-capped +// Select (the original showed every project — the parent only renders this form +// after measuring it fits). Dense: the blank line is dropped and visibility is +// capped via Select's `visibleOptionCount` with a "+N more" hint so a user with +// many projects can't blow the budget. + +export interface GcpProjectsSelectStepProps { + options: SelectOption[] + onChange: (value: string) => void + dense?: boolean +} + +export const GcpProjectsSelectStep: FC = ({ options, onChange, dense = false }) => { + return ( + + Which Google Cloud project should host the service account? + We'll create a `capgo-native-build` service account in the chosen project. + + + + ) + : ( + <> + Android package name: + + + + )} + + ) +} + +// ── gcp-setup-running (spinner + optional status stream) ────────────────────── +// `statusMessages` is the live provisioning progress stream. The spinner stays +// pinned at the top. Comfortable: the full stream is rendered (the original +// look). Dense: only the last few lines are shown so a long stream can't push +// the spinner / frame past budget. + +export interface GcpSetupRunningStepProps { + statusMessages: string[] + dense?: boolean +} + +export const GcpSetupRunningStep: FC = ({ statusMessages, dense = false }) => { + const lines = dense ? statusMessages.slice(-STATUS_TAIL) : statusMessages + return ( + + + {lines.length > 0 && ( + + {lines.map((msg, i) => ({msg}))} + + )} + + ) +} diff --git a/cli/src/build/onboarding/ui/steps/android-shared.tsx b/cli/src/build/onboarding/ui/steps/android-shared.tsx new file mode 100644 index 0000000000..d2f87d716f --- /dev/null +++ b/cli/src/build/onboarding/ui/steps/android-shared.tsx @@ -0,0 +1,375 @@ +// src/build/onboarding/ui/steps/android-shared.tsx +// +// Pure presentational step bodies for the Android `build init` onboarding +// wizard's shared lifecycle frames (welcome / credentials-exist / backing-up / +// no-platform / build-complete / error) plus the AI build-log analysis +// prompt / running / result frames. Each component is "props in → JSX out": +// every dynamic value and event handler is an explicit, typed prop. The parent +// wizard (android/ui/app.tsx) owns all state, routing, async work, telemetry +// (trackAiAnalysisChoice etc.) and terminal-size measurement; these components +// never touch `useStdout` / `measureElement`. `useInput` inside a leaf control +// is fine — that's not layout measurement. +// +// Adaptive spacing — each body renders its COMFORTABLE form by default (the +// original design: bordered banners where applicable + decorative +// blank-line spacing + full copy). The 16-row frame contract is a FLOOR we must +// survive on short terminals, not a cap on every terminal: when the parent +// measures that the comfortable body can't fit the viewport it flips the sticky +// `dense` signal and threads `dense={true}` here, collapsing each body to the +// terse, budget-fitting form (blank lines dropped, copy trimmed, banners +// boxless via AiResultBanner's own `dense` pass-through). The two +// variable-length frames also cap their growth in dense mode: `error` +// truncates a long failure message to a single line and `ai-analysis-result` +// renders SHORT analysis text inline (long analyses are routed to the +// fullscreen scroll viewer by the parent before this frame is shown) and +// collapses the "retries exhausted" hint to one line. `dense` defaults to +// `false` so a component rendered without the prop (e.g. a test asserting the +// comfortable form) gets the original look. +import type { FC } from 'react' +import type { AiResultKind } from '../components.js' +import { Select } from '@inkjs/ui' +import { Box, Newline, Text } from 'ink' +import React from 'react' +import { AiResultBanner, ErrorLine, SpinnerLine, SuccessLine } from '../components.js' + +// Longest a single failure message may be before we hard-truncate it with an +// ellipsis in the DENSE form. A raw backend / CLI stderr can be hundreds of +// characters and would wrap several rows at 60 cols, pushing the retry/exit +// control off the 13-row budget. One line of failure context + the recovery +// control is enough in dense mode; the parent already logs the full message to +// the scrollback above, and the comfortable form prints the message in full. +const MAX_ERROR_CHARS = 110 + +function truncate(text: string, max: number): string { + if (text.length <= max) + return text + // -1 so the ellipsis itself doesn't push us over `max`. + return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}…` +} + +// ── welcome (spinner) ───────────────────────────────────────────────────────── +// Single spinner line — identical comfortable / dense (no spacing to collapse). + +export const WelcomeStep: FC = () => ( + + + +) + +// ── no-platform ─────────────────────────────────────────────────────────────── +// `androidDir` is the (configurable) native dir we looked for, e.g. "android". +// Comfortable: a separates the error line from the recovery +// instruction (3 rows). Dense: the blank line is dropped so the two lines sit +// together (2 rows). + +export interface NoPlatformStepProps { + androidDir: string + dense?: boolean +} + +export const NoPlatformStep: FC = ({ androidDir, dense = false }) => ( + + + {!dense && } + + Run + {' '} + npx cap add android + {' '} + first, then re-run onboarding. + + +) + +// ── credentials-exist ───────────────────────────────────────────────────────── +// `appId` is the Capgo app whose credentials already exist. Comfortable: the +// warning, the explanation and the Select are each separated by a . +// Dense: the blank lines are dropped so the prompt + choices fit at 60 cols. + +export interface CredentialsExistStepProps { + appId: string + onChoose: (choice: 'backup' | 'exit') => void + dense?: boolean +} + +export const CredentialsExistStep: FC = ({ appId, onChoose, dense = false }) => ( + + {`⚠ Android credentials already exist for ${appId}`} + {!dense && } + Onboarding will create new credentials, replacing the existing ones. + {!dense && } + onChoose(value as 'retry' | 'exit')} + /> + +) + +// ── ai-analysis-prompt ──────────────────────────────────────────────────────── +// Offered when a build fails and Capgo captured a log to analyze. Comfortable: +// the failure line, the offer and the Select are each separated by a . +// Dense: the blank lines are dropped so the offer + debug/skip control fit at 60 +// cols. All telemetry on the choice stays in the parent's onChoose handler. + +export interface AiAnalysisPromptStepProps { + onChoose: (choice: 'debug' | 'skip') => void + dense?: boolean +} + +export const AiAnalysisPromptStep: FC = ({ onChoose, dense = false }) => ( + + + {!dense && } + We can analyze the build log with Capgo AI (Kimi K2.5) and suggest a fix. + {!dense && } + { + if (value === 'retry') + onRetry() + else if (value === 'reread') + onReread() + else + onSkipOrContinue() + }} + /> + + ) +} diff --git a/cli/src/build/onboarding/ui/steps/ios-ci.tsx b/cli/src/build/onboarding/ui/steps/ios-ci.tsx new file mode 100644 index 0000000000..9a5443b339 --- /dev/null +++ b/cli/src/build/onboarding/ui/steps/ios-ci.tsx @@ -0,0 +1,299 @@ +import type { FC } from 'react' +import type { CiSecretSetupAdvice, CiSecretTarget } from '../../ci-secrets.js' +// src/build/onboarding/ui/steps/ios-ci.tsx +// +// Pure presentational step bodies for the iOS CI-secrets sub-flow of the +// `build init` onboarding wizard (detect git hosting → optionally upload the +// build env vars as GitHub Actions secrets / GitLab CI/CD variables). Each +// component is "props in → JSX out": every dynamic value and event handler is +// an explicit, typed prop. The parent wizard (ui/app.tsx) owns all state, +// routing, async work and terminal-size measurement; these components never +// touch `useStdout` / `measureElement`. +// +// Adaptive spacing. Each interactive step renders its COMFORTABLE form (the +// original design — decorative blank-line spacing between elements, +// per-provider setup advice with its full reason message, the full overwrite +// key list, and an UN-CAPPED Select that shows every option) by DEFAULT and +// collapses to a COMPACT form only when the parent passes `dense=true`. The +// parent (ui/app.tsx) measures the comfortable body against the live viewport +// and flips `dense` on only when the comfortable version can't fit — so a roomy +// terminal breathes while a 16-row terminal still survives. `dense` defaults to +// `false`, so a component rendered without the prop (e.g. a test asserting the +// comfortable form) gets the original look. All props/handlers/behaviour are +// identical across both modes. +// +// The 16-row frame contract (see ui/components.tsx + test/helpers/frame-fit.mjs) +// is a FLOOR we must survive on short terminals, not a cap on every terminal: +// every step body's DENSE form must render within BODY_BUDGET_ROWS (13) rows at +// the reference widths (80 + 60) — that's the form which must survive the floor. +// The comfortable form may legitimately exceed the budget (it only renders when +// the parent measured that it fits). The budget offenders in dense mode are: +// • ci-secrets-setup lists per-provider install/login advice — the dense form +// drops the decorative blank lines + the reason message and keeps only the +// provider label + command lines. +// • confirm-ci-secret-overwrite lists the env vars that would be replaced — +// the dense form caps to the last few keys + a "… +N more" line. +// • ci-secrets-failed renders an arbitrary error string — the dense form +// clamps it so a long backend error can't wrap past a couple of rows and +// push the picker off screen. +// In dense mode pickers cap @inkjs/ui's `Select` with `visibleOptionCount`; +// every step keeps its interactive control + key instruction on screen. +// Verified in test-frame-fit-ios-ci.mjs (dense form asserted ≤ 13). +// +// Pure spinner steps (detecting-ci-secrets / checking-ci-secrets / +// uploading-ci-secrets) are a single SpinnerLine that fits both forms +// identically, so they take no `dense` prop. +import { Select } from '@inkjs/ui' +import { Box, Newline, Text } from 'ink' +import React from 'react' +import { ErrorLine, SpinnerLine, SuccessLine } from '../components.js' + +// A single Select option. Mirrors the shape @inkjs/ui's Select expects so the +// parent can build option lists (provider rows + control rows) and pass them +// straight through. Matches ios-credentials.tsx / ios-import.tsx's SelectOption. +export interface SelectOption { + label: string + value: string +} + +// `Select` only ever renders `visibleOptionCount` OPTIONS (it scrolls the +// rest). The target picker tops out at 3 options (GitHub + GitLab + Skip) so it +// never needs scrolling, but we pass the cap defensively in dense mode, +// mirroring the Android keystore flow. +const TARGET_VISIBLE_COUNT = 3 + +// ── detecting-ci-secrets ────────────────────────────────────────────────────── +export const DetectingCiSecretsStep: FC = () => ( + + + +) + +// ── ci-secrets-setup ────────────────────────────────────────────────────────── +// Shown when a git remote was detected but the matching CLI (gh/glab) isn't +// installed or isn't logged in. The parent owns the retry/skip routing +// (retry re-runs detection; skip jumps to build-complete). +// +// Comfortable: the bold heading, a , then each provider's advice block +// (the label, the dim reason `message`, and the command lines) separated by a +// marginBottom={1}, the "Run this in another terminal…" dim note, another +// , then the Select. Dense: the blank lines + the per-provider reason +// message drop so two providers' worth of label + command lines still fits 13 +// rows at 60 cols. +export interface CiSecretsSetupStepProps { + advice: CiSecretSetupAdvice[] + dense?: boolean + onChange: (value: string) => void +} + +export const CiSecretsSetupStep: FC = ({ advice, dense = false, onChange }) => { + const select = ( + + : + +) + +// ── checking-ci-secrets ─────────────────────────────────────────────────────── +export interface CheckingCiSecretsStepProps { + targetLabel: string +} + +export const CheckingCiSecretsStep: FC = ({ targetLabel }) => ( + + + +) + +// ── confirm-ci-secret-overwrite ─────────────────────────────────────────────── +// Some of the env vars we'd upload already exist remotely. The parent routes +// replace → uploading-ci-secrets, skip → build-complete. +// +// Comfortable: the bold yellow warning, the FULL list of keys that would be +// replaced (in a marginTop+marginLeft box), a , then the Select. +// Dense: the list caps to the last OVERWRITE_VISIBLE_KEYS keys + a "… +N more" +// count and the blank lines drop, so a long list (iOS + Android credentials → +// ~10 keys) can't blow the budget at 60 cols. +export interface ConfirmCiSecretOverwriteStepProps { + existingKeys: string[] + dense?: boolean + onChange: (value: string) => void +} + +const OVERWRITE_OPTIONS = [ + { label: 'Replace existing env vars', value: 'replace' }, + { label: 'Skip upload', value: 'skip' }, +] + +export const ConfirmCiSecretOverwriteStep: FC = ({ existingKeys, dense = false, onChange }) => { + return ( + + These env vars already exist and will be replaced: + + {existingKeys.map(key => ( + {`• ${key}`} + ))} + + + + + ) +} + +// ── ask-build ───────────────────────────────────────────────────────────────── +// Final prompt of the credential flow: kick off the first cloud build now or +// finish. The parent routes yes → requesting-build, no → build-complete. +// +// Comfortable: the success line, a , the bold question, another +// , then the Select. Dense: the blank lines drop so the body fits the +// budget at 60 cols. +export interface AskBuildStepProps { + dense?: boolean + onChange: (value: string) => void +} + +export const AskBuildStep: FC = ({ dense = false, onChange }) => ( + + + {!dense && } + Start your first cloud build now? + {!dense && } + + +) + +// ── backing-up ────────────────────────────────────────────────────────────── +export const BackingUpStep: FC = () => ( + + + +) + +// ── setup-method-select ────────────────────────────────────────────────────── +// Comfortable: the info Alert, a , the Select, another , +// then the full two-line "Importing reuses the certificate Xcode already +// installed…" tip. Dense: the blank lines drop and the tip is trimmed to a +// single line so the banner + choices + tip fit the budget. +export interface SetupMethodSelectStepProps { + dense?: boolean + onChange: (value: string) => void | Promise +} + +export const SetupMethodSelectStep: FC = ({ dense = false, onChange }) => ( + + + How do you want to set up iOS credentials? + + {!dense && } + + + ) + : ( + <> + Path to your .p8 file: + + + + + ) + return ( + + + We need an App Store Connect API key to manage certificates and profiles for you. + + + + + 1. + {' '} + Go to + {' '} + appstoreconnect.apple.com/access/integrations/api + + + 2. + {' '} + Click + {' '} + "Generate API Key" + + + 3. + {' '} + Name it + {' '} + "Capgo Builder" + {' '} + · Access: + {' '} + "Admin" + + + 4. + {' '} + Download the + {' '} + .p8 + {' '} + file + + + + + Press + Ctrl+O + to open App Store Connect in your browser + + + + + {control} + + ) +} + +// ── p8-method-select ────────────────────────────────────────────────────────── +export const P8MethodSelectStep: FC = () => ( + + + +) + +// ── input-p8-path ────────────────────────────────────────────────────────────── +// The bold label + a marginTop input. Identical in both forms (already a tight +// two-element body), so no `dense` branch is needed. +export interface InputP8PathStepProps { + onSubmit: (value: string) => void | Promise +} + +export const InputP8PathStep: FC = ({ onSubmit }) => ( + + Path to your .p8 file: + + + + +) + +// ── input-key-id ────────────────────────────────────────────────────────────── +// `keyId` is the value detected from the .p8 filename (empty when none was +// found). When present we pre-confirm it and let the user override; when empty +// we prompt for it fresh. The `(value || keyId).trim()` reuse logic lives in the +// parent's onSubmit — this component only renders and forwards. +// +// Comfortable: the original full copy — when detected, the "(detected from +// filename)" label + the green-tick value row with the longer "press Enter to +// confirm, or type a different one" hint + the input. Dense: the hint shortens +// to "Enter to confirm, or type another". The blank-line spacing (marginTop +// boxes) is the original look and is kept in both forms (this body is already +// short). +export interface InputKeyIdStepProps { + keyId: string + dense?: boolean + onSubmit: (value: string) => void +} + +export const InputKeyIdStep: FC = ({ keyId, dense = false, onSubmit }) => ( + + {keyId + ? ( + <> + + Key ID + {' '} + (detected from filename) + : + + + + {keyId} + {dense ? ' — Enter to confirm, or type another' : ' — press Enter to confirm, or type a different one'} + + + + + + ) + : ( + <> + + Key ID + {' '} + (shown next to the key name in App Store Connect) + : + + + + + + )} + +) + +// ── input-issuer-id ────────────────────────────────────────────────────────── +// Comfortable: the full label ("…at the very top of the API keys page, above +// the key list"), a , the "Press Ctrl+O" hint, then the input. Dense: +// the label trims, the blank line drops, and the hint sits directly above the +// input so the prompt + hint + input fit the budget. +export interface InputIssuerIdStepProps { + dense?: boolean + onSubmit: (value: string) => void +} + +export const InputIssuerIdStep: FC = ({ dense = false, onSubmit }) => ( + + + Issuer ID + {' '} + {dense ? '(UUID at the top of the API keys page)' : '(UUID at the very top of the API keys page, above the key list)'} + : + + {!dense && } + + Press + Ctrl+O + to open App Store Connect in your browser + + + + + +) + +// ── verifying-key ────────────────────────────────────────────────────────────── +export const VerifyingKeyStep: FC = () => ( + + + +) + +// ── creating-certificate ───────────────────────────────────────────────────── +export const CreatingCertificateStep: FC = () => ( + + + + +) + +// ── cert-limit-prompt ───────────────────────────────────────────────────────── +// Apple caps distribution certs at 3; the user must revoke one to continue. +// `options` is built by the parent (one row per existing cert + an exit row) +// so this component stays presentational. `existingCount` drives the header. +// +// Comfortable: the error line, a , the bold "Select a certificate to +// revoke:" prompt, another , then the Select. Dense: the blank lines +// drop so the error + prompt + the (up to 3 cert rows + exit) Select fit the +// budget at 60 cols. +export interface CertLimitPromptStepProps { + existingCount: number + options: SelectOption[] + dense?: boolean + onChange: (value: string) => void +} + +export const CertLimitPromptStep: FC = ({ existingCount, options, dense = false, onChange }) => ( + + + {!dense && } + Select a certificate to revoke: + {!dense && } + + +) + +// ── deleting-duplicate-profiles ────────────────────────────────────────────── +export interface DeletingDuplicateProfilesStepProps { + duplicateCount: number +} + +export const DeletingDuplicateProfilesStep: FC = ({ duplicateCount }) => ( + + + +) + +// ── saving-credentials ────────────────────────────────────────────────────── +export const SavingCredentialsStep: FC = () => ( + + + +) diff --git a/cli/src/build/onboarding/ui/steps/ios-import.tsx b/cli/src/build/onboarding/ui/steps/ios-import.tsx new file mode 100644 index 0000000000..6075e6b724 --- /dev/null +++ b/cli/src/build/onboarding/ui/steps/ios-import.tsx @@ -0,0 +1,367 @@ +import type { FC } from 'react' +// src/build/onboarding/ui/steps/ios-import.tsx +// +// Pure presentational step bodies for the iOS "import existing credentials" +// sub-flow of the `build init` onboarding wizard (macOS only). Each component is +// "props in → JSX out": every dynamic value and event handler is an explicit, +// typed prop. The parent wizard (ui/app.tsx) owns all state, routing, async work +// and terminal-size measurement; these components never touch +// `useStdout` / `measureElement`. +// +// Adaptive spacing. Each interactive step renders its COMFORTABLE form (the +// original design — bordered Alert banners, decorative blank-line +// spacing, full multi-line copy, and an UN-CAPPED Select that shows every +// option) by DEFAULT and collapses to a COMPACT form only when the parent passes +// `dense=true`. The parent (ui/app.tsx) measures the comfortable body against the +// live viewport and flips `dense` on only when the comfortable version can't fit +// — so a roomy terminal breathes while a 16-row terminal still survives. `dense` +// defaults to `false`, so a component rendered without the prop (e.g. a test +// asserting the comfortable form) gets the original look. All props/handlers/ +// behaviour are identical across both modes. +// +// The 16-row frame contract (see ui/components.tsx + test/helpers/frame-fit.mjs) +// is a FLOOR we must survive on short terminals, not a cap on every terminal: +// every step body's DENSE form must render within BODY_BUDGET_ROWS (13) rows at +// the reference widths (80 + 60) — that's the form which must survive the floor. +// In dense mode the list/picker steps cap the @inkjs/ui `Select` with +// `visibleOptionCount` and add a "+N more (↑/↓)" hint so a long list can't blow +// the budget; the verbose warning/recovery/compiling steps switch to terse copy +// with the decorative blank lines (and Alert chrome) dropped, while always +// keeping the interactive control + the key instruction visible. The comfortable +// form may legitimately exceed the budget (it only renders when the parent +// measured that it fits). Verified in test-frame-fit-ios-import.mjs (dense form +// asserted ≤ 13). +// +// Pure spinner steps (import-scanning / import-fetching-profile / +// import-create-profile-only / import-exporting) are a single SpinnerLine plus +// at most one short note that fits both forms identically, so they take no +// `dense` prop. +import { Alert, Select } from '@inkjs/ui' +import { Box, Newline, Text } from 'ink' +import React from 'react' +import { SpinnerLine } from '../components.js' + +// A single Select option. Mirrors the shape @inkjs/ui's Select expects so the +// parent can build option lists (identity/profile rows + control rows) and pass +// them straight through. Matches ios-credentials.tsx's SelectOption. +export interface SelectOption { + label: string + value: string +} + +// `Select` only ever renders `visibleOptionCount` OPTIONS (it scrolls the +// rest) — but each option can WRAP to multiple terminal rows at narrow widths. +// The identity/profile labels here are long (full cert name + bundle id), so an +// option wraps to ~3 rows at 60 cols; capping at 3 visible options keeps the +// worst case (3 × 3 = 9 body rows + header + hint) inside the 13-row budget. A +// "+N more" hint tells the user the list scrolls. (The Android keystore flow +// can afford 4 because its alias labels are single short tokens that never +// wrap.) +const LIST_VISIBLE_COUNT = 3 + +// ── import-scanning ─────────────────────────────────────────────────────────── +export const ImportScanningStep: FC = () => ( + + + This is read-only — no Keychain password prompt yet. + +) + +// ── import-distribution-mode ────────────────────────────────────────────────── +// First visible step of the import flow. The three options (App Store / Ad-hoc / +// Cancel) are dispatched by the parent, which owns the persistence + routing. +// +// Comfortable: the bold question, a , the two full-length bullet lines +// explaining each mode, another , then the Select. Dense: the blank +// lines drop and the bullets shorten to single un-wrapped lines so the +// question + bullets + three choices fit the budget at 60 cols. +export interface ImportDistributionModeStepProps { + dense?: boolean + onChange: (value: string) => void | Promise +} + +export const ImportDistributionModeStep: FC = ({ dense = false, onChange }) => ( + + How will Capgo distribute your build? + {!dense && } + + {dense + ? '• App Store: auto-uploads to TestFlight (needs an ASC API key).' + : '• App Store: builds upload to TestFlight automatically (requires an App Store Connect API key)'} + + + {dense + ? '• Ad-hoc: signed build, downloaded from Capgo or via QR. No ASC key.' + : '• Ad-hoc: builds are signed and either downloaded from Capgo or installed via QR. No ASC key needed.'} + + {!dense && } + + : + : + + ) +} + +// ── import-fetching-profile ─────────────────────────────────────────────────── +export const ImportFetchingProfileStep: FC = () => ( + + + +) + +// ── import-create-profile-only ──────────────────────────────────────────────── +// D2: create a new profile via Apple for the cert already in the Keychain +// (cert creation is skipped). Static spinner + a one-line clarification. +export const ImportCreateProfileOnlyStep: FC = () => ( + + + (Skipping cert creation — using the cert already in your Keychain.) + +) + +// ── import-export-warning ───────────────────────────────────────────────────── +// Heads-up before the single Keychain permission dialog. The label of the +// "export now" row embeds the identity name; the parent owns the go/back/exit +// routing. +// +// Comfortable: the warning Alert, a , the THREE full numbered steps +// (step 1 quotes the exact macOS dialog text), another , then the +// Select. Dense: the blank lines drop and the numbered steps shorten to single +// un-wrapped lines so the banner + steps + three choices fit the budget at 60 +// cols. The Select is always shown in full (only three rows). +export interface ImportExportWarningStepProps { + identityName: string + dense?: boolean + onChange: (value: string) => void +} + +export const ImportExportWarningStep: FC = ({ identityName, dense = false, onChange }) => { + const select = ( + + +) + +// ── no-platform ──────────────────────────────────────────────────────────────── +// The iOS native folder is missing. `iosDir` names the missing directory, +// `addIosCommand`/`syncIosCommand` are the suggested fixes (shown terse), and +// the option labels embed `addIosCommand`. The parent owns run/recheck/exit. +export interface NoPlatformStepProps { + iosDir: string + addIosCommand: string + syncIosCommand: string + dense?: boolean + onChange: (value: string) => void +} + +export const NoPlatformStep: FC = ({ iosDir, addIosCommand, syncIosCommand, dense = false, onChange }) => ( + + + + {dense + ? 'Onboarding needs a generated native iOS project before creating credentials.' + : 'This onboarding flow needs a generated native iOS project before credentials can be created.'} + + {dense ? `Suggested: ${addIosCommand} && ${syncIosCommand}` : `Suggested commands: ${addIosCommand} && ${syncIosCommand}`} + + +) + +// ── ai-analysis-running ───────────────────────────────────────────────────────── +export const AiAnalysisRunningStep: FC = () => ( + + + +) + +// ── ai-analysis-result ────────────────────────────────────────────────────────── +// Renders the diagnosis (or fallback banner), then a retry/skip Select. The +// parent computes whether retries remain and owns ALL telemetry + state-reset +// on retry; this component only renders and forwards the chosen value. +// +// Display rules: +// • success + fits inline (`collapsed` false) → render `analysisText` inline. +// • success + too tall (`collapsed` true) → a compact "reviewed" marker plus +// a "Re-read analysis" option that re-opens the fullscreen scroll viewer. +// The parent sets `collapsed` = (dismissed the viewer) AND (still too tall +// for the current terminal), so growing the terminal reveals the full text +// again instead of leaving a stale marker. +// • a non-success outcome (`result`) → the coloured AiResultBanner. +// The "⚠ AI can make mistakes…" caution always shows. When no retries remain a +// terse "used all N retries" notice + a single "Continue" option replace the +// retry/skip pair. +// +// `maxRetries` is the parent's MAX_AI_RETRIES; `retriesLeft` is the remaining +// count (0 ⇒ `canRetry` false). The `analysisText` rendered inline here only +// happens when it fits (collapsed false), so it never threatens the budget. +export interface AiAnalysisResultStepProps { + analysisText: string | null + // True when the analysis is too tall to show inline alongside the picker, so + // it lives in the fullscreen scroll viewer and is replaced here by a compact + // marker + a "Re-read analysis" option. False ⇒ render the full text inline. + collapsed: boolean + result: { kind: AiResultKind, message: string } | null + canRetry: boolean + retriesLeft: number + maxRetries: number + dense?: boolean + // Receives 'retry' | 'skip' | 'continue' | 'reread'. 'reread' re-opens the + // fullscreen scroll viewer (the wizard runs in the alt-screen buffer, which + // has no scrollback — so re-reading must re-open the viewer, not scroll up). + onChange: (value: string) => void | Promise +} + +export const AiAnalysisResultStep: FC = ({ + analysisText, + collapsed, + result, + canRetry, + retriesLeft, + maxRetries, + dense = false, + onChange, +}) => { + const retryLabel = retriesLeft === 1 + ? '🔄 I fixed it, retry build (last retry)' + : `🔄 I fixed it, retry build (${retriesLeft} retries left)` + return ( + + AI analysis + {!dense && } + {analysisText && !collapsed && {analysisText}} + {analysisText && collapsed && ( + 📖 Analysis reviewed — pick an option below, or re-read it. + )} + {result && } + {!dense && } + + {dense + ? '⚠ AI can make mistakes. Verify the diagnosis against the full log before applying the fix.' + : '⚠ AI can make mistakes. Always verify the diagnosis against the full log before applying the suggested fix.'} + + {!canRetry && ( + <> + {!dense && } + + {dense + ? `You've used all ${maxRetries} retries. Exit and re-run the wizard for another attempt.` + : `You've used all ${maxRetries} retries. Exit and re-run the wizard if you need another attempt.`} + + + )} + {!dense && } + + + )} + + ) + } + return ( + + + + {recoveryAdvice && ( + <> + Recovery plan + + {recoveryAdvice.summary.map(line => ( + {`• ${line}`} + ))} + + {recoveryAdvice.commands.length > 0 && ( + <> + + Helpful commands + + {recoveryAdvice.commands.map(command => ( + {command} + ))} + + + )} + {recoveryAdvice.docs.length > 0 && ( + <> + + Docs + + {recoveryAdvice.docs.map(doc => ( + {doc} + ))} + + + )} + + )} + {supportBundlePath && ( + <> + + Support bundle + {supportBundlePath} + + )} + + {showRetry && ( + <> + What do you want to do? + + = ({ onBack, dense = false }) => { +export const GoogleSignInLearnMoreStep: FC = ({ onBack }) => { return ( @@ -354,7 +354,7 @@ export interface PlayDeveloperIdActionsStepProps { // + the Select(3). Dense: the explanation is compressed to two terse // lines that still tell the user what the ID is and where to find it, the blank // lines are dropped, and the URL example is folded into one line. -export const PlayDeveloperIdActionsStep: FC = ({ playDeveloperUrl, onChoose, dense = false }) => { +export const PlayDeveloperIdActionsStep: FC = ({ playDeveloperUrl, onChoose }) => { const options = [ { label: '🌐 Open Play Console in my browser', value: 'open' }, { label: '🎬 Watch a quick video tutorial', value: 'tutorial' }, @@ -435,7 +435,7 @@ export interface GcpProjectsSelectStepProps { dense?: boolean } -export const GcpProjectsSelectStep: FC = ({ options, onChange, dense = false }) => { +export const GcpProjectsSelectStep: FC = ({ options, onChange }) => { return ( Which Google Cloud project should host the service account? @@ -502,11 +502,9 @@ export interface AndroidPackageSelectStepProps { export const AndroidPackageSelectStep: FC = ({ showChooser, detectedOptions, - detectedCount, androidDir, onChooseDetected, onSubmitManual, - dense = false, }) => { return ( diff --git a/cli/src/build/onboarding/ui/steps/ios-ci.tsx b/cli/src/build/onboarding/ui/steps/ios-ci.tsx index 9a5443b339..1b28135eca 100644 --- a/cli/src/build/onboarding/ui/steps/ios-ci.tsx +++ b/cli/src/build/onboarding/ui/steps/ios-ci.tsx @@ -86,7 +86,7 @@ export interface CiSecretsSetupStepProps { onChange: (value: string) => void } -export const CiSecretsSetupStep: FC = ({ advice, dense = false, onChange }) => { +export const CiSecretsSetupStep: FC = ({ advice, onChange }) => { const select = ( = ({ dense = false }) => { +export const ImportCompilingHelperStep: FC = () => { return ( diff --git a/cli/src/build/onboarding/ui/steps/ios-shared.tsx b/cli/src/build/onboarding/ui/steps/ios-shared.tsx index 1858fe99ef..4a45851834 100644 --- a/cli/src/build/onboarding/ui/steps/ios-shared.tsx +++ b/cli/src/build/onboarding/ui/steps/ios-shared.tsx @@ -387,7 +387,7 @@ export function estimateErrorBodyRows( return rows } -export const ErrorStep: FC = ({ error, recoveryAdvice, supportBundlePath, showRetry, dense = false, collapsed = false, onChange }) => { +export const ErrorStep: FC = ({ error, recoveryAdvice, supportBundlePath, showRetry, collapsed = false, onChange }) => { // Collapsed form: the full error + recovery advice was too tall for the // viewport, so the parent already showed it in the scrollable viewer. Render // only the error headline + the action prompt, so Try again / Restart / Exit @@ -484,7 +484,7 @@ export interface BuildCompleteStepProps { dense?: boolean } -export const BuildCompleteStep: FC = ({ buildUrl, ciSecretUploadSummary, buildRequestCommand, workflowWrittenPath = null, envExportPath = null, envExportError = null, dense = false }) => { +export const BuildCompleteStep: FC = ({ buildUrl, ciSecretUploadSummary, buildRequestCommand, workflowWrittenPath = null, envExportPath = null, envExportError = null }) => { const detail = buildUrl ? ( <> From 0d687383c58499562b82c8feb70c9ded976d0ade Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sun, 31 May 2026 07:59:12 +0200 Subject: [PATCH 03/14] =?UTF-8?q?fix(cli):=20clear=20CI=20=E2=80=94=20`aci?= =?UTF-8?q?`=20typo=20+=20knip=20dead=20exports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename the `aci` import alias (android-ci) to `androidCi` in onboarding-fixtures — `aci` tripped the typos check (`aci`→`acpi`), which failed both "Check for typos (CLI)" and "Run tests". Also alphabetised the step imports (perfectionist). - Remove dead exports knip flagged (leftover dense-flag / frame-budget machinery the rewrite abandoned): totalRenderedRows, isFrameTooSmall, shouldCollapseToDense, COMPACT_HEADER_TOTAL_ROWS, the unused ios-shared SelectOption, and the CheckingCiSecretsStep/UploadingCiSecretsStep spinner steps (superseded by dynamic phase text). Move the test-only BODY_BUDGET_ROWS/MAX_FRAME_ROWS constants into the frame-fit test helper, and delete the orphaned test-frame-fit-decision.mjs (it only exercised the deleted dense logic). De-alias MIN_ROWS to clear knip's duplicate-export. Build green, cli:lint clean, knip 0, and all onboarding/workflow/tracking suites pass. --- cli/src/build/onboarding/ai-fit.ts | 15 -- cli/src/build/onboarding/min-terminal-size.ts | 5 +- cli/src/build/onboarding/ui/app.tsx | 6 +- cli/src/build/onboarding/ui/components.tsx | 28 +--- cli/src/build/onboarding/ui/frame-fit.ts | 40 +----- .../build/onboarding/ui/steps/android-ci.tsx | 20 --- cli/src/build/onboarding/ui/steps/ios-ci.tsx | 22 --- .../build/onboarding/ui/steps/ios-shared.tsx | 8 -- cli/test/helpers/frame-fit.mjs | 12 +- cli/test/helpers/onboarding-fixtures.mjs | 10 +- cli/test/test-frame-fit-decision.mjs | 131 ------------------ 11 files changed, 27 insertions(+), 270 deletions(-) delete mode 100644 cli/test/test-frame-fit-decision.mjs diff --git a/cli/src/build/onboarding/ai-fit.ts b/cli/src/build/onboarding/ai-fit.ts index f9fd9027ad..11fbf76c5c 100644 --- a/cli/src/build/onboarding/ai-fit.ts +++ b/cli/src/build/onboarding/ai-fit.ts @@ -133,21 +133,6 @@ function renderedRowsForLine(line: string, terminalCols: number): number { return Math.max(1, Math.ceil(visibleLen / cols)) } -/** - * Sum of rendered rows for a list of logical lines. - * - * Used by the scrollable viewer to figure out how many padding rows to - * add below the visible content so the frame height stays constant across - * scroll positions (constant height = Ink renders in-place, no scrollback - * growth on every keystroke). - */ -export function totalRenderedRows(lines: string[], terminalCols: number): number { - let total = 0 - for (const line of lines) - total += renderedRowsForLine(line, terminalCols) - return total -} - /** * Pick the slice of `lines` starting at `scrollOffset` that PACKS the * `viewportRows` rendered rows of a terminal `terminalCols` wide. diff --git a/cli/src/build/onboarding/min-terminal-size.ts b/cli/src/build/onboarding/min-terminal-size.ts index 96429d366e..20b145266b 100644 --- a/cli/src/build/onboarding/min-terminal-size.ts +++ b/cli/src/build/onboarding/min-terminal-size.ts @@ -41,8 +41,9 @@ export const ANDROID_MIN_ROWS = 49 // Conservative default for platform-agnostic callers (the generic MinSizeGate / // TerminalTooSmallPrompt default): the LARGER floor, so a gate with no platform -// context never under-reserves. -export const MIN_ROWS = ANDROID_MIN_ROWS +// context never under-reserves. Computed (not aliased to ANDROID_MIN_ROWS) so it +// stays correct if the per-platform floors are ever reordered. +export const MIN_ROWS = Math.max(IOS_MIN_ROWS, ANDROID_MIN_ROWS) /** The full-onboarding row floor for a given platform. */ export function onboardingMinRows(platform: 'ios' | 'android'): number { diff --git a/cli/src/build/onboarding/ui/app.tsx b/cli/src/build/onboarding/ui/app.tsx index 8a91ca59d3..c7ae95c1a1 100644 --- a/cli/src/build/onboarding/ui/app.tsx +++ b/cli/src/build/onboarding/ui/app.tsx @@ -1984,10 +1984,8 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) // with the resize prompt instead.) // The fullscreen AI viewer is a takeover: render it as an EARLY RETURN so it - // owns the whole terminal and bypasses the body-measurement / dense / - // too-small logic above. (If it rendered inside the measured body, its - // full-height body would trip `shouldCollapseToDense`/`tooSmall` and get - // replaced by the resize prompt.) It fills the screen itself via minHeight. + // owns the whole terminal and bypasses the regular wizard frame above. It + // fills the screen itself via minHeight. if (isAiResultScroll && aiAnalysisText) return ( uses padding={1} → one row top + one row bottom. export const WIZARD_PADDING_ROWS = 2 -// Frame-fit contract. Every rendered frame must fit within MAX_FRAME_ROWS -// terminal rows so the wizard never surprises the user with a "terminal too -// small" block after a step that fit. A frame = adaptive header + body + -// padding; with the one-line compact header the body's row budget is the -// constant below. Each step BODY component is unit-tested (see -// test/helpers/frame-fit.mjs) to render within BODY_BUDGET_ROWS at the +// Frame-fit contract. Every rendered frame must fit within the 16-row floor so +// the wizard never surprises the user with a "terminal too small" block after a +// step that fit. A frame = adaptive header + body + padding; with the one-line +// compact header the body's row budget is 13 rows. Each step BODY component is +// unit-tested (see test/helpers/frame-fit.mjs, which derives the budget from +// COMPACT_HEADER_ROWS + WIZARD_PADDING_ROWS) to render within that budget at the // reference widths, so a too-tall step can never silently regress. -export const MAX_FRAME_ROWS = 16 -export const BODY_BUDGET_ROWS = MAX_FRAME_ROWS - COMPACT_HEADER_ROWS - WIZARD_PADDING_ROWS // 13 - -// Shown in place of the step content when even the one-line header + the -// step's content won't fit the current viewport. Kept to TWO rows with no -// padding: in the alt buffer the TOP of overflowing content is what gets -// clipped, so the fewer rows this occupies the more likely the user sees the -// actionable instruction even on a very short terminal. `neededRows` is the -// measured target height (body + one-line header + padding) so the message -// can tell the user concretely how tall to make the window. -export const TerminalTooSmall: FC<{ rows: number, neededRows: number }> = ({ rows, neededRows }) => ( - - {`⚠ Terminal too small (${rows} row${rows === 1 ? '' : 's'})`} - {`Resize taller — at least ${neededRows} rows — to continue onboarding.`} - -) export const SpinnerLine: FC<{ text: string }> = ({ text }) => ( diff --git a/cli/src/build/onboarding/ui/frame-fit.ts b/cli/src/build/onboarding/ui/frame-fit.ts index 470242174f..8a66582654 100644 --- a/cli/src/build/onboarding/ui/frame-fit.ts +++ b/cli/src/build/onboarding/ui/frame-fit.ts @@ -1,43 +1,7 @@ // Pure frame-fit DECISION logic for the onboarding wizard's adaptive spacing. // Extracted from the parent `app.tsx` state machine so it can be unit-tested -// independently of Ink rendering (see test/test-frame-fit-decision.mjs). -import { COMPACT_HEADER_ROWS, WIZARD_PADDING_ROWS } from './components.js' - -// Rows the one-line compact header + the wizard's outer padding occupy. -export const COMPACT_HEADER_TOTAL_ROWS = COMPACT_HEADER_ROWS + WIZARD_PADDING_ROWS - -export interface FrameFitInput { - // Measured body height FOR THE CURRENT DENSITY, or null when unknown — before - // the first measure, or right after a density flip when the only measurement - // we have was taken at the other density (stale). - bodyRows: number | null - // Whether the body is currently rendering its compact (dense) form. - dense: boolean - terminalRows: number -} - -// Decide whether to show the "terminal too small" resize prompt. -// -// CRITICAL: only block when we are ALREADY dense — i.e. there is no smaller -// form left to fall back to. When the COMFORTABLE form overflows we must NOT -// block; the parent collapses to dense (see shouldCollapseToDense) and -// re-measures. Blocking while a denser form is still available unmounts the -// body, which kills the measure→re-measure loop and wedges the wizard on the -// resize prompt forever. A null bodyRows means "not yet measured at the -// current density" (pre-measure, or stale right after a flip) → render -// optimistically so the body can be measured; only a terminal too short for -// the one-line header + padding + a single content row is blocked pre-measure. -export function isFrameTooSmall({ bodyRows, dense, terminalRows }: FrameFitInput): boolean { - if (bodyRows == null) - return terminalRows < COMPACT_HEADER_TOTAL_ROWS + 1 - return dense && bodyRows + COMPACT_HEADER_TOTAL_ROWS > terminalRows -} - -// A comfortable (non-dense) body should collapse to its dense form when it -// overflows the viewport even with the one-line compact header. -export function shouldCollapseToDense({ bodyRows, terminalRows }: { bodyRows: number, terminalRows: number }): boolean { - return bodyRows + COMPACT_HEADER_TOTAL_ROWS > terminalRows -} +// independently of Ink rendering. +import { WIZARD_PADDING_ROWS } from './components.js' // ── Platform picker layout ─────────────────────────────────────────────────── // The platform picker renders two bordered "cards" side-by-side when there's diff --git a/cli/src/build/onboarding/ui/steps/android-ci.tsx b/cli/src/build/onboarding/ui/steps/android-ci.tsx index 2aee123f1b..6572fbaea2 100644 --- a/cli/src/build/onboarding/ui/steps/android-ci.tsx +++ b/cli/src/build/onboarding/ui/steps/android-ci.tsx @@ -158,16 +158,6 @@ export const AskCiSecretsStep: FC = ({ entryCount, target ) -// ── checking-ci-secrets (spinner) ───────────────────────────────────────────── - -export interface CheckingCiSecretsStepProps { - targetLabel: string -} - -export const CheckingCiSecretsStep: FC = ({ targetLabel }) => ( - -) - // ── confirm-ci-secret-overwrite ─────────────────────────────────────────────── // `existingKeys` are the env-var names already present on the target that the // upload would replace. Comfortable: the original listed every key indented @@ -204,16 +194,6 @@ export const ConfirmCiSecretOverwriteStep: FC ) } -// ── uploading-ci-secrets (spinner) ──────────────────────────────────────────── - -export interface UploadingCiSecretsStepProps { - targetLabel: string -} - -export const UploadingCiSecretsStep: FC = ({ targetLabel }) => ( - -) - // ── ci-secrets-failed (error) ───────────────────────────────────────────────── // `error` is the upload failure detail and can be long (CLI stderr). // Comfortable: the original error line + a + the dim reassurance + diff --git a/cli/src/build/onboarding/ui/steps/ios-ci.tsx b/cli/src/build/onboarding/ui/steps/ios-ci.tsx index 1b28135eca..4bcfea41fe 100644 --- a/cli/src/build/onboarding/ui/steps/ios-ci.tsx +++ b/cli/src/build/onboarding/ui/steps/ios-ci.tsx @@ -181,17 +181,6 @@ export const AskCiSecretsStep: FC = ({ entryCount, target ) -// ── checking-ci-secrets ─────────────────────────────────────────────────────── -export interface CheckingCiSecretsStepProps { - targetLabel: string -} - -export const CheckingCiSecretsStep: FC = ({ targetLabel }) => ( - - - -) - // ── confirm-ci-secret-overwrite ─────────────────────────────────────────────── // Some of the env vars we'd upload already exist remotely. The parent routes // replace → uploading-ci-secrets, skip → build-complete. @@ -227,17 +216,6 @@ export const ConfirmCiSecretOverwriteStep: FC ) } -// ── uploading-ci-secrets ────────────────────────────────────────────────────── -export interface UploadingCiSecretsStepProps { - targetLabel: string -} - -export const UploadingCiSecretsStep: FC = ({ targetLabel }) => ( - - - -) - // ── ci-secrets-failed ───────────────────────────────────────────────────────── // Upload failed, but credentials are already saved locally so the user can // continue. The parent routes retry → checking-ci-secrets (or diff --git a/cli/src/build/onboarding/ui/steps/ios-shared.tsx b/cli/src/build/onboarding/ui/steps/ios-shared.tsx index 4a45851834..106074da2a 100644 --- a/cli/src/build/onboarding/ui/steps/ios-shared.tsx +++ b/cli/src/build/onboarding/ui/steps/ios-shared.tsx @@ -47,14 +47,6 @@ import { Box, Newline, Text } from 'ink' import React from 'react' import { AiResultBanner, ErrorLine, SpinnerLine, SuccessLine } from '../components.js' -// A single Select option. Mirrors the shape @inkjs/ui's Select expects so the -// parent can build option lists and pass them straight through. Matches the -// other step files' SelectOption. -export interface SelectOption { - label: string - value: string -} - // ── welcome ───────────────────────────────────────────────────────────────── export const WelcomeStep: FC = () => ( diff --git a/cli/test/helpers/frame-fit.mjs b/cli/test/helpers/frame-fit.mjs index 1edf65d4b3..6e7c8ef2c8 100644 --- a/cli/test/helpers/frame-fit.mjs +++ b/cli/test/helpers/frame-fit.mjs @@ -8,12 +8,18 @@ // // Contract: each STEP BODY must render within BODY_BUDGET_ROWS rows at every // REFERENCE_WIDTH, which guarantees the full frame (compact header + body + -// padding) fits MAX_FRAME_ROWS. See components.tsx for the constants. +// padding) fits MAX_FRAME_ROWS. The header/padding row costs live in +// components.tsx; the frame budget is a test-only contract, so it's derived +// here from those costs rather than exported from production. import { EventEmitter } from 'node:events' import { render as inkRender } from 'ink' -import { BODY_BUDGET_ROWS, MAX_FRAME_ROWS } from '../../src/build/onboarding/ui/components.tsx' +import { COMPACT_HEADER_ROWS, WIZARD_PADDING_ROWS } from '../../src/build/onboarding/ui/components.tsx' -export { BODY_BUDGET_ROWS, MAX_FRAME_ROWS } +// The 16-row frame floor and the body's share of it (frame minus the one-line +// compact header + the wizard's outer padding = 13). Kept in the test harness +// because only the per-component frame-fit tests consume them. +export const MAX_FRAME_ROWS = 16 +export const BODY_BUDGET_ROWS = MAX_FRAME_ROWS - COMPACT_HEADER_ROWS - WIZARD_PADDING_ROWS // 13 // Widths we guarantee the contract at. 80 = standard; 60 = a narrow case so // text wrapping can't sneak a violation past us. Below ~60 cols the runtime diff --git a/cli/test/helpers/onboarding-fixtures.mjs b/cli/test/helpers/onboarding-fixtures.mjs index a78e01b32e..4a036f1fbf 100644 --- a/cli/test/helpers/onboarding-fixtures.mjs +++ b/cli/test/helpers/onboarding-fixtures.mjs @@ -23,14 +23,14 @@ // takeover / pre-flow frames that hide the progress bar (welcome, no-platform, // build-complete, adding-platform). import React from 'react' +import * as androidCi from '../../src/build/onboarding/ui/steps/android-ci.tsx' import * as ks from '../../src/build/onboarding/ui/steps/android-keystore.tsx' import * as sa from '../../src/build/onboarding/ui/steps/android-sa-gcp.tsx' import * as ash from '../../src/build/onboarding/ui/steps/android-shared.tsx' -import * as aci from '../../src/build/onboarding/ui/steps/android-ci.tsx' +import * as cic from '../../src/build/onboarding/ui/steps/ios-ci.tsx' import * as cred from '../../src/build/onboarding/ui/steps/ios-credentials.tsx' import * as imp from '../../src/build/onboarding/ui/steps/ios-import.tsx' import * as ish from '../../src/build/onboarding/ui/steps/ios-shared.tsx' -import * as cic from '../../src/build/onboarding/ui/steps/ios-ci.tsx' const h = React.createElement const noop = () => {} @@ -117,8 +117,8 @@ export function staticStepFixtures() { f('build-complete', h(ash.BuildCompleteStep, { uploadSummary: null, buildUrl: 'https://capgo.app/app/com.example.app/builds', ...C }), false), f('error', h(ash.ErrorStep, { message: LONG_ERR, onChoose: noop, ...C })), // ── ci ────────────────────────────────────────────────────────────────── - f('ci-secrets-setup', h(aci.CiSecretsSetupStep, { advice: CI_ADVICE, onChoose: noop, ...C })), - f('ci-secrets-target-select', h(aci.CiSecretsTargetSelectStep, { options: opt(8), onChange: noop, ...C })), - f('ask-ci-secrets', h(aci.AskCiSecretsStep, { entryCount: 12, targetLabel: 'GitLab CI/CD', cli: 'glab', onChoose: noop, ...C })), + f('ci-secrets-setup', h(androidCi.CiSecretsSetupStep, { advice: CI_ADVICE, onChoose: noop, ...C })), + f('ci-secrets-target-select', h(androidCi.CiSecretsTargetSelectStep, { options: opt(8), onChange: noop, ...C })), + f('ask-ci-secrets', h(androidCi.AskCiSecretsStep, { entryCount: 12, targetLabel: 'GitLab CI/CD', cli: 'glab', onChoose: noop, ...C })), ] } diff --git a/cli/test/test-frame-fit-decision.mjs b/cli/test/test-frame-fit-decision.mjs deleted file mode 100644 index 1101e127e6..0000000000 --- a/cli/test/test-frame-fit-decision.mjs +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env node -// Tests for the PARENT wizard's adaptive frame-fit DECISION logic — the layer -// the per-component frame-fit tests don't reach. -// -// Regression: the adaptive "comfortable by default, collapse to dense when the -// viewport can't fit it" logic deadlocked. When the comfortable body overflowed -// it reported the frame "too small" (showing the resize prompt) EVEN THOUGH a -// denser form was still available — and because the resize prompt unmounts the -// body, the dense form could never be measured, wedging the wizard on -// "Resize taller — at least N rows" forever. These tests lock the contract: -// • while a denser form is still available, NEVER report too-small; -// • a stale (wrong-density) measurement must not trigger too-small; -// • only block when even the dense form can't fit. -import { - capLogRows, - COMPACT_HEADER_TOTAL_ROWS, - isFrameTooSmall, - shouldCollapseToDense, -} from '../src/build/onboarding/ui/frame-fit.ts' - -let passed = 0 -let failed = 0 -function test(name, fn) { - try { - fn() - passed++ - console.log(`✔ ${name}`) - } - catch (error) { - failed++ - console.error(`✖ ${name}\n ${error.message}`) - } -} -function assert(cond, msg) { - if (!cond) - throw new Error(msg) -} - -// Sanity: compact header + padding = 1 + 2. -test('COMPACT_HEADER_TOTAL_ROWS is 3', () => { - assert(COMPACT_HEADER_TOTAL_ROWS === 3, `expected 3, got ${COMPACT_HEADER_TOTAL_ROWS}`) -}) - -// THE BUG: ai-analysis-result at 17 rows. Comfortable body = 15 → 15+3=18 > 17 -// overflows. While still comfortable (dense=false) we must NOT block — the -// parent collapses to dense instead. The old logic returned true here → resize -// prompt → body unmounts → dense never measured → permanent deadlock. -test('comfortable overflow is NOT too-small while a dense fallback remains', () => { - assert(isFrameTooSmall({ bodyRows: 15, dense: false, terminalRows: 17 }) === false, 'must not block in comfortable mode') -}) - -test('comfortable overflow DOES trigger a collapse-to-dense', () => { - assert(shouldCollapseToDense({ bodyRows: 15, terminalRows: 17 }) === true, 'should flip to dense') -}) - -// Right after the flip the only measurement we have was taken in the OTHER -// density, so the parent passes bodyRows=null. That must render optimistically -// (not block) so the dense body can be measured. -test('stale measurement after a density flip does NOT block', () => { - assert(isFrameTooSmall({ bodyRows: null, dense: true, terminalRows: 17 }) === false, 'null body must render, not block') -}) - -test('dense body that fits is NOT too-small', () => { - assert(isFrameTooSmall({ bodyRows: 8, dense: true, terminalRows: 17 }) === false, 'dense fits → render') -}) - -// Only legitimate too-small: even the dense form overflows. -test('dense body that still overflows IS too-small (legit resize prompt)', () => { - assert(isFrameTooSmall({ bodyRows: 16, dense: true, terminalRows: 12 }) === true, 'dense overflow → block') -}) - -test('comfortable body that fits neither blocks nor collapses', () => { - assert(isFrameTooSmall({ bodyRows: 9, dense: false, terminalRows: 30 }) === false) - assert(shouldCollapseToDense({ bodyRows: 9, terminalRows: 30 }) === false) -}) - -// Pre-measure: only a terminal too short for one-line header + padding + a row -// is blocked before we know the body height. -test('truly tiny terminal blocks pre-measure', () => { - assert(isFrameTooSmall({ bodyRows: null, dense: false, terminalRows: 3 }) === true) - assert(isFrameTooSmall({ bodyRows: null, dense: false, terminalRows: 4 }) === false) -}) - -// ── capLogRows (completed-steps log capping) ───────────────────────────────── -// Each entry is one row (the renderer truncates long lines). capLogRows keeps -// the most-recent entries that fit; when they overflow, a summary line is -// MANDATORY (we never hide that more completed steps exist) and always condenses -// ≥ 2 of them. At a one-row budget the summary wins the row, shown alone. -const mk = (...texts) => texts.map(t => ({ text: t, color: 'green' })) - -test('capLogRows: everything fits → no summary, all shown in order', () => { - const { hidden, visible } = capLogRows(mk('a', 'b', 'c'), 10) - assert(hidden === 0, `expected 0 hidden, got ${hidden}`) - assert(visible.map(e => e.text).join('') === 'abc', 'order/content preserved') -}) - -test('capLogRows: overflow → most-recent kept, rest summarized (1 row reserved)', () => { - const log = mk('s1', 's2', 's3', 's4', 's5', 's6', 's7', 's8') - // budget 5 → reserve 1 for the summary → 4 most-recent entries (s5..s8). - const { hidden, visible } = capLogRows(log, 5) - assert(visible.map(e => e.text).join(',') === 's5,s6,s7,s8', `got ${visible.map(e => e.text)}`) - assert(hidden === 4, `expected 4 hidden, got ${hidden}`) -}) - -test('capLogRows: when summary shows, it always covers ≥ 2 steps', () => { - // The "…and 1 earlier step done" wart: never produce a summary for a single - // hidden step. Sweep counts/budgets and assert hidden is 0 or ≥ 2. - for (let total = 1; total <= 12; total++) { - for (let budget = 1; budget <= 12; budget++) { - const { hidden } = capLogRows(mk(...Array.from({ length: total }, (_, i) => `s${i}`)), budget) - assert(hidden === 0 || hidden >= 2, `hidden=${hidden} (1 is the wart) for total=${total} budget=${budget}`) - } - } -}) - -test('capLogRows: one row + overflow → the summary wins the row (never hide "more steps")', () => { - // budget 1, 3 entries: the "…and N" summary is mandatory. It takes the only - // row (shown alone, covering all 3) rather than a single newest step that - // would silently hide that two more completed steps exist. - const { hidden, visible } = capLogRows(mk('a', 'b', 'c'), 1) - assert(visible.length === 0, `expected the summary to take the only row, got ${visible.length} step(s)`) - assert(hidden === 3, `expected all 3 summarized, got hidden=${hidden}`) -}) - -test('capLogRows: zero budget shows nothing (no room, not even a summary)', () => { - const { hidden, visible } = capLogRows(mk('a', 'b'), 0) - assert(visible.length === 0 && hidden === 0, 'nothing rendered when no rows') -}) - -console.log(`\n${passed} passed, ${failed} failed`) -process.exit(failed > 0 ? 1 : 0) From 409224b170c605ee129915d1cd0e8e32bb86bb4d Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sun, 31 May 2026 08:01:53 +0200 Subject: [PATCH 04/14] =?UTF-8?q?fix(cli):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20build-viewer=20gate=20order=20+=20test=20arg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - android/ui/app.tsx: move the requesting-build + AI-scroll fullscreen takeovers BEFORE the min-size gate (matching the iOS app). Previously a mid-build/mid-AI terminal shrink hid the live streaming view behind the resize prompt. The gate now runs after the takeovers, still guarding the normal step body. (CodeRabbit) - test-frame-fit-log.mjs: fix frame(longLog, rows, 80, 6) → frame(longLog, rows, 6); frame() takes (logEntries, rows, bodyHeight), so 80 was wrongly used as bodyHeight and 6 ignored. Restores the intended uncapped-overflow assertion. (CodeRabbit) --- cli/src/build/onboarding/android/ui/app.tsx | 32 ++++++++++----------- cli/test/test-frame-fit-log.mjs | 2 +- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/cli/src/build/onboarding/android/ui/app.tsx b/cli/src/build/onboarding/android/ui/app.tsx index 4b5942a3da..d542712ba7 100644 --- a/cli/src/build/onboarding/android/ui/app.tsx +++ b/cli/src/build/onboarding/android/ui/app.tsx @@ -2064,26 +2064,16 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const showLog = step !== 'requesting-build' && step !== 'build-complete' && !isAiStep && !tallStep // Streaming build output is a fullscreen takeover — see iOS sibling. As an - // early return (before the `tooSmall` guard) it auto-tails inside a viewport - // that always fits, so the unbounded output never trips "terminal too small". - // Size gate (resize-reactive): below the floor, render the resize prompt from - // THIS mounted component so all in-progress state (current step, entered - // values, build output) is preserved — a shrink shows the prompt, a re-grow - // shows the exact same step. Kept as an early return after all hooks so the - // rules of hooks hold. The wizard never clips, and never exits, on resize. - if (!terminalFitsOnboarding(terminalCols, terminalRows, 'android')) - return - + // early return BEFORE the size gate it auto-tails inside a viewport that always + // fits, so the unbounded output never trips "terminal too small": shrinking the + // window mid-build keeps the live log on screen instead of hiding it behind the + // resize prompt. (Matches the iOS app's ordering.) if (step === 'requesting-build') return - // (No in-app "terminal too small" guard: the startup MinSizeGate in the shell - // guarantees the terminal is large enough before the wizard mounts, so a - // mid-flow too-small state can't occur.) - - // Fullscreen AI viewer is a takeover — early return so it owns the whole - // terminal and bypasses the body-measurement / dense / too-small logic (see - // iOS sibling). It fills the screen itself via minHeight. + // Fullscreen AI viewer is a takeover too — early return BEFORE the gate so it + // owns the whole terminal (it paginates to the live size itself) and a mid-view + // shrink can't replace the scrollable analysis with the resize prompt. if (isAiResultScroll && aiAnalysisText) return ( = ({ appId, initialProgress, androidDir /> ) + // Size gate (resize-reactive): below the enforced floor, render the resize + // prompt from THIS mounted component so all in-progress state is preserved — a + // shrink shows the prompt, a re-grow shows the exact same step. Placed AFTER the + // fullscreen build/AI takeovers above (which own the whole screen and must not + // be hidden by the prompt) but before the normal step body. Matches iOS. + if (!terminalFitsOnboarding(terminalCols, terminalRows, 'android')) + return + // `minHeight={terminalRows}` fills the viewport so Ink always uses its full // clear-screen redraw path, which avoids stale rows lingering after the // terminal shrinks. See iOS sibling for the full explanation. diff --git a/cli/test/test-frame-fit-log.mjs b/cli/test/test-frame-fit-log.mjs index 235722afa2..888af7426e 100644 --- a/cli/test/test-frame-fit-log.mjs +++ b/cli/test/test-frame-fit-log.mjs @@ -102,7 +102,7 @@ for (const rows of [16, 19, 24]) { test('UNCAPPED long log overflows — proving the cap is what prevents too-small', () => { const rows = 19 - const uncapped = frameRows(frame(longLog, rows, 80, 6), 80) + const uncapped = frameRows(frame(longLog, rows, 6), 80) assert(uncapped > rows, `expected the uncapped 30-entry log to overflow ${rows} rows, got ${uncapped}`) }) From 87f9cc4f325227cf64a5970633288426aad7c5ba Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sun, 31 May 2026 08:11:11 +0200 Subject: [PATCH 05/14] fix(cli): restore node engines floor to >=20 (was accidentally bumped to >=22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `>=22.0.0` floor was introduced inadvertently while merging package.json versions during the rebase — main requires `>=20.0.0` and no Node-22-only API is used in cli/src. Revert to `>=20.0.0` to avoid an unintended breaking change for downstream consumers. (Caught by CodeRabbit on PR #2376.) --- cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/package.json b/cli/package.json index 5407a71eb3..93e406765b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -49,7 +49,7 @@ ], "engines": { "npm": ">=8.0.0", - "node": ">=22.0.0" + "node": ">=20.0.0" }, "scripts": { "build": "tsc && bun build.mjs", From af6ffd4dd11c9610d01989f758533f24d3666c37 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sun, 31 May 2026 11:13:33 +0200 Subject: [PATCH 06/14] =?UTF-8?q?fix(cli):=20accurate=20onboarding=20exit?= =?UTF-8?q?=20=E2=80=94=20gate=20success=20message,=20surface=20durable=20?= =?UTF-8?q?summary,=20fix=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three review findings on the build-init onboarding PR: - [P1] False success on cancel/error: command.ts printed "✔ Capgo onboarding complete" after waitUntilExit() on EVERY exit — including missing-platform (Android exits after 2s) and user-cancel paths. Add an OnboardingResult contract: the shell defaults to { outcome: 'cancelled' }, and each app reports { outcome: 'completed', summary } ONLY when it reaches build-complete. command.ts prints the ✔ line only on 'completed'; otherwise a neutral "exited — not completed" line. No false success. - [P2] Final actionable output lost to the alt-screen teardown: the build URL, workflow path, .env path and CI-secret summary rendered by BuildCompleteStep were erased when Ink restored the primary buffer on exit. The completion summary is now carried out through onResult and reprinted to the PRIMARY buffer by command.ts, so the build URL + generated file paths survive. - [P2] Version regression: cli/package.json was 7.119.0 (fork-point) vs base 7.119.1. Bumped to 7.119.1 (+ lockfile) to avoid a publish/release-automation regression. Build green, cli:lint clean, knip 0, all onboarding suites pass. --- bun.lock | 2 +- cli/package.json | 2 +- cli/src/build/onboarding/android/ui/app.tsx | 20 +++++++++- cli/src/build/onboarding/command.ts | 41 ++++++++++++++++++--- cli/src/build/onboarding/types.ts | 25 +++++++++++++ cli/src/build/onboarding/ui/app.tsx | 22 ++++++++++- cli/src/build/onboarding/ui/shell.tsx | 13 +++++-- 7 files changed, 111 insertions(+), 14 deletions(-) diff --git a/bun.lock b/bun.lock index 19a97b4b33..ef390594a8 100644 --- a/bun.lock +++ b/bun.lock @@ -207,7 +207,7 @@ }, "cli": { "name": "@capgo/cli", - "version": "7.119.0", + "version": "7.119.1", "bin": { "capgo": "dist/index.js", }, diff --git a/cli/package.json b/cli/package.json index 93e406765b..1efec3b242 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@capgo/cli", "type": "module", - "version": "7.119.0", + "version": "7.119.1", "description": "A CLI to upload to capgo servers", "author": "Martin martin@capgo.app", "license": "Apache 2.0", diff --git a/cli/src/build/onboarding/android/ui/app.tsx b/cli/src/build/onboarding/android/ui/app.tsx index d542712ba7..f0ef51232b 100644 --- a/cli/src/build/onboarding/android/ui/app.tsx +++ b/cli/src/build/onboarding/android/ui/app.tsx @@ -13,6 +13,7 @@ import type { PlayInviteProvisioned, ServiceAccountProvisioned, } from '../types.js' +import type { OnboardingResult } from '../../types.js' import { handleCustomMsg } from '../../../qr.js' import { existsSync, readFileSync } from 'node:fs' import { copyFile, readFile } from 'node:fs/promises' @@ -163,6 +164,10 @@ interface AppProps { androidDir: string /** Optional Capgo API key passed via -a/--apikey flag; takes precedence over saved key. */ apikey?: string + /** Reports the wizard outcome to the shell when it reaches build-complete, so + * the caller prints an accurate post-exit message + durable summary instead of + * always claiming success. Never fires on cancel/missing-platform exits. */ + onResult?: (result: OnboardingResult) => void } const RELEASE_ALIAS_DEFAULT = 'release' @@ -199,7 +204,7 @@ function emptyProgress(appId: string): AndroidOnboardingProgress { } } -const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir, apikey }) => { +const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir, apikey, onResult }) => { const { exit } = useApp() const startStep: AndroidOnboardingStep = getAndroidResumeStep(initialProgress) @@ -1985,6 +1990,19 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir if (step === 'build-complete') { setBuildOutput([]) + // Report a successful outcome + durable summary to the shell/caller so it + // can reprint the build URL + generated file paths to the PRIMARY buffer + // (the alt-screen final frame is wiped on exit). ONLY place that fires + // 'completed'; every other exit stays 'cancelled' by default. + onResult?.({ + outcome: 'completed', + summary: { + buildUrl: buildUrl || undefined, + ciSecretUploadSummary, + workflowFilePath: workflowWrittenPath, + envExportPath, + }, + }) // Best-effort cleanup of any leftover captured log. if (aiJobId) { void releaseCapturedLogs(aiJobId).catch(() => { /* best-effort */ }) diff --git a/cli/src/build/onboarding/command.ts b/cli/src/build/onboarding/command.ts index 9f9c6610e6..c417c7b961 100644 --- a/cli/src/build/onboarding/command.ts +++ b/cli/src/build/onboarding/command.ts @@ -8,6 +8,7 @@ import React from 'react' import { getAppId, getConfig } from '../../utils.js' import { getPlatformDirFromCapacitorConfig } from '../platform-paths.js' import OnboardingShell from './ui/shell.js' +import type { OnboardingResult } from './types.js' export interface OnboardingBuilderOptions { apikey?: string @@ -107,6 +108,10 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions // else once the user picks). Capture it so the breadcrumb below — printed // after Ink restores the primary buffer — names the right platform. let resolvedPlatform: Platform | undefined = initialPlatform + // Default to 'cancelled': the wizard reports 'completed' (with a summary) ONLY + // when it reaches build-complete. Any other exit (missing platform, user + // cancel, error) leaves this untouched, so we never claim false success. + let result: OnboardingResult = { outcome: 'cancelled' } const { waitUntilExit } = render( React.createElement(OnboardingShell, { appId, @@ -117,14 +122,40 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions onResolvePlatform: (platform: Platform) => { resolvedPlatform = platform }, + onResult: (r: OnboardingResult) => { + result = r + }, }), { alternateScreen: true }, ) await waitUntilExit() - // Durable breadcrumb in the user's normal terminal flow — the alt buffer - // restore wiped the wizard's last frame. Written via process.stdout to - // bypass the project-wide no-console lint rule (one-shot UX message, not - // application logging). - process.stdout.write(`\n✔ Capgo onboarding complete for ${appId}${resolvedPlatform ? ` (${resolvedPlatform})` : ''}.\n`) + // Durable post-exit output in the user's normal terminal flow — the alt buffer + // restore wiped the wizard's last frame, so anything the user needs to keep + // (build URL, generated file paths) must be reprinted here. Written via + // process.stdout to bypass the project-wide no-console lint rule (one-shot UX + // message, not application logging). + if (result.outcome === 'completed') { + const platformSuffix = resolvedPlatform ? ` (${resolvedPlatform})` : '' + process.stdout.write(`\n✔ Capgo onboarding complete for ${appId}${platformSuffix}.\n`) + const s = result.summary + if (s) { + if (s.buildUrl) + process.stdout.write(` Build: ${s.buildUrl}\n`) + if (s.workflowFilePath) + process.stdout.write(` Workflow: ${s.workflowFilePath}\n`) + if (s.envExportPath) + process.stdout.write(` Env file: ${s.envExportPath}\n`) + if (s.ciSecretUploadSummary) + process.stdout.write(` Secrets: ${s.ciSecretUploadSummary}\n`) + if (s.buildRequestCommand) + process.stdout.write(` Run anytime: ${s.buildRequestCommand}\n`) + } + } + else { + // Cancelled / incomplete — do NOT claim success. The wizard already showed + // the user why it stopped (e.g. the "no native platform" screen); this is + // just a neutral closing line so the exit isn't silent. + process.stdout.write(`\nCapgo onboarding exited — setup not completed. Re-run \`capgo build init\` to continue.\n`) + } } diff --git a/cli/src/build/onboarding/types.ts b/cli/src/build/onboarding/types.ts index 2a7af52e29..962fcf26a1 100644 --- a/cli/src/build/onboarding/types.ts +++ b/cli/src/build/onboarding/types.ts @@ -2,6 +2,31 @@ export type Platform = 'ios' | 'android' +// The outcome a wizard app reports to the shell/command when Ink exits, so the +// caller can print an accurate post-exit message instead of always claiming +// success. The shell defaults to `cancelled`; an app flips it to `completed` +// (with a durable summary) only when it actually reaches the build-complete +// screen. This fixes the false "✔ onboarding complete" that printed on every +// exit path (missing-platform, user-cancel, etc.). +export interface OnboardingCompletionSummary { + /** The Capgo dashboard build URL, when a build was kicked off. */ + buildUrl?: string + /** One-line CI-secret upload summary, when secrets were pushed. */ + ciSecretUploadSummary?: string | null + /** Path to the generated GitHub Actions workflow file, when written. */ + workflowFilePath?: string | null + /** Path to the exported .env file, when the user chose the env-export fallback. */ + envExportPath?: string | null + /** The "run anytime" build-request command shown on the final screen. */ + buildRequestCommand?: string +} + +export interface OnboardingResult { + outcome: 'completed' | 'cancelled' + /** Present only when outcome === 'completed'. */ + summary?: OnboardingCompletionSummary +} + export type OnboardingStep = | 'welcome' | 'platform-select' diff --git a/cli/src/build/onboarding/ui/app.tsx b/cli/src/build/onboarding/ui/app.tsx index c7ae95c1a1..06a71cb5e9 100644 --- a/cli/src/build/onboarding/ui/app.tsx +++ b/cli/src/build/onboarding/ui/app.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react' import type { BuildLogger } from '../../request.js' import type { DiscoveredProfile, IdentityProfileMatch, SigningIdentity } from '../macos-signing.js' -import type { ApiKeyData, CertificateData, OnboardingErrorCategory, OnboardingProgress, OnboardingStep, ProfileData } from '../types.js' +import type { ApiKeyData, CertificateData, OnboardingErrorCategory, OnboardingProgress, OnboardingResult, OnboardingStep, ProfileData } from '../types.js' import { handleCustomMsg } from '../../qr.js' import { spawn } from 'node:child_process' import { Buffer } from 'node:buffer' @@ -128,6 +128,10 @@ interface AppProps { iosDir: string /** Optional Capgo API key passed via -a/--apikey flag; takes precedence over saved key */ apikey?: string + /** Reports the wizard outcome to the shell when it reaches build-complete, so + * the caller prints an accurate post-exit message + durable summary instead of + * always claiming success. Never fires on cancel/missing-platform exits. */ + onResult?: (result: OnboardingResult) => void } async function runRunnerCommand(runner: string, args: string[]): Promise<{ success: boolean, output: string[] }> { @@ -167,7 +171,7 @@ async function runRunnerCommand(runner: string, args: string[]): Promise<{ succe }) } -const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) => { +const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, onResult }) => { const { exit } = useApp() const startStep = getResumeStep(initialProgress) @@ -1849,6 +1853,20 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) if (step === 'build-complete') { setBuildOutput([]) + // Report a successful outcome + the durable summary to the shell/caller, so + // it can reprint the build URL + generated file paths to the PRIMARY buffer + // (the alt-screen final frame is wiped on exit). This is the ONLY place that + // fires 'completed'; every other exit stays 'cancelled' by default. + onResult?.({ + outcome: 'completed', + summary: { + buildUrl: buildUrl || undefined, + ciSecretUploadSummary, + workflowFilePath: workflowWrittenPath, + envExportPath, + buildRequestCommand, + }, + }) // Best-effort cleanup of any leftover captured log file. Safe to call // even if we never entered the AI flow (operates only on jobs we know). if (aiJobId) { diff --git a/cli/src/build/onboarding/ui/shell.tsx b/cli/src/build/onboarding/ui/shell.tsx index 22fe5cc70e..9579d1ae3d 100644 --- a/cli/src/build/onboarding/ui/shell.tsx +++ b/cli/src/build/onboarding/ui/shell.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react' -import type { Platform } from '../types.js' +import type { OnboardingResult, Platform } from '../types.js' // src/build/onboarding/ui/shell.tsx // // Top-level wizard shell, rendered ONCE inside the alt-screen buffer @@ -82,9 +82,14 @@ export interface OnboardingShellProps { initialPlatform?: Platform /** Called once a platform is chosen so the caller can print the completion breadcrumb. */ onResolvePlatform?: (platform: Platform) => void + /** Called by the mounted app when it reaches the build-complete screen, so the + * caller prints the accurate post-exit message + durable summary. If the wizard + * exits any other way (cancel / missing platform), this never fires and the + * caller treats it as cancelled. */ + onResult?: (result: OnboardingResult) => void } -const OnboardingShell: FC = ({ appId, iosDir, androidDir, apikey, initialPlatform, onResolvePlatform }) => { +const OnboardingShell: FC = ({ appId, iosDir, androidDir, apikey, initialPlatform, onResolvePlatform, onResult }) => { const { cols, rows } = useTerminalSize() const [ready, setReady] = useState(null) @@ -109,9 +114,9 @@ const OnboardingShell: FC = ({ appId, iosDir, androidDir, // exiting the wizard. The app owns the size decision so a shrink→regrow keeps // the user exactly where they were. if (ready?.kind === 'ios') - return + return if (ready?.kind === 'android') - return + return // Not ready yet: the platform picker (or a brief framed load). The picker is // NOT gated to the full 80×49 onboarding floor — it's small and adapts From 5cd30fbeddee0d1e72be165df593c7766d81f8c5 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sun, 31 May 2026 11:33:52 +0200 Subject: [PATCH 07/14] =?UTF-8?q?fix(cli):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20wire=20watchdog=20tests=20into=20CI,=20handle=20loadReady=20?= =?UTF-8?q?rejection,=20fix=20stale=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package.json: wire test:build-log-sanitize + test:build-output-viewport into the aggregate `test` chain. These regression guards existed but no runner executed them (run-frame-fit only globs test-frame-fit-*). (CodeRabbit) - shell.tsx: choose() now .catch()es loadReady — loadProgress throws on corrupt saved-progress JSON, which previously produced an unhandled rejection + a stuck picker. On failure it shows an error frame, reports { outcome: 'cancelled' }, and exits. (CodeRabbit) - Doc-only: correct stale comments that described the dropped adaptive `dense` behavior (ios-credentials ApiKeyInstructionsStep, ios-shared BuildCompleteStep), the MinSizeGate picker-gating note (the shell gates the picker via terminalFitsPicker, not MinSizeGate), and "binary-search" → "linear scan" in size-search.mjs. No behavior change in these four. Build green, cli:lint clean, knip 0, onboarding suites pass. --- cli/package.json | 6 ++-- cli/src/build/onboarding/ui/min-size-gate.tsx | 11 ++++-- cli/src/build/onboarding/ui/shell.tsx | 35 +++++++++++++++++-- .../onboarding/ui/steps/ios-credentials.tsx | 14 ++++---- .../build/onboarding/ui/steps/ios-shared.tsx | 10 +++--- cli/test/helpers/size-search.mjs | 4 +-- 6 files changed, 58 insertions(+), 22 deletions(-) diff --git a/cli/package.json b/cli/package.json index 3a012c7346..b95e314937 100644 --- a/cli/package.json +++ b/cli/package.json @@ -105,7 +105,7 @@ "test:macos-signing": "bun test/test-macos-signing.mjs", "test:apple-api-import-helpers": "bun test/test-apple-api-import-helpers.mjs", "test:manifest-path-encoding": "bun test/test-manifest-path-encoding.mjs", - "test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && 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: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:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && 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-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-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", + "test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && 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: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:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && 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-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-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", "test:build-platform-selection": "bun test/test-build-platform-selection.mjs", "test:ai-log-capture": "bun test/test-ai-log-capture.mjs", "test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs", @@ -116,7 +116,9 @@ "test:frame-fit": "bun test/run-frame-fit.mjs", "test:onboarding-min-size": "bun test/test-onboarding-min-size.mjs", "test:min-size-gate": "bun test/test-min-size-gate.mjs", - "test:shell-size-gate": "bun test/test-shell-size-gate.mjs" + "test:shell-size-gate": "bun test/test-shell-size-gate.mjs", + "test:build-log-sanitize": "bun test/test-build-log-sanitize.mjs", + "test:build-output-viewport": "bun test/test-build-output-viewport.mjs" }, "dependencies": { "@inkjs/ui": "^2.0.0", diff --git a/cli/src/build/onboarding/ui/min-size-gate.tsx b/cli/src/build/onboarding/ui/min-size-gate.tsx index 886843bcda..feece52d8d 100644 --- a/cli/src/build/onboarding/ui/min-size-gate.tsx +++ b/cli/src/build/onboarding/ui/min-size-gate.tsx @@ -14,9 +14,14 @@ import type { FC, ReactNode } from 'react' // in-progress step state and (via Ink teardown effects) could exit the whole // wizard. Keeping it mounted means a shrink shows the prompt and a re-grow // shows the exact same step, with no lost state and no exit. -// • MinSizeGate — a convenience wrapper (fits ? children : prompt) for callers -// with no precious state to preserve (e.g. the shell's pre-platform picker), -// where unmounting children on resize is harmless. +// • MinSizeGate — a convenience wrapper (fits ? children : prompt), gated on the +// full onboarding floor (terminalFitsOnboarding), for callers with no precious +// state to preserve where unmounting children on resize is harmless. NOTE: the +// shell does NOT use this for the platform picker — the picker has its own, +// much smaller floor and is gated directly with terminalFitsPicker + +// TerminalTooSmallPrompt(PICKER_MIN_*) in shell.tsx. MinSizeGate is currently +// unused by the picker path; it remains for any caller wanting the full-floor +// wrapper. // // Both are resize-reactive: callers pass cols/rows from useTerminalSize, which // re-renders on every resize event. diff --git a/cli/src/build/onboarding/ui/shell.tsx b/cli/src/build/onboarding/ui/shell.tsx index 9579d1ae3d..3f9a84dd53 100644 --- a/cli/src/build/onboarding/ui/shell.tsx +++ b/cli/src/build/onboarding/ui/shell.tsx @@ -18,7 +18,7 @@ import type { OnboardingResult, Platform } from '../types.js' // // `command.ts` passes `initialPlatform` to skip the picker, and // `onResolvePlatform` so it can print the post-exit completion breadcrumb. -import { Box, useStdout } from 'ink' +import { Box, Text, useApp, useStdout } from 'ink' import React, { useCallback, useEffect, useState } from 'react' import { loadAndroidProgress } from '../android/progress.js' import AndroidOnboardingApp from '../android/ui/app.js' @@ -90,16 +90,31 @@ export interface OnboardingShellProps { } const OnboardingShell: FC = ({ appId, iosDir, androidDir, apikey, initialPlatform, onResolvePlatform, onResult }) => { + const { exit } = useApp() const { cols, rows } = useTerminalSize() const [ready, setReady] = useState(null) + // Set when progress loading fails (e.g. corrupt saved-progress JSON). loadProgress + // throws for non-ENOENT errors, so without a rejection handler `choose` would + // leave an unhandled promise rejection and the picker stuck with no feedback. + const [loadError, setLoadError] = useState(null) // Begin loading the chosen platform's progress; mount the app once it lands. // The picker stays on screen during the (few-ms) load, so there's no loading // frame on the picker path. const choose = useCallback((platform: Platform) => { onResolvePlatform?.(platform) - void loadReady(platform, appId).then(setReady) - }, [appId, onResolvePlatform]) + void loadReady(platform, appId) + .then(setReady) + .catch((err: unknown) => { + // Surface the failure instead of hanging: show an error frame, report a + // cancelled outcome (so the caller doesn't claim success), and exit. The + // common cause is unreadable/corrupt saved progress on disk. + const message = err instanceof Error ? err.message : String(err) + setLoadError(message) + onResult?.({ outcome: 'cancelled' }) + setTimeout(() => exit(), 50) + }) + }, [appId, onResolvePlatform, onResult, exit]) // Pre-resolved platform → load immediately (no picker shown). useEffect(() => { @@ -107,6 +122,20 @@ const OnboardingShell: FC = ({ appId, iosDir, androidDir, choose(initialPlatform) }, [initialPlatform, choose]) + // Progress load failed (corrupt/unreadable saved state) — show why and exit, + // rather than hanging on a frozen picker. The exit is scheduled in the .catch. + if (loadError) { + return ( + + {`✖ Could not load onboarding progress for ${appId}.`} + {loadError} + + Your saved progress file may be corrupt. Remove it and re-run `capgo build init`. + + + ) + } + // Render the chosen app DIRECTLY (no MinSizeGate wrapper). Each app self-gates // internally (renders the resize prompt from its own render when the terminal // is too small) so it STAYS MOUNTED across resizes — wrapping it in a gate diff --git a/cli/src/build/onboarding/ui/steps/ios-credentials.tsx b/cli/src/build/onboarding/ui/steps/ios-credentials.tsx index 23fe6bc60b..de5e39a0db 100644 --- a/cli/src/build/onboarding/ui/steps/ios-credentials.tsx +++ b/cli/src/build/onboarding/ui/steps/ios-credentials.tsx @@ -122,13 +122,13 @@ export const SetupMethodSelectStep: FC = ({ dense = // (Select) or a direct path input (FilteredTextInput). The submit handler for // the no-picker path is owned by the parent. Telemetry/file-reads happen there. // -// Comfortable: the info Alert, a , the FOUR numbered setup steps in a -// marginLeft box, a , the "Press Ctrl+O" hint, a , a -// Divider, another , then the control (with a before the -// picker Select). Dense: the steps collapse to THREE terser lines (the Ctrl+O -// hint folds into step 3), the standalone Ctrl+O line + blank lines drop, and -// the Divider sits directly above the control so the instructions + control fit -// the budget at 60 cols. +// Renders the full comfortable form: the info Alert, a , the FOUR +// numbered setup steps in a marginLeft box, a , the "Press Ctrl+O" +// hint, a , a Divider, another , then the control (with a +// before the picker Select). (The startup size gate guarantees the +// terminal is tall enough, so the old adaptive `dense` collapse was dropped; the +// `dense` prop is accepted for call-site compatibility but no longer alters the +// layout — see the per-platform floor in min-terminal-size.ts.) export interface ApiKeyInstructionsStepProps { canUseFilePicker: boolean dense?: boolean diff --git a/cli/src/build/onboarding/ui/steps/ios-shared.tsx b/cli/src/build/onboarding/ui/steps/ios-shared.tsx index 106074da2a..184a08e20b 100644 --- a/cli/src/build/onboarding/ui/steps/ios-shared.tsx +++ b/cli/src/build/onboarding/ui/steps/ios-shared.tsx @@ -458,11 +458,11 @@ export const ErrorStep: FC = ({ error, recoveryAdvice, supportBu // ── build-complete ────────────────────────────────────────────────────────────── // Final success screen. `buildUrl` (when a build was kicked off) and // `ciSecretUploadSummary` (when env vars were uploaded) are optional details. -// `buildRequestCommand` is shown as the "run anytime" hint. The bordered box is -// kept in BOTH forms (this is a terminal frame, not an interactive step that -// risks clipping). The comfortable form (default) restores the original -// `paddingY={1}` inside the box plus the blank-line spacing around and inside -// it; the dense form drops that vertical padding/spacing to fit the floor. +// `buildRequestCommand` is shown as the "run anytime" hint. Always renders the +// comfortable form: the bordered box with `paddingY={1}` plus the blank-line +// spacing around and inside it. (The startup size gate guarantees enough rows, +// so the old adaptive `dense` collapse was dropped; the `dense` prop is accepted +// for call-site compatibility but no longer alters the layout.) export interface BuildCompleteStepProps { buildUrl: string ciSecretUploadSummary: string | null diff --git a/cli/test/helpers/size-search.mjs b/cli/test/helpers/size-search.mjs index c9b3d763c7..41236075b2 100644 --- a/cli/test/helpers/size-search.mjs +++ b/cli/test/helpers/size-search.mjs @@ -8,8 +8,8 @@ // The search is two independent 1-D searches because cols and rows affect fit // almost separably: cols drives text WRAPPING (narrower → taller frames), rows // is the vertical budget. We: -// 1. Pick a generous rows budget, binary-search the minimal COLS at which no -// frame's natural width is mangled and wrapping stays bounded. +// 1. Pick a generous rows budget, linearly scan upward for the minimal COLS at +// which no frame's natural width is mangled and wrapping stays bounded. // 2. At that cols, find the minimal ROWS = max over frames of naturalRows // (the tallest frame's height) — that's the vertical floor. // 3. Verify the (cols, rows) pair passes all checks together. From f55d6b6fcce1d36053cb3cbd4fa4729184980b17 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sun, 31 May 2026 11:59:56 +0200 Subject: [PATCH 08/14] fix(cli): expand tabs in build-log sanitizer so truncation stops eating line ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fastlane indents log lines with literal tabs. The build viewer renders each line in a single that truncates by CHARACTER count, but a terminal renders a tab as 1–8 columns (advance to the next multiple of 8). So Ink budgeted a tab as 1 column while the terminal drew up to 8 — the line overflowed the width and the terminal clipped the tail, dropping the last visible char of every tab-indented line ("* App" → "* Ap", "* Release" → "* Releas"). Flush-left lines were unaffected, which is the tell. sanitizeBuildLogLines now expands tabs to spaces (per-column, to the next tab stop — not a blind 8-space swap) after stripping ANSI and before truncation, so the character count matches the rendered width. Regression guards added: test-build-log-sanitize (tab→spaces, no literal tab survives, mid-line stop math) and test-build-output-viewport (tab-indented lines render full text through the real sanitize→VT path). Reproduced the bug + verified the fix with @xterm/headless. --- cli/src/build/onboarding/build-log.ts | 43 ++++++++++++++++++++++--- cli/test/test-build-log-sanitize.mjs | 9 ++++-- cli/test/test-build-output-viewport.mjs | 22 +++++++++++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/cli/src/build/onboarding/build-log.ts b/cli/src/build/onboarding/build-log.ts index c509ba09af..7e6a712ad5 100644 --- a/cli/src/build/onboarding/build-log.ts +++ b/cli/src/build/onboarding/build-log.ts @@ -33,10 +33,41 @@ const CSI_RE = /\x1B\[[0-9;?]*[ -/]*[@-~]/g // eslint-disable-next-line no-control-regex const ESC_RE = /\x1B[@-Z\\-_]/g // Remaining C0 control bytes + DEL, EXCLUDING tab (\x09), LF (\x0A) and CR -// (\x0D) — LF/CR are handled by the split above, tab is legitimate indentation. +// (\x0D) — LF/CR are handled by the split above; tabs are expanded to spaces by +// expandTabs() BEFORE this strip, so by the time CTRL_RE runs there are no tabs +// left to preserve. // eslint-disable-next-line no-control-regex const CTRL_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g +// Tab stop width. Fastlane indents log lines with literal tabs; the viewer +// renders each line in a single , which truncates by +// CHARACTER count. A terminal renders a tab as 1..8 columns (advance to the next +// multiple of 8), so Ink budgets a tab as 1 column while the terminal draws up +// to 8 — the line overflows the width, and the terminal clips the tail, eating +// the last visible character(s) of every tab-indented line (e.g. "* App" → "* Ap"). +// Expanding tabs to spaces up front makes the character count match the rendered +// width, so truncation lands where Ink thinks it does. +const TAB_WIDTH = 8 + +// Replace each tab with spaces to the next tab stop, per column position. (Not a +// blind 8-space swap: a tab one column into the line advances 7 cols, not 8.) +function expandTabs(line: string): string { + let out = '' + let col = 0 + for (const ch of line) { + if (ch === '\t') { + const pad = TAB_WIDTH - (col % TAB_WIDTH) + out += ' '.repeat(pad) + col += pad + } + else { + out += ch + col += 1 + } + } + return out +} + /** * Turn a raw streamed build-log chunk into clean display lines. * @@ -53,7 +84,11 @@ export function sanitizeBuildLogLines(chunk: string): string[] { // real blank line; drop only that one. Interior blanks are preserved. if (parts.length > 1 && parts[parts.length - 1] === '') parts.pop() - return parts.map(line => - line.replace(CSI_RE, '').replace(ESC_RE, '').replace(CTRL_RE, '').replace(/\s+$/, ''), - ) + return parts.map((line) => { + // Strip ANSI/escape first (so tab positions are computed on visible text), + // then expand tabs to spaces (fixes truncation eating the last char of + // tab-indented lines), then drop remaining control bytes + trailing ws. + const noAnsi = line.replace(CSI_RE, '').replace(ESC_RE, '') + return expandTabs(noAnsi).replace(CTRL_RE, '').replace(/\s+$/, '') + }) } diff --git a/cli/test/test-build-log-sanitize.mjs b/cli/test/test-build-log-sanitize.mjs index 63329143db..32c7d5eaa8 100644 --- a/cli/test/test-build-log-sanitize.mjs +++ b/cli/test/test-build-log-sanitize.mjs @@ -73,8 +73,13 @@ const FUSED_RAW = `${ZIP}\rCruising 🚗` // one stored line: zip then in-place check('ANSI SGR stripped', sanitizeBuildLogLines(`${ESC}[36mcolored${ESC}[0m`).join('|') === 'colored') check('CSI erase-line stripped', sanitizeBuildLogLines(`text${ESC}[K`)[0] === 'text') -// 3. C0 control bytes stripped, tab kept -check('BEL stripped, tab kept', sanitizeBuildLogLines('a\x07b\tc')[0] === 'ab\tc') +// 3. C0 control bytes stripped; tabs EXPANDED to spaces (not kept) so the viewer's +// char-count truncation matches the terminal's rendered width — otherwise a tab +// (1 char, ~8 cols) overflows and the terminal eats the line's last char(s). +check('BEL stripped', sanitizeBuildLogLines('a\x07bc')[0] === 'abc') +check('leading tab → 8 spaces to the tab stop', sanitizeBuildLogLines('\t* App')[0] === ' * App') +check('no literal tab survives', !sanitizeBuildLogLines('a\tb\tc')[0].includes('\t')) +check('mid-line tab advances to next stop (not blind 8)', sanitizeBuildLogLines('ab\tc')[0] === 'ab c') // col 2 → pad 6 → col 8 // 4. line-break normalisation + blank handling check('CRLF splits', JSON.stringify(sanitizeBuildLogLines('a\r\nb')) === JSON.stringify(['a', 'b'])) diff --git a/cli/test/test-build-output-viewport.mjs b/cli/test/test-build-output-viewport.mjs index ea9ffc7c9e..c87417449b 100644 --- a/cli/test/test-build-output-viewport.mjs +++ b/cli/test/test-build-output-viewport.mjs @@ -96,6 +96,28 @@ check('giant line is shown once, truncated', grid.some(r => r.startsWith('"CAPGO // The lines AFTER the giant line are still visible (weren't pushed off-screen). check('lines after the giant line remain visible', grid.some(r => r.includes(TAIL.trim()))) +// Regression: tab-indented fastlane lines must render intact. The viewer +// truncates by char count; a literal tab (1 char, up to 8 cols) used to overflow +// the width so the terminal clipped the last char ("* App" → "* Ap"). sanitize +// now expands tabs, so the full text survives. Pipe a tabbed chunk through the +// sanitizer (the real path) into the viewer and assert the tails are intact. +{ + const { sanitizeBuildLogLines } = await import('../src/build/onboarding/build-log.ts') + const tabbed = sanitizeBuildLogLines('Modified Targets:\n\t* App\n\t* Release\nStep: update_project_team') + const so = makeStdout(COLS, ROWS) + const inst2 = render( + React.createElement(FullscreenBuildOutput, { title: 'Building...', lines: tabbed, terminalRows: ROWS }), + { stdout: so, stderr: makeStdout(COLS, ROWS), stdin: makeStdin(), debug: true, exitOnCtrlC: false, patchConsole: false }, + ) + await new Promise(r => setTimeout(r, 80)) + const f2 = (so.lastFrame ?? '').replace(/\n$/, '') + inst2.unmount() + const g2 = await frameToGrid(f2, { cols: COLS, rows: ROWS }) + check('tab-indented line keeps its full text ("* App")', g2.some(r => r.includes('* App'))) + check('tab-indented line keeps its full text ("* Release")', g2.some(r => r.includes('* Release'))) + check('no literal tab reaches the rendered grid', !g2.join('').includes('\t')) +} + console.log(`\n${passed} passed, ${failed} failed`) clearTimeout(watchdog) process.exit(failed > 0 ? 1 : 0) From 20a84b304ecc449fa5a19440cb0d8003fc5103dc Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sun, 31 May 2026 12:08:43 +0200 Subject: [PATCH 09/14] =?UTF-8?q?docs(cli):=20correct=20ApiKeyInstructions?= =?UTF-8?q?Step=20comment=20=E2=80=94=20dense=20still=20suppresses=20one?= =?UTF-8?q?=20Newline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My prior comment fix over-claimed: it said `dense` "no longer alters the layout", but ApiKeyInstructionsStep still uses `{!dense && }` to drop the spacer between the picker prompt and its Select. Comment now states `dense` only suppresses that single Newline (the multi-step copy is unconditional). (CodeRabbit) --- .../build/onboarding/ui/steps/ios-credentials.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cli/src/build/onboarding/ui/steps/ios-credentials.tsx b/cli/src/build/onboarding/ui/steps/ios-credentials.tsx index de5e39a0db..5ccb2fb654 100644 --- a/cli/src/build/onboarding/ui/steps/ios-credentials.tsx +++ b/cli/src/build/onboarding/ui/steps/ios-credentials.tsx @@ -122,13 +122,13 @@ export const SetupMethodSelectStep: FC = ({ dense = // (Select) or a direct path input (FilteredTextInput). The submit handler for // the no-picker path is owned by the parent. Telemetry/file-reads happen there. // -// Renders the full comfortable form: the info Alert, a , the FOUR -// numbered setup steps in a marginLeft box, a , the "Press Ctrl+O" -// hint, a , a Divider, another , then the control (with a -// before the picker Select). (The startup size gate guarantees the -// terminal is tall enough, so the old adaptive `dense` collapse was dropped; the -// `dense` prop is accepted for call-site compatibility but no longer alters the -// layout — see the per-platform floor in min-terminal-size.ts.) +// Renders the (essentially comfortable) form: the info Alert, a , the +// FOUR numbered setup steps in a marginLeft box, a , the "Press Ctrl+O" +// hint, a , a Divider, another , then the control. The old +// adaptive `dense` collapse was dropped when the startup size gate began +// guaranteeing enough rows (see the per-platform floor in min-terminal-size.ts), +// so the multi-step copy no longer changes — `dense` now only suppresses the +// single spacer between the picker prompt and its Select. export interface ApiKeyInstructionsStepProps { canUseFilePicker: boolean dense?: boolean From 13a1cdbcc8f2d31b8c8a5d11f301325584afc4e4 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sun, 31 May 2026 12:14:44 +0200 Subject: [PATCH 10/14] fix(cli): drop "Kimi K2.5" model name from onboarding AI copy (match main) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main deliberately hides the underlying model name — "Capgo AI", not "Capgo AI (Kimi K2.5)" (see request.ts on main, #2362). The onboarding AI-debug steps reintroduced it in 4 user-facing strings (ios-shared + android-shared: the "analyze the build log…" prompt + the running spinner). Revert to plain "Capgo AI" to match. (The internal ai_analyze.ts code comment that mentions Kimi is unchanged — it's identical to main and not user-facing.) --- cli/src/build/onboarding/ui/steps/android-shared.tsx | 4 ++-- cli/src/build/onboarding/ui/steps/ios-shared.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/src/build/onboarding/ui/steps/android-shared.tsx b/cli/src/build/onboarding/ui/steps/android-shared.tsx index d2f87d716f..e5bdf66704 100644 --- a/cli/src/build/onboarding/ui/steps/android-shared.tsx +++ b/cli/src/build/onboarding/ui/steps/android-shared.tsx @@ -239,7 +239,7 @@ export const AiAnalysisPromptStep: FC = ({ onChoose, {!dense && } - We can analyze the build log with Capgo AI (Kimi K2.5) and suggest a fix. + We can analyze the build log with Capgo AI and suggest a fix. {!dense && } = ({ dense = fa // ── ai-analysis-running ───────────────────────────────────────────────────────── export const AiAnalysisRunningStep: FC = () => ( - + ) From 62734ad234cd272e892ce801784fb1c3fca17b41 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sun, 31 May 2026 12:25:58 +0200 Subject: [PATCH 11/14] fix(cli): persist + re-derive the API Key ID so resume doesn't lose it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When you pick the .p8 (named AuthKey_.p8), the Key ID is extracted and pre-filled — but it was only ever PERSISTED when you manually submitted the Key ID step. Quitting after selecting the file but before confirming saved just p8Path, so on resume `keyId` initialized empty and the input showed the generic "ABC123DEF" placeholder instead of the detected id. Two-part fix: - Persist the extracted keyId alongside p8Path at all three .p8-selection sites (file picker + manual path, x2), so a normal resume restores it. - On resume, if keyId wasn't saved, re-derive it from the saved p8Path filename. This also recovers progress files written by the old code. extractKeyIdFromP8Path moved to progress.ts (pure, co-located with the other resume helpers) + exported, with regression tests in test-onboarding-progress (AuthKey_/ApiKey_ prefixes, case-insensitivity, non-matching/renamed files, end-of-path anchoring). Build green, cli:lint clean, knip 0, onboarding suites pass. --- cli/src/build/onboarding/progress.ts | 9 ++++++++ cli/src/build/onboarding/ui/app.tsx | 33 +++++++++++++++++++-------- cli/test/test-onboarding-progress.mjs | 29 ++++++++++++++++++++++- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/cli/src/build/onboarding/progress.ts b/cli/src/build/onboarding/progress.ts index d73215736a..1590e928f4 100644 --- a/cli/src/build/onboarding/progress.ts +++ b/cli/src/build/onboarding/progress.ts @@ -184,3 +184,12 @@ export function getImportEntryStep(progress: OnboardingProgress | null): Onboard return 'input-key-id' return 'api-key-instructions' } + +// Apple names downloaded App Store Connect API keys "AuthKey_.p8" (older +// portals used "ApiKey_"), so the Key ID is recoverable from the filename. Used +// both to pre-fill the Key ID when a .p8 is picked and to re-derive it on resume +// when a prior session saved the path but quit before confirming the Key ID step. +// Returns '' when the filename doesn't match (e.g. a manually-renamed file). +export function extractKeyIdFromP8Path(filePath: string): string { + return filePath.match(/(?:Auth|Api)Key_([A-Z0-9]+)\.p8$/i)?.[1] ?? '' +} diff --git a/cli/src/build/onboarding/ui/app.tsx b/cli/src/build/onboarding/ui/app.tsx index 06a71cb5e9..9d7a1fc25e 100644 --- a/cli/src/build/onboarding/ui/app.tsx +++ b/cli/src/build/onboarding/ui/app.tsx @@ -36,7 +36,7 @@ import { createP12, DEFAULT_P12_PASSWORD, generateCsr } from '../csr.js' import { mapIosOnboardingError } from '../error-categories.js' import { canUseFilePicker, openFilePicker } from '../file-picker.js' import { exportP12FromKeychain, isHelperCached, isMacOS, listSigningIdentities, matchIdentitiesToProfiles, precompileSwiftHelper, scanProvisioningProfiles } from '../macos-signing.js' -import { deleteProgress, getImportEntryStep, getResumeStep, loadProgress, saveProgress } from '../progress.js' +import { deleteProgress, extractKeyIdFromP8Path, getImportEntryStep, getResumeStep, loadProgress, saveProgress } from '../progress.js' import { getBuildOnboardingRecoveryAdvice } from '../recovery.js' import { createCiSecretEntries, detectCiSecretTargets, getCiSecretRepoLabelAsync, getCiSecretTargetLabel, listExistingCiSecretKeysAsync, uploadCiSecretsAsync } from '../ci-secrets.js' import type { CiSecretEntry, CiSecretSetupAdvice, CiSecretTarget } from '../ci-secrets.js' @@ -244,7 +244,16 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, o // Collected data — restore p8Path from progress if resuming const [p8Path, setP8Path] = useState(initialProgress?.p8Path || '') const [p8Content, _setP8Content] = useState('') - const [keyId, setKeyId] = useState(initialProgress?.completedSteps.apiKeyVerified?.keyId || initialProgress?.keyId || '') + // Resume order: verified key → explicitly saved keyId → re-derive from the saved + // .p8 filename. The last fallback fixes the case where a previous session picked + // the .p8 (saving only p8Path) and quit before confirming the Key ID step — the + // field used to come back empty (showing the placeholder) instead of the real id. + const [keyId, setKeyId] = useState( + initialProgress?.completedSteps.apiKeyVerified?.keyId + || initialProgress?.keyId + || extractKeyIdFromP8Path(initialProgress?.p8Path || '') + || '', + ) const [issuerId, setIssuerId] = useState(initialProgress?.completedSteps.apiKeyVerified?.issuerId || initialProgress?.issuerId || '') // Terminal dimensions, tracked in state so the wizard RE-RENDERS on resize. @@ -637,11 +646,9 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, o await saveProgress(appId, existing) }, [appId]) - // Extract Key ID from .p8 filename (e.g. "AuthKey_ABC123.p8" or "ApiKey_ABC123.p8") - function extractKeyIdFromPath(filePath: string): string { - const match = filePath.match(/(?:Auth|Api)Key_([A-Z0-9]+)\.p8$/i) - return match?.[1] || '' - } + // Extract Key ID from .p8 filename — delegates to the module-level helper so + // the resume initializer and the live-pick handlers share one implementation. + const extractKeyIdFromPath = extractKeyIdFromP8Path /** * Get a fresh JWT token, re-reading the .p8 file if needed. @@ -1158,7 +1165,9 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, o if (extracted) setKeyId(extracted) addLog(`✔ Key file selected · ${selected}`) - void savePartialProgress({ p8Path: selected }) + // Persist the extracted keyId too — otherwise quitting before the + // Key ID step loses it and resume shows the empty placeholder. + void savePartialProgress({ p8Path: selected, keyId: extracted || undefined }) setStep('input-key-id') } else { @@ -2491,7 +2500,9 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, o if (extracted) setKeyId(extracted) addLog(`✔ Key file found · ${filePath}`) - void savePartialProgress({ p8Path: filePath }) + // Persist the extracted keyId too, so a quit-before-confirm resume + // restores it instead of showing the empty placeholder. + void savePartialProgress({ p8Path: filePath, keyId: extracted || undefined }) setStep('input-key-id') } catch { @@ -2517,7 +2528,9 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, o if (extracted) setKeyId(extracted) addLog(`✔ Key file found · ${filePath}`) - void savePartialProgress({ p8Path: filePath }) + // Persist the extracted keyId too, so a quit-before-confirm resume + // restores it instead of showing the empty placeholder. + void savePartialProgress({ p8Path: filePath, keyId: extracted || undefined }) setStep('input-key-id') } catch { diff --git a/cli/test/test-onboarding-progress.mjs b/cli/test/test-onboarding-progress.mjs index daf3a3fde2..51ef2da035 100644 --- a/cli/test/test-onboarding-progress.mjs +++ b/cli/test/test-onboarding-progress.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { getImportEntryStep, getResumeStep } from '../src/build/onboarding/progress.ts' +import { extractKeyIdFromP8Path, getImportEntryStep, getResumeStep } from '../src/build/onboarding/progress.ts' function t(name, fn) { try { @@ -135,3 +135,30 @@ t('getResumeStep still returns import-scanning for verified import flow', () => }) assert.equal(getResumeStep(progress), 'import-scanning') }) + +// ─── extractKeyIdFromP8Path — Key ID recovered from the .p8 filename ────────── +// Regression: a session that picked the .p8 but quit before confirming the Key +// ID step used to come back with an empty field (the "ABC123DEF" placeholder). +// The Key ID is now re-derived from the saved p8Path filename on resume. + +t('extracts the Key ID from an AuthKey_.p8 filename', () => { + assert.equal(extractKeyIdFromP8Path('/Users/me/AuthKey_66FGQZB566.p8'), '66FGQZB566') +}) + +t('extracts from the legacy ApiKey_ prefix too', () => { + assert.equal(extractKeyIdFromP8Path('~/Downloads/ApiKey_ABC123DEF.p8'), 'ABC123DEF') +}) + +t('matches the prefix case-insensitively', () => { + assert.equal(extractKeyIdFromP8Path('/x/authkey_9Z9ZZZ9Z9Z.p8'), '9Z9ZZZ9Z9Z') +}) + +t('returns empty for a renamed / non-matching filename', () => { + assert.equal(extractKeyIdFromP8Path('/Users/me/my-apple-key.p8'), '') + assert.equal(extractKeyIdFromP8Path('/Users/me/AuthKey_66FGQZB566.pem'), '') + assert.equal(extractKeyIdFromP8Path(''), '') +}) + +t('only matches the key id at the end of the path (not a mid-path token)', () => { + assert.equal(extractKeyIdFromP8Path('/AuthKey_NOPE/actual-file.p8'), '') +}) From 406299ea1e3068a7fd288aa864ef764b92b37502 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sun, 31 May 2026 12:29:25 +0200 Subject: [PATCH 12/14] =?UTF-8?q?docs(cli):=20correct=20ios-shared=20Error?= =?UTF-8?q?Step=20+=20header=20comments=20(dense=20=E2=86=92=20collapsed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third instance of the same stale-comment family CodeRabbit has been catching: ErrorStep's block comment and the file header described a `dense` collapse form that doesn't exist — ErrorStep only uses `collapsed` (route the too-tall error + advice through the FullscreenAiViewer, then render headline + action Select). Rewrote both comments to describe the collapsed-vs-full behavior and note `dense` is accepted for call-site compatibility but no longer alters layout. Doc-only; no behavior change. Build green, frame-fit 8/8, cli:lint clean. --- .../build/onboarding/ui/steps/ios-shared.tsx | 65 ++++++++----------- 1 file changed, 26 insertions(+), 39 deletions(-) diff --git a/cli/src/build/onboarding/ui/steps/ios-shared.tsx b/cli/src/build/onboarding/ui/steps/ios-shared.tsx index ac2638d816..a45b08576e 100644 --- a/cli/src/build/onboarding/ui/steps/ios-shared.tsx +++ b/cli/src/build/onboarding/ui/steps/ios-shared.tsx @@ -13,35 +13,22 @@ import type { AiResultKind } from '../components.js' // render and forward callbacks. They never touch `useStdout` / // `measureElement`. // -// Adaptive spacing. Each step renders its COMFORTABLE form (the original -// design — bordered boxes, blank-line spacing between elements, full copy) by -// DEFAULT and collapses to a COMPACT form only when the parent passes -// `dense=true`. The parent (ui/app.tsx) measures the comfortable body against -// the live viewport and flips `dense` on only when the comfortable version -// can't fit — so a roomy terminal breathes while a 16-row terminal still -// survives. Mirrors the AiResultBanner (ui/components.tsx) adaptive pattern, -// which these step bodies thread `dense` straight through to. +// Spacing. Each step renders its COMFORTABLE form (bordered boxes, blank-line +// spacing, full copy). The old adaptive `dense` collapse was dropped once the +// startup size gate began guaranteeing a per-platform minimum height (see +// min-terminal-size.ts), so a `dense` prop is still accepted on some steps for +// call-site compatibility but no longer changes the layout. // -// The frame-fit contract (see ui/components.tsx + test/helpers/frame-fit.mjs) -// requires every step body's DENSE form to render within BODY_BUDGET_ROWS (13) -// rows at the reference widths (80 + 60) — that's the form which must survive -// the 16-row floor. The comfortable form may legitimately exceed the budget -// (it only renders when the parent measured that it fits). The two budget -// offenders in dense mode are: -// • error — renders variable-length recovery advice (the recovery helper can -// match several branches at once, so summary/commands/docs all grow). The -// dense form clamps the error string, caps the advice lists to a couple of -// rows each with a "… +N more" line, drops docs (the actionable bits are -// summary + commands + the Select), and drops the decorative blank lines so -// the "what failed" line + recovery action stay on screen. The comfortable -// form restores the full "Recovery plan / Helpful commands / Docs / Support -// bundle" headings, the uncapped lists, and the blank-line spacing. -// • ai-analysis-result — the success analysis text rendered inline here is -// always SHORT (long analyses are routed to the fullscreen scroll step by -// the parent BEFORE this frame); the dense form keeps the caution + -// "retries used" notice terse and drops the blank lines. The comfortable -// form restores the full caution copy + blank-line spacing. -// Verified in test-frame-fit-ios-shared.mjs (dense form asserted ≤ 13). +// The two steps whose content is UNBOUNDED don't rely on a dense form at all — +// they hand off to a scrollable fullscreen takeover instead: +// • error — recovery advice is variable-length (recovery.ts can match several +// branches at once). When the full form is taller than the viewport the +// parent shows it in the FullscreenAiViewer, then renders ErrorStep in its +// `collapsed` form (error headline + the action Select only) so Try again / +// Restart / Exit stay reachable. (See the per-step note on ErrorStep below.) +// • ai-analysis-result — the inline success text is always SHORT here; long +// analyses are routed to the fullscreen scroll step by the parent BEFORE +// this frame renders. import { Select } from '@inkjs/ui' import { Box, Newline, Text } from 'ink' import React from 'react' @@ -257,18 +244,18 @@ export const AiAnalysisResultStep: FC = ({ // Recovery advice is variable-length (recovery.ts can match several branches for // one composite error, growing summary/commands/docs). // -// Comfortable form (default): the original full layout — "Recovery plan", -// "Helpful commands" and "Docs" headings, each over its uncapped list; the -// full (unclamped) error; the "Support bundle" heading + path; and the "What do -// you want to do?" Select, all with blank-line spacing. The parent only renders -// this when it measured that it fits. +// Full form (default): the complete layout — "Recovery plan", "Helpful commands" +// and "Docs" headings, each over its uncapped list; the full (unclamped) error; +// the "Support bundle" heading + path; and the "What do you want to do?" Select, +// all with blank-line spacing. // -// Dense form: the budget-fitting fallback — a clamped one-line "what failed", -// the single most relevant recovery summary line + the first helpful command -// (each with a "… +N more" count), docs dropped (the Select + commands are the -// actionable parts; docs URLs are long and would wrap past the budget), the -// support-bundle path rendered whole (the artifact the user must copy; it may -// wrap to 2 rows, which the budget accounts for), no headings, no blank lines. +// Collapsed form (`collapsed`): when the parent measured the full form as taller +// than the viewport, it first shows the error + advice in the scrollable +// FullscreenAiViewer, then renders ONLY the error headline + the action Select +// here — so Try again / Restart / Exit stay reachable no matter how long the +// advice was. This `collapsed` route REPLACES the old adaptive `dense` collapse, +// which was dropped along with the rest of the dense flag; `dense` is still +// accepted for call-site compatibility but no longer alters the layout. // // `showRetry` gates the Select (the parent only sets a retryStep on recoverable // errors); the parent owns retry/restart/exit. From 749d1b7926ed5179802074eb850971e7760203ee Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sun, 31 May 2026 12:31:38 +0200 Subject: [PATCH 13/14] docs(cli): correct last stale dense comment (ImportNoMatchRecoveryStep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proactive sweep for the dense-comment-vs-code mismatch CodeRabbit has caught several times: ImportNoMatchRecoveryStep's comment described a "Dense:" form, but the body destructures only { identityName, options, onChange } — no dense. Fixed the comment to describe the actual always-full layout (dense kept on props for call-site compatibility only). A per-component scan of every onboarding step now reports zero remaining stale dense comments. Doc-only; build green, frame-fit 8/8. --- .../build/onboarding/ui/steps/ios-import.tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/cli/src/build/onboarding/ui/steps/ios-import.tsx b/cli/src/build/onboarding/ui/steps/ios-import.tsx index c56b073782..6eb260698b 100644 --- a/cli/src/build/onboarding/ui/steps/ios-import.tsx +++ b/cli/src/build/onboarding/ui/steps/ios-import.tsx @@ -209,18 +209,13 @@ export interface ImportNoMatchRecoveryStepProps { onChange: (value: string) => void } -// Comfortable: the original layout — the warning inside an `Alert variant= -// "warning"` box ("No provisioning profile on this Mac is linked to '{name}'."), -// a , the full "The cert is in your Keychain but the matching profile -// isn't on disk. Pick a recovery path:" line, another , then an -// UN-capped Select listing every recovery option. -// -// Dense: the Alert box (whose border + padding wrap the unbounded identity name -// to several rows) drops to a plain bold yellow warning line — same as the -// Android keystore explainer's fix, so the long name costs only its wrapped text -// — the dim line + blank lines shorten/drop, and the Select caps to -// LIST_VISIBLE_COUNT visible rows with a "… +N more" hint so the (wrapping) -// recovery options stay within budget at 60 cols. +// Renders the full layout: the warning inside an `Alert variant="warning"` box +// ("No provisioning profile on this Mac is linked to '{name}'."), a , +// the full "The cert is in your Keychain but the matching profile isn't on disk. +// Pick a recovery path:" line, another , then an UN-capped Select +// listing every recovery option. (The old adaptive `dense` collapse was dropped +// once the startup size gate began guaranteeing enough rows; `dense` is still on +// the props for call-site compatibility but no longer alters the layout.) export const ImportNoMatchRecoveryStep: FC = ({ identityName, options, onChange }) => { return ( From 2c03ad4d805b3b4506a9349616c2575b1ef0ea5f Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sun, 31 May 2026 16:08:35 +0200 Subject: [PATCH 14/14] =?UTF-8?q?fix(cli):=20onboarding=20viewers=20?= =?UTF-8?q?=E2=80=94=20persist=20success=20screen=20+=20fill=20workflow=20?= =?UTF-8?q?diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-complete auto-exited 100ms after rendering, so the alt-screen teardown wiped the success frame instantly. It now stays until the user presses Enter/Esc/q (extracted isBuildCompleteDismissKey for testability), then exits and lets command.ts reprint the durable summary. The workflow-file diff viewer rendered inside the wizard Box (header/padding top gap) and reserved 12 chrome rows; it's now a fullscreen early-return takeover that fills via minHeight and reserves only the real chrome — both iOS and Android. Tests: test-build-complete-exit.mjs (pure isBuildCompleteDismissKey predicate) + test-diff-viewer-viewport.mjs (VT-grid render asserts it fills the height + truncates), both wired into the CI test chain. --- cli/package.json | 6 +- cli/src/build/onboarding/android/ui/app.tsx | 58 ++++++---- cli/src/build/onboarding/ui/app.tsx | 59 ++++++---- cli/src/build/onboarding/ui/components.tsx | 93 ++++++++++----- .../onboarding/ui/steps/android-shared.tsx | 2 + .../build/onboarding/ui/steps/ios-shared.tsx | 1 + cli/test/test-build-complete-exit.mjs | 38 +++++++ cli/test/test-diff-viewer-viewport.mjs | 106 ++++++++++++++++++ 8 files changed, 288 insertions(+), 75 deletions(-) create mode 100644 cli/test/test-build-complete-exit.mjs create mode 100644 cli/test/test-diff-viewer-viewport.mjs diff --git a/cli/package.json b/cli/package.json index b95e314937..b9ceff9cbd 100644 --- a/cli/package.json +++ b/cli/package.json @@ -105,7 +105,7 @@ "test:macos-signing": "bun test/test-macos-signing.mjs", "test:apple-api-import-helpers": "bun test/test-apple-api-import-helpers.mjs", "test:manifest-path-encoding": "bun test/test-manifest-path-encoding.mjs", - "test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && 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: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:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && 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-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-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", + "test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && 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: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:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && 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-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-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", "test:build-platform-selection": "bun test/test-build-platform-selection.mjs", "test:ai-log-capture": "bun test/test-ai-log-capture.mjs", "test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs", @@ -118,7 +118,9 @@ "test:min-size-gate": "bun test/test-min-size-gate.mjs", "test:shell-size-gate": "bun test/test-shell-size-gate.mjs", "test:build-log-sanitize": "bun test/test-build-log-sanitize.mjs", - "test:build-output-viewport": "bun test/test-build-output-viewport.mjs" + "test:build-output-viewport": "bun test/test-build-output-viewport.mjs", + "test:diff-viewer-viewport": "bun test/test-diff-viewer-viewport.mjs", + "test:build-complete-exit": "bun test/test-build-complete-exit.mjs" }, "dependencies": { "@inkjs/ui": "^2.0.0", diff --git a/cli/src/build/onboarding/android/ui/app.tsx b/cli/src/build/onboarding/android/ui/app.tsx index f0ef51232b..20c598995d 100644 --- a/cli/src/build/onboarding/android/ui/app.tsx +++ b/cli/src/build/onboarding/android/ui/app.tsx @@ -49,7 +49,7 @@ import { CompletedStepsLog } from '../../ui/completed-steps-log.js' import { ANDROID_MIN_ROWS, terminalFitsOnboarding } from '../../min-terminal-size.js' import { sanitizeBuildLogLines } from '../../build-log.js' import { TerminalTooSmallPrompt } from '../../ui/min-size-gate.js' -import { BOX_HEADER_ROWS, DiffSummary, Divider, FilteredTextInput, FullscreenAiViewer, FullscreenBuildOutput, FullscreenDiffViewer, Header, SecretsTable, SpinnerLine, SuccessLine } from '../../ui/components.js' +import { BOX_HEADER_ROWS, DiffSummary, Divider, FilteredTextInput, FullscreenAiViewer, FullscreenBuildOutput, FullscreenDiffViewer, Header, isBuildCompleteDismissKey, SecretsTable, SpinnerLine, SuccessLine } from '../../ui/components.js' import type { AiResultKind } from '../../ui/components.js' import { logBudgetRows } from '../../ui/frame-fit.js' import { writeWorkflowFile, WORKFLOW_PATH } from '../../workflow-writer.js' @@ -670,6 +670,14 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir if (key.ctrl && input === 'c') process.kill(process.pid, 'SIGINT') + // build-complete is the terminal success screen; it deliberately does not + // auto-exit (that would wipe the frame on the alt-screen before it can be + // read). Dismiss on Enter/Esc/q so it lasts until the user is ready. + if (step === 'build-complete' && isBuildCompleteDismissKey(input, key)) { + exit() + return + } + // preview-workflow-file: Esc skips. Arrows/Enter go to the Select. if (step === 'preview-workflow-file' && key.escape) { trackWorkflowEvent('workflow-preview-action', { decision: 'escape' }) @@ -2007,13 +2015,12 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir if (aiJobId) { void releaseCapturedLogs(aiJobId).catch(() => { /* best-effort */ }) } - const timer = setTimeout(() => { - if (!cancelled) - exit() - }, 100) + // Do NOT auto-exit here. On the alt-screen, exit() restores the primary + // buffer and wipes this success frame instantly — the user never gets to + // read it. Stay rendered; a keypress (handled in useInput) exits, after + // which command.ts reprints the durable summary to the primary buffer. return () => { cancelled = true - clearTimeout(timer) validationCleanupRef.current?.() validationCleanupRef.current = null } @@ -2106,6 +2113,28 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir /> ) + // The workflow-file diff is a fullscreen takeover too (same reasoning as the + // AI/build viewers): rendered inside the wizard Box it inherited the header + + // padding (a large top gap) and a too-short viewport. As an early return it + // owns the whole terminal and fills it. + if (step === 'view-workflow-diff' && previewDiff.length > 0) + return ( + { + trackWorkflowEvent('workflow-diff-closed', { decision: 'close' }) + setStep('preview-workflow-file') + }} + /> + ) + // Size gate (resize-reactive): below the enforced floor, render the resize // prompt from THIS mounted component so all in-progress state is preserved — a // shrink shows the prompt, a re-grow shows the exact same step. Placed AFTER the @@ -3046,22 +3075,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir )} - {step === 'view-workflow-diff' && previewDiff.length > 0 && ( - { - trackWorkflowEvent('workflow-diff-closed', { decision: 'close' }) - setStep('preview-workflow-file') - }} - /> - )} + {/* view-workflow-diff renders as a fullscreen early-return takeover above. */} {step === 'writing-workflow-file' && ( diff --git a/cli/src/build/onboarding/ui/app.tsx b/cli/src/build/onboarding/ui/app.tsx index 9d7a1fc25e..c79312ebb1 100644 --- a/cli/src/build/onboarding/ui/app.tsx +++ b/cli/src/build/onboarding/ui/app.tsx @@ -54,7 +54,7 @@ import { CompletedStepsLog } from './completed-steps-log.js' import { IOS_MIN_ROWS, terminalFitsOnboarding } from '../min-terminal-size.js' import { sanitizeBuildLogLines } from '../build-log.js' import { TerminalTooSmallPrompt } from './min-size-gate.js' -import { BOX_HEADER_ROWS, COMPACT_HEADER_ROWS, DiffSummary, Divider, FilteredTextInput, FullscreenAiViewer, FullscreenBuildOutput, FullscreenDiffViewer, Header, SecretsTable, SpinnerLine, SuccessLine, WIZARD_PADDING_ROWS } from './components.js' +import { BOX_HEADER_ROWS, COMPACT_HEADER_ROWS, DiffSummary, Divider, FilteredTextInput, FullscreenAiViewer, FullscreenBuildOutput, FullscreenDiffViewer, Header, isBuildCompleteDismissKey, SecretsTable, SpinnerLine, SuccessLine, WIZARD_PADDING_ROWS } from './components.js' import type { AiResultKind } from './components.js' import { logBudgetRows } from './frame-fit.js' import { diffLines } from '../diff-utils.js' @@ -616,6 +616,14 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, o return } + // build-complete is the terminal success screen; it deliberately does not + // auto-exit (that would wipe the frame on the alt-screen before it can be + // read). Dismiss on Enter/Esc/q so it lasts until the user is ready. + if (step === 'build-complete' && isBuildCompleteDismissKey(input, key)) { + exit() + return + } + if (key.ctrl && input === 'o' && (step === 'api-key-instructions' || step === 'input-issuer-id')) { open('https://appstoreconnect.apple.com/access/integrations/api') } @@ -1881,14 +1889,12 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, o if (aiJobId) { void releaseCapturedLogs(aiJobId).catch(() => { /* best-effort */ }) } - // Exit immediately after rendering the final screen - const timer = setTimeout(() => { - if (!cancelled) - exit() - }, 100) + // Do NOT auto-exit here. On the alt-screen, exit() restores the primary + // buffer and wipes this success frame instantly — the user never gets to + // read it. Stay rendered; a keypress (handled in useInput) exits, after + // which command.ts reprints the durable summary to the primary buffer. return () => { cancelled = true - clearTimeout(timer) } } @@ -2043,6 +2049,28 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, o /> ) + // The workflow-file diff is a fullscreen takeover too (same reasoning as the + // AI/build viewers): rendered inside the wizard Box it inherited the header + + // padding (a large top gap) and a too-short viewport. As an early return it + // owns the whole terminal and fills it. + if (step === 'view-workflow-diff' && previewDiff.length > 0) + return ( + { + trackWorkflowEvent('workflow-diff-closed', { decision: 'close' }) + setStep('preview-workflow-file') + }} + /> + ) + // `minHeight={terminalRows}` makes the root fill the whole viewport. Ink // only does a full clear-the-screen redraw when the frame height is ≥ the // terminal height; for shorter frames it uses an incremental cursor-up @@ -2928,22 +2956,7 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, o )} - {step === 'view-workflow-diff' && previewDiff.length > 0 && ( - { - trackWorkflowEvent('workflow-diff-closed', { decision: 'close' }) - setStep('preview-workflow-file') - }} - /> - )} + {/* view-workflow-diff renders as a fullscreen early-return takeover above. */} {step === 'writing-workflow-file' && ( diff --git a/cli/src/build/onboarding/ui/components.tsx b/cli/src/build/onboarding/ui/components.tsx index dcc744f21c..f6411c7d51 100644 --- a/cli/src/build/onboarding/ui/components.tsx +++ b/cli/src/build/onboarding/ui/components.tsx @@ -328,6 +328,18 @@ export const FullscreenAiViewer: FC<{ ) } +// Pure predicate for the build-complete success screen. The wizard deliberately +// does NOT auto-exit there (on the alt-screen, exit() wipes the final frame +// instantly); instead it waits for the user to dismiss with Enter / Esc / q. +// Extracted as a pure function so the exit behavior is unit-testable without +// rendering the whole app (same rationale as buildScrollAction). +export function isBuildCompleteDismissKey( + input: string, + key: { return?: boolean, escape?: boolean }, +): boolean { + return Boolean(key.return || key.escape || input === 'q') +} + // Pure keypress → scroll/follow transition for the streaming build viewer // (extracted so the scroll logic is unit-testable without rendering, like // platformKeyAction). Returns the next { scrollOffset, follow } or null for an @@ -563,7 +575,27 @@ export const FullscreenDiffViewer: FC<{ terminalRows: number onExit: () => void }> = ({ title, subtitle, lines, terminalRows, onExit }) => { - const viewportRows = Math.max(1, Math.min(lines.length || 1, terminalRows - 12)) + // Read the live terminal size each render + re-render on resize (same as + // FullscreenAiViewer). The parent renders this as a fullscreen early-return + // takeover, so it owns the whole terminal: reserve only the real chrome + // (7 rows — title + subtitle + two dividers + summary + position + exit hint) + // and let the viewport fill the rest. Previously this reserved 12 AND rendered + // inside the wizard Box, so a big top gap + a short viewport wasted the screen. + const { stdout } = useStdout() + const [, forceResize] = useState(0) + useEffect(() => { + if (!stdout) + return + const onResize = (): void => forceResize(n => n + 1) + stdout.on('resize', onResize) + return () => { + stdout.off('resize', onResize) + } + }, [stdout]) + const dims = { rows: stdout?.rows ?? terminalRows, cols: stdout?.columns ?? 80 } + const dividerWidth = Math.max(10, Math.min(60, dims.cols - 1)) + const DIFF_CHROME_ROWS = 7 + const viewportRows = Math.max(1, dims.rows - DIFF_CHROME_ROWS) const [scrollOffset, setScrollOffset] = useState(0) const { addCount, delCount, total } = getDiffCounts(lines) const maxScrollOffset = Math.max(0, lines.length - viewportRows) @@ -573,7 +605,7 @@ export const FullscreenDiffViewer: FC<{ }, [maxScrollOffset]) useInput((input, key) => { - if (key.escape) { + if (key.escape || key.return) { onExit() return } @@ -600,42 +632,47 @@ export const FullscreenDiffViewer: FC<{ const lineNumberWidth = String(Math.max(total, 1)).length return ( - - {title} - {subtitle && {subtitle}} - {'─'.repeat(60)} + // minHeight fills the whole terminal; the fixed-height content box below + // clips overflow so the footer stays pinned at the bottom and the frame + // height is constant — no dead space regardless of scroll position. + + {title} + {subtitle && {subtitle}} + {'─'.repeat(dividerWidth)} {'Summary: '} {`+${addCount} added`} {' '} {`-${delCount} removed`} - {visibleLines.map((line, index) => { - const lineNumber = String(scrollOffset + index + 1).padStart(lineNumberWidth, ' ') - if (line.kind === 'add') { - return ( - - {`${lineNumber} + `} - {line.text} - - ) - } - if (line.kind === 'del') { + + {visibleLines.map((line, index) => { + const lineNumber = String(scrollOffset + index + 1).padStart(lineNumberWidth, ' ') + if (line.kind === 'add') { + return ( + + {`${lineNumber} + `} + {line.text} + + ) + } + if (line.kind === 'del') { + return ( + + {`${lineNumber} - `} + {line.text} + + ) + } return ( - - {`${lineNumber} - `} + + {`${lineNumber} `} {line.text} ) - } - return ( - - {`${lineNumber} `} - {line.text} - - ) - })} - {'─'.repeat(60)} + })} + + {'─'.repeat(dividerWidth)} {`Showing ${firstVisibleLine}-${lastVisibleLine} of ${total} lines. Use ↑/↓ or k/j to scroll.`} diff --git a/cli/src/build/onboarding/ui/steps/android-shared.tsx b/cli/src/build/onboarding/ui/steps/android-shared.tsx index e5bdf66704..041156f1f8 100644 --- a/cli/src/build/onboarding/ui/steps/android-shared.tsx +++ b/cli/src/build/onboarding/ui/steps/android-shared.tsx @@ -193,6 +193,8 @@ export const BuildCompleteStep: FC = ({ uploadSummary, b )} + + Press Enter to finish › ) diff --git a/cli/src/build/onboarding/ui/steps/ios-shared.tsx b/cli/src/build/onboarding/ui/steps/ios-shared.tsx index a45b08576e..1d398208a9 100644 --- a/cli/src/build/onboarding/ui/steps/ios-shared.tsx +++ b/cli/src/build/onboarding/ui/steps/ios-shared.tsx @@ -559,6 +559,7 @@ export const BuildCompleteStep: FC = ({ buildUrl, ciSecr {runHint} + Press Enter to finish › ) } diff --git a/cli/test/test-build-complete-exit.mjs b/cli/test/test-build-complete-exit.mjs new file mode 100644 index 0000000000..062d2bbb63 --- /dev/null +++ b/cli/test/test-build-complete-exit.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env bun +// Unit test for isBuildCompleteDismissKey — the pure predicate behind the +// build-complete success screen's "wait for the user, don't auto-exit" behavior. +// The screen used to auto-exit 100ms after rendering, which wiped the final +// frame on the alt-screen instantly (the user never got to read it). Now it +// stays until the user presses Enter / Esc / q. This guards that contract +// without rendering the whole app (driving OnboardingApp to build-complete +// would require mocking the entire flow — Supabase, Apple API, etc.). +import process from 'node:process' +import { isBuildCompleteDismissKey } from '../src/build/onboarding/ui/components.tsx' + +let passed = 0 +let failed = 0 +function check(name, cond) { + if (cond) { + passed++ + console.log(`✔ ${name}`) + } + else { + failed++ + console.error(`✖ ${name}`) + } +} + +// Dismiss keys — the user is done reading and wants to exit. +check('Enter dismisses', isBuildCompleteDismissKey('', { return: true }) === true) +check('Esc dismisses', isBuildCompleteDismissKey('', { escape: true }) === true) +check('q dismisses', isBuildCompleteDismissKey('q', {}) === true) + +// Non-dismiss keys — the success screen must PERSIST (the whole point of the +// fix). A stray keypress should not tear down the frame. +check('plain letter does not dismiss', isBuildCompleteDismissKey('x', {}) === false) +check('arrow key does not dismiss', isBuildCompleteDismissKey('', { downArrow: true }) === false) +check('space does not dismiss', isBuildCompleteDismissKey(' ', {}) === false) +check('no input + no key does not dismiss', isBuildCompleteDismissKey('', {}) === false) + +console.log(`\n${passed} passed, ${failed} failed`) +process.exit(failed > 0 ? 1 : 0) diff --git a/cli/test/test-diff-viewer-viewport.mjs b/cli/test/test-diff-viewer-viewport.mjs new file mode 100644 index 0000000000..f0e6fe823f --- /dev/null +++ b/cli/test/test-diff-viewer-viewport.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env bun +// Guards FullscreenDiffViewer against the "wastes the screen" bug: it used to +// render INSIDE the wizard Box (inheriting the header slot + padding → a large +// top gap) and reserved 12 chrome rows when it needs only 7, so the workflow-file +// diff showed ~26 lines with a big empty band above. It's now a fullscreen +// early-return takeover that fills via minHeight and reserves only real chrome. +// +// Renders the REAL component through Ink debug mode + the VT grid and asserts: +// the frame fills the terminal height, shows ~rows-7 diff lines (uses the space), +// never exceeds the height, a giant line stays on one row (truncate, no wrap), +// and the chrome (summary + exit hint) is present. +import { EventEmitter } from 'node:events' +import process from 'node:process' +import { render } from 'ink' +import React from 'react' +import { FullscreenDiffViewer } from '../src/build/onboarding/ui/components.tsx' +import { frameToGrid } from './helpers/vt-grid.mjs' + +const watchdog = setTimeout(() => { + console.error('WATCHDOG: diff-viewer-viewport test exceeded 30s') + process.exit(2) +}, 30000) +watchdog.unref() + +function makeStdout(cols, rows) { + const s = new EventEmitter() + s.columns = cols + s.rows = rows + s.isTTY = true + s.lastFrame = '' + s.write = (f) => { + s.lastFrame = f + return true + } + return s +} +function makeStdin() { + const s = new EventEmitter() + s.isTTY = true + s.setEncoding = () => {} + s.setRawMode = () => {} + s.resume = () => {} + s.pause = () => {} + s.ref = () => {} + s.unref = () => {} + s.read = () => null + return s +} + +let passed = 0 +let failed = 0 +function check(name, cond) { + if (cond) { + passed++ + console.log(`✔ ${name}`) + } + else { + failed++ + console.error(`✖ ${name}`) + } +} + +const COLS = 100 +const ROWS = 40 +const DIFF_CHROME_ROWS = 7 // title + subtitle + 2 dividers + summary + position + exit hint + +// A new-file diff longer than the viewport, with one giant line (would wrap to +// ~10 rows if not truncated) to prove the one-row-per-line truncation. +const lines = [] +for (let i = 0; i < 60; i++) + lines.push({ kind: 'add', text: ` line ${i + 1}: some proposed workflow yaml content` }) +lines.splice(5, 0, { kind: 'add', text: `KEY: ${'A'.repeat(900)}` }) + +const stdout = makeStdout(COLS, ROWS) +const inst = render( + React.createElement(FullscreenDiffViewer, { + title: '🆕 Proposed new file — /tmp/x/.github/workflows/capgo-build.yml', + subtitle: 'Nothing exists on disk yet. Every line below is what would be written.', + lines, + terminalRows: ROWS, + onExit: () => {}, + }), + { stdout, stderr: makeStdout(COLS, ROWS), stdin: makeStdin(), debug: true, exitOnCtrlC: false, patchConsole: false }, +) +await new Promise(r => setTimeout(r, 80)) +const frame = (stdout.lastFrame ?? '').replace(/\n$/, '') +inst.unmount() + +const frameRows = frame === '' ? 0 : frame.split('\n').length +check(`frame never exceeds the terminal height (${frameRows} <= ${ROWS})`, frameRows <= ROWS) +check(`frame FILLS the terminal height (${frameRows} >= ${ROWS - 1})`, frameRows >= ROWS - 1) + +const grid = await frameToGrid(frame, { cols: COLS, rows: ROWS }) +// Diff content rows look like " NN + text". Count them — should be ~rows-7. +// The old bug (reserve 12 + the wizard top gap) showed far fewer. +const diffRows = grid.filter(r => /^\s*\d+ \+ /.test(r)).length +check(`uses the space — ${diffRows} diff lines visible (>= ${ROWS - DIFF_CHROME_ROWS - 1})`, diffRows >= ROWS - DIFF_CHROME_ROWS - 1) +// Giant line must not wrap into pure-'A' continuation rows. +const pureAContinuationRows = grid.filter(r => /^A{40,}$/.test(r)).length +check('giant line does NOT wrap into continuation rows', pureAContinuationRows === 0) +check('summary line is shown', grid.some(r => r.includes('Summary:'))) +check('exit hint is shown', grid.some(r => r.includes('Press Escape to exit diff viewer'))) + +console.log(`\n${passed} passed, ${failed} failed`) +clearTimeout(watchdog) +process.exit(failed > 0 ? 1 : 0)