From a2ca346e8693498fde0551ff47a7804226294464 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Thu, 21 May 2026 12:43:16 +0200 Subject: [PATCH 1/9] feat(cli): generate GitHub Actions workflow + auto-push CAPGO_TOKEN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `capgo build init` detects a GitHub remote after a successful first build, the wizard now offers a 3-option choice instead of the existing "upload secrets? yes/no": • Yes — set the secrets AND create a workflow file • Yes — set ONLY the secrets • No The "No" branch then offers a .env export fallback ("Do you want to export the credentials as a .env so that you can setup CI/CD later?"), reusing the renderer from `build credentials manage`. What's new in v1 ================ 1. CAPGO_TOKEN auto-push (cli/src/build/onboarding/ci-secrets.ts) `createCiSecretEntries` now takes an optional API key arg. When provided, the bundle pushed to GitHub/GitLab includes CAPGO_TOKEN alongside build credentials, so the generated workflow can authenticate without the user manually running `gh secret set CAPGO_TOKEN` afterward. Both onboarding wizards pass `apikey ?? findSavedKey(...)` at credentials-save time. 2. Workflow generator (cli/src/build/onboarding/workflow-generator.ts) Pure function that produces a `.github/workflows/capgo-build.yml` with `workflow_dispatch` trigger, branched on the four package managers (bun / npm / pnpm / yarn). Per maintainer convention, the bun branch includes BOTH `oven-sh/setup-bun@v2` AND `actions/setup-node@v4` — bun's Node compat isn't perfect and many build pipelines still need Node on PATH. Backed by 12 unit tests in cli/test/test-workflow-generator.mjs. 3. Workflow writer (cli/src/build/onboarding/workflow-writer.ts) Thin file-I/O wrapper. Returns `kind: 'exists'` with both contents when the target file is already present, so the wizard can show a line-count summary and ask for explicit overwrite confirmation before clobbering. 4. .env export reuse (cli/src/build/onboarding/env-export.ts) Reuses `renderEnvFile` from `build credentials manage` (refactored to take `({ appId, local, platform, creds })` — one minimal signature change, same comment header / .gitignore reminder / provisioning-map base64 fallback). Writes to mode 0600, refuses to silently overwrite, surfaces the same overwrite-confirm prompt. 5. Build script picker (both wizards) When the user picks "secrets + workflow", the wizard prompts for which package.json script builds the web assets BEFORE running `capgo build request`. Always asks — never auto-picks blindly. Lists all `scripts{}`, surfaces a "recommended" hint sourced from `findBuildCommandForProjectType()` when the matching script exists, plus escape hatches for "Type a custom command…" and "Skip build step (my app is raw HTML)". Routing ======= GitHub-only for v1 — GitLab keeps the existing 2-option `ask-ci-secrets` flow. In the multi-target picker, picking GitHub routes to the new 3-option prompt; picking GitLab routes to the legacy 2-option prompt. `uploading-ci-secrets` branches on `setupMode`: • `with-workflow` → loads `getPackageScripts()` + project-type recommendation → `pick-build-script` → `writing-workflow-file` (which checks for existing file and may route to `confirm-workflow-overwrite`) • `secrets-only` / `undecided` (GitLab) → `build-complete` The `ask-export-env` "no" path on the declined branch is reachable from the new 3-option prompt; `ci-secrets-target-select` "skip" still goes straight to `build-complete` (no second-chance prompt — keeping that exit minimal). What this v1 deliberately doesn't do ===================================== - GitLab `.gitlab-ci.yml` generation (structurally different, follow-up) - Push / pull_request triggers (only `workflow_dispatch` — manual until the user trusts it) - Monorepo subdirectory detection (`working-directory`) - Modifying / merging into existing non-Capgo workflows - webDir verification after the build step Build / lint / typecheck / test all green via `bun run cli:check`. --- cli/src/build/credentials-manage.ts | 17 +- cli/src/build/onboarding/android/types.ts | 32 ++ cli/src/build/onboarding/android/ui/app.tsx | 483 ++++++++++++++++- cli/src/build/onboarding/ci-secrets.ts | 20 +- cli/src/build/onboarding/env-export.ts | 76 +++ cli/src/build/onboarding/types.ts | 32 ++ cli/src/build/onboarding/ui/app.tsx | 504 +++++++++++++++++- .../build/onboarding/workflow-generator.ts | 234 ++++++++ cli/src/build/onboarding/workflow-writer.ts | 60 +++ cli/test/test-ci-secrets.mjs | 21 + cli/test/test-workflow-generator.mjs | 236 ++++++++ 11 files changed, 1701 insertions(+), 14 deletions(-) create mode 100644 cli/src/build/onboarding/env-export.ts create mode 100644 cli/src/build/onboarding/workflow-generator.ts create mode 100644 cli/src/build/onboarding/workflow-writer.ts create mode 100644 cli/test/test-workflow-generator.mjs diff --git a/cli/src/build/credentials-manage.ts b/cli/src/build/credentials-manage.ts index fe340ef170..984f2fc2d2 100644 --- a/cli/src/build/credentials-manage.ts +++ b/cli/src/build/credentials-manage.ts @@ -1197,7 +1197,7 @@ async function exportToEnvFile(entry: AppEntry): Promise { return false } - const content = renderEnvFile(entry, platform, creds) + const content = renderEnvFile({ appId: entry.appId, local: entry.local, platform, creds }) await writeFile(target.path, content, { mode: 0o600 }) // Node's writeFile mode option only applies when the file is newly created; // an overwrite leaves the existing permission bits untouched. Force 0o600 @@ -1235,7 +1235,7 @@ async function exportCombinedEnvFile(entry: AppEntry): Promise { pLog.info('✗ Export cancelled.') return false } - const content = renderEnvFile(entry, platform, creds) + const content = renderEnvFile({ appId: entry.appId, local: entry.local, platform, creds }) await writeFile(target.path, content, { mode: 0o600 }) await chmod(target.path, 0o600) const fieldCount = Object.values(creds).filter(v => typeof v === 'string' && v.length > 0).length @@ -1392,13 +1392,20 @@ async function resolveExportTarget(entry: AppEntry, label: 'ios' | 'android' | ' return { path: resolved } } -function renderEnvFile(entry: AppEntry, platform: 'ios' | 'android', creds: Partial): string { +/** + * Render a single-platform .env file from credentials. Exported so the build + * onboarding wizard can produce the same format on the "export-instead-of-CI" + * branch without duplicating the section headers, escaping rules, and + * provisioning-map base64 trick. + */ +export function renderEnvFile(args: { appId: string, local: boolean, platform: 'ios' | 'android', creds: Partial }): string { + const { appId, local, platform, creds } = args const lines: string[] = [] const generated = new Date().toISOString() lines.push('# Capgo build credentials — CI/CD environment file') - lines.push(`# App: ${entry.appId}`) + lines.push(`# App: ${appId}`) lines.push(`# Platform: ${platform}`) - lines.push(`# Source: ${entry.local ? 'local' : 'global'} credentials store`) + lines.push(`# Source: ${local ? 'local' : 'global'} credentials store`) lines.push(`# Generated: ${generated}`) lines.push('#') lines.push('# Paste these into your CI/CD provider as secrets, or source the file locally:') diff --git a/cli/src/build/onboarding/android/types.ts b/cli/src/build/onboarding/android/types.ts index 0b7bf90ec6..49ba981bb8 100644 --- a/cli/src/build/onboarding/android/types.ts +++ b/cli/src/build/onboarding/android/types.ts @@ -46,6 +46,17 @@ export type AndroidOnboardingStep | 'confirm-ci-secret-overwrite' | 'uploading-ci-secrets' | 'ci-secrets-failed' + // GitHub Actions workflow + .env export sub-flow (post-secrets-upload) + | 'ask-github-actions-setup' + | 'ask-export-env' + | 'exporting-env' + | 'confirm-env-export-overwrite' + | 'overwrite-and-export-env' + | 'pick-build-script' + | 'pick-build-script-custom' + | 'writing-workflow-file' + | 'confirm-workflow-overwrite' + | 'overwrite-and-write-workflow' | 'ask-build' | 'requesting-build' | 'build-complete' @@ -175,6 +186,17 @@ export const ANDROID_STEP_PROGRESS: Record = { 'confirm-ci-secret-overwrite': 87, 'uploading-ci-secrets': 88, 'ci-secrets-failed': 88, + // GitHub Actions + .env export branch — post-build, ~96 + 'ask-github-actions-setup': 86, + 'ask-export-env': 96, + 'exporting-env': 96, + 'confirm-env-export-overwrite': 96, + 'overwrite-and-export-env': 96, + 'pick-build-script': 96, + 'pick-build-script-custom': 96, + 'writing-workflow-file': 97, + 'confirm-workflow-overwrite': 97, + 'overwrite-and-write-workflow': 97, 'ask-build': 90, 'requesting-build': 95, 'build-complete': 100, @@ -220,10 +242,20 @@ export function getAndroidPhaseLabel(step: AndroidOnboardingStep): string { case 'ci-secrets-setup': case 'ci-secrets-target-select': case 'ask-ci-secrets': + case 'ask-github-actions-setup': case 'checking-ci-secrets': case 'confirm-ci-secret-overwrite': case 'uploading-ci-secrets': case 'ci-secrets-failed': + case 'ask-export-env': + case 'exporting-env': + case 'confirm-env-export-overwrite': + case 'overwrite-and-export-env': + case 'pick-build-script': + case 'pick-build-script-custom': + case 'writing-workflow-file': + case 'confirm-workflow-overwrite': + case 'overwrite-and-write-workflow': case 'ask-build': case 'requesting-build': return 'Step 4 of 4 · Save & Build' diff --git a/cli/src/build/onboarding/android/ui/app.tsx b/cli/src/build/onboarding/android/ui/app.tsx index 9adf838c5c..d5d7b36960 100644 --- a/cli/src/build/onboarding/android/ui/app.tsx +++ b/cli/src/build/onboarding/android/ui/app.tsx @@ -27,6 +27,11 @@ import { loadSavedCredentials, updateSavedCredentials } from '../../../credentia import { requestBuildInternal } from '../../../request.js' import { createCiSecretEntries, detectCiSecretTargets, getCiSecretTargetLabel, listExistingCiSecretKeys, uploadCiSecrets } from '../../ci-secrets.js' import type { CiSecretEntry, CiSecretSetupAdvice, CiSecretTarget } from '../../ci-secrets.js' +import { defaultExportPath, exportCredentialsToEnv } from '../../env-export.js' +import { writeWorkflowFile, WORKFLOW_PATH } from '../../workflow-writer.js' +import type { BuildScriptChoice, PackageManager } from '../../workflow-generator.js' +import { findBuildCommandForProjectType, findProjectType, getPackageScripts, getPMAndCommand } from '../../../../utils.js' +import type { BuildCredentials } from '../../../../schemas/build.js' import { canUseFilePicker, openKeystorePicker } from '../../file-picker.js' import { findAndroidApplicationIds } from '../gradle-parser.js' import { Divider, ErrorLine, FilteredTextInput, Header, SpinnerLine, SuccessLine } from '../../ui/components.js' @@ -112,6 +117,47 @@ function emptyProgress(appId: string): AndroidOnboardingProgress { } } +/** + * `getPMAndCommand()` returns 'unknown' when no recognizable lockfile is + * present. The workflow generator only knows the four real ones — fall back + * to 'npm' for the generator template. + */ +function normalizePackageManager(pm: string): PackageManager { + if (pm === 'bun' || pm === 'npm' || pm === 'pnpm' || pm === 'yarn') + return pm + return 'npm' +} + +interface BuildScriptOption { + label: string + value: string +} + +/** + * Build the picker options for `pick-build-script`. Shows ALL scripts from + * package.json (the user picks; we don't auto-guess), with the project-type + * recommendation surfaced at the top, plus escape hatches for custom commands + * and "skip build entirely" (raw HTML Capacitor apps). + */ +function buildScriptPickerOptions(scripts: Record, recommended: string | null): BuildScriptOption[] { + const options: BuildScriptOption[] = [] + const seen = new Set() + + if (recommended && Object.prototype.hasOwnProperty.call(scripts, recommended)) { + options.push({ label: `${recommended} (recommended — matches your project type)`, value: recommended }) + seen.add(recommended) + } + + const others = Object.keys(scripts).filter(name => !seen.has(name)).sort((a, b) => a.localeCompare(b)) + for (const name of others) + options.push({ label: name, value: name }) + + options.push({ label: 'Type a custom command…', value: '__custom__' }) + options.push({ label: 'Skip build step (my app is raw HTML)', value: '__skip__' }) + + return options +} + const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir, apikey }) => { const { exit } = useApp() const startStep: AndroidOnboardingStep = getAndroidResumeStep(initialProgress) @@ -210,6 +256,22 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const [ciSecretExistingKeys, setCiSecretExistingKeys] = useState([]) const [ciSecretError, setCiSecretError] = useState(null) const [ciSecretUploadSummary, setCiSecretUploadSummary] = useState(null) + // 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 + // exits via build-complete; declined branches to ask-export-env. + const [setupMode, setSetupMode] = useState<'undecided' | 'with-workflow' | 'secrets-only' | 'declined'>('undecided') + const [availableScripts, setAvailableScripts] = useState>({}) + const [recommendedScript, setRecommendedScript] = useState(null) + const [buildScriptChoice, setBuildScriptChoice] = useState(null) + const [workflowExistingContent, setWorkflowExistingContent] = useState(null) + const [workflowProposedContent, setWorkflowProposedContent] = useState(null) + const [workflowWrittenPath, setWorkflowWrittenPath] = useState(null) + const [envExportPath, setEnvExportPath] = useState(null) + const [envExportError, setEnvExportError] = useState(null) + const [envExportTargetPath, setEnvExportTargetPath] = useState('') + const [savedCredentials, setSavedCredentials] = useState | null>(null) + const pm = getPMAndCommand() const { stdout } = useStdout() const terminalRows = stdout?.rows ?? 24 @@ -966,8 +1028,22 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir // yet — the wizard now offers that step only AFTER a successful first // build, so users never end up with orphan secrets in a repo whose // build was never proven to work. - const entries = createCiSecretEntries(credentials) + // + // Pass the API key so CAPGO_TOKEN gets included — the generated + // GitHub Actions workflow references ${{ secrets.CAPGO_TOKEN }} for + // --apikey, and users who pick "secrets only" still benefit from + // having it ready in their repo for a workflow they'll write later. + let capgoKey: string | undefined = apikey + if (!capgoKey) { + try { capgoKey = findSavedKey(true) } + catch {} + } + const entries = createCiSecretEntries(credentials, capgoKey) setCiSecretEntries(entries) + // Stash the raw credentials so the .env-export branch can write the + // same shape `build credentials manage`'s export writes — without + // CAPGO_TOKEN. + setSavedCredentials(credentials) setStep('ask-build') } catch (err) { @@ -996,8 +1072,11 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir return } if (discovery.targets.length === 1) { - setCiSecretTarget(discovery.targets[0]) - setStep('ask-ci-secrets') + const target = discovery.targets[0] + setCiSecretTarget(target) + // GitHub → new 3-option flow; GitLab → keep existing 2-option flow. + // Workflow generation for GitLab CI is out of scope for v1. + setStep(target.provider === 'github' ? 'ask-github-actions-setup' : 'ask-ci-secrets') return } setStep('ci-secrets-target-select') @@ -1042,6 +1121,25 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const summary = `Uploaded ${ciSecretEntries.length} env var${ciSecretEntries.length === 1 ? '' : 's'} to ${getCiSecretTargetLabel(ciSecretTarget)}` setCiSecretUploadSummary(summary) addLog(`✔ ${summary}`) + // Branch on what the user picked at ask-github-actions-setup. GitLab + // path leaves setupMode='undecided' and falls through to build-complete. + if (setupMode === 'with-workflow') { + try { + const scripts = getPackageScripts() ?? {} + setAvailableScripts(scripts) + const projectType = await findProjectType({ quiet: true }).catch(() => null) + if (projectType) { + const recommended = await findBuildCommandForProjectType(projectType).catch(() => null) + if (recommended && Object.prototype.hasOwnProperty.call(scripts, recommended)) + setRecommendedScript(recommended) + } + } + catch { + // Best-effort; pick-build-script falls back to empty list + escape hatches. + } + setStep('pick-build-script') + return + } setStep('build-complete') } catch (err) { @@ -1053,6 +1151,133 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir })() } + if (step === 'writing-workflow-file') { + ;(() => { + try { + if (!buildScriptChoice) + throw new Error('Internal error: no build script choice recorded.') + const result = writeWorkflowFile({ + appId, + defaultPlatform: 'android', + packageManager: normalizePackageManager(pm.pm), + buildScript: buildScriptChoice, + secretKeys: ciSecretEntries.map(entry => entry.key), + }) + if (cancelled) + return + if (result.kind === 'exists') { + setWorkflowExistingContent(result.existingContent) + setWorkflowProposedContent(result.newContent) + setStep('confirm-workflow-overwrite') + return + } + setWorkflowWrittenPath(result.absolutePath) + addLog(`✔ Wrote ${WORKFLOW_PATH}`) + setStep('build-complete') + } + catch (err) { + if (!cancelled) { + addLog(`⚠ Failed to write workflow file: ${err instanceof Error ? err.message : String(err)}`, 'yellow') + setStep('build-complete') + } + } + })() + } + + if (step === 'overwrite-and-write-workflow') { + ;(() => { + try { + if (!buildScriptChoice) + throw new Error('Internal error: no build script choice recorded.') + const result = writeWorkflowFile( + { + appId, + defaultPlatform: 'android', + packageManager: normalizePackageManager(pm.pm), + buildScript: buildScriptChoice, + secretKeys: ciSecretEntries.map(entry => entry.key), + }, + { overwrite: true }, + ) + if (cancelled) + return + if (result.kind === 'written') { + setWorkflowWrittenPath(result.absolutePath) + addLog(`✔ Overwrote ${WORKFLOW_PATH}`) + } + setStep('build-complete') + } + catch (err) { + if (!cancelled) { + addLog(`⚠ Failed to overwrite workflow file: ${err instanceof Error ? err.message : String(err)}`, 'yellow') + setStep('build-complete') + } + } + })() + } + + if (step === 'exporting-env') { + ;(() => { + try { + const targetPath = envExportTargetPath || defaultExportPath(appId, 'android') + const result = exportCredentialsToEnv({ + appId, + platform: 'android', + credentials: savedCredentials ?? {}, + targetPath, + }) + if (cancelled) + return + if (result.kind === 'empty') { + setEnvExportError('No credentials to export — saved state is empty.') + setStep('build-complete') + return + } + if (result.kind === 'exists') { + setEnvExportTargetPath(result.path) + setStep('confirm-env-export-overwrite') + return + } + setEnvExportPath(result.path) + addLog(`✔ Exported ${result.fieldCount} field${result.fieldCount === 1 ? '' : 's'} → ${result.path}`) + setStep('build-complete') + } + catch (err) { + if (!cancelled) { + setEnvExportError(err instanceof Error ? err.message : String(err)) + setStep('build-complete') + } + } + })() + } + + if (step === 'overwrite-and-export-env') { + ;(() => { + try { + const result = exportCredentialsToEnv({ + appId, + platform: 'android', + credentials: savedCredentials ?? {}, + targetPath: envExportTargetPath, + overwrite: true, + }) + if (cancelled) + return + if (result.kind === 'written') { + setEnvExportPath(result.path) + addLog(`✔ Overwrote ${result.path} with ${result.fieldCount} field${result.fieldCount === 1 ? '' : 's'}`) + } + setStep('build-complete') + } + catch (err) { + if (!cancelled) { + setEnvExportError(err instanceof Error ? err.message : String(err)) + setStep('build-complete') + } + } + })() + } + if (step === 'requesting-build') { ;(async () => { try { @@ -1934,7 +2159,11 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } const target = ciSecretTargets.find(candidate => candidate.provider === value) || null setCiSecretTarget(target) - setStep(target ? 'ask-ci-secrets' : 'build-complete') + if (!target) { + setStep('build-complete') + return + } + setStep(target.provider === 'github' ? 'ask-github-actions-setup' : 'ask-ci-secrets') }} /> @@ -1971,6 +2200,211 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir )} + {step === 'ask-github-actions-setup' && ( + + + + Set up GitHub Actions for you? + + Capgo can push your + {' '} + {ciSecretEntries.length} + {' '} + build env var + {ciSecretEntries.length === 1 ? '' : 's'} + {' '} + as repository secrets and drop a + {' '} + .github/workflows/capgo-build.yml file you can dispatch manually. + + + { + if (value === 'yes') { + setEnvExportTargetPath(defaultExportPath(appId, 'android')) + setStep('exporting-env') + return + } + setStep('build-complete') + }} + /> + + )} + + {step === 'exporting-env' && ( + + + + )} + + {step === 'confirm-env-export-overwrite' && ( + + + {envExportTargetPath} + {' '} + already exists. + + Replace it with a fresh export, or skip? + + { + if (value === '__skip__') { + setBuildScriptChoice({ type: 'skip' }) + setStep('writing-workflow-file') + return + } + if (value === '__custom__') { + setStep('pick-build-script-custom') + return + } + setBuildScriptChoice({ type: 'npm-script', name: value }) + setStep('writing-workflow-file') + }} + /> + + )} + + {step === 'pick-build-script-custom' && ( + + Custom build command + + Type the exact command you want the workflow to run before + {' '} + capgo build request + {' '} + (e.g. + {' '} + make web + , + {' '} + bash scripts/build.sh + ). + + + { + const cleaned = value.trim() + if (!cleaned) + return + setBuildScriptChoice({ type: 'custom', command: cleaned }) + setStep('writing-workflow-file') + }} + /> + + + )} + + {step === 'writing-workflow-file' && ( + + + + )} + + {step === 'overwrite-and-write-workflow' && ( + + + + )} + + {step === 'confirm-workflow-overwrite' && ( + + + {WORKFLOW_PATH} + {' '} + already exists. + + Replace it with the new Capgo workflow, or keep your version? + {workflowExistingContent && workflowProposedContent && ( + + + Existing: + {' '} + {workflowExistingContent.split('\n').length} + {' '} + lines · New: + {' '} + {workflowProposedContent.split('\n').length} + {' '} + lines + + + )} + + { + if (value === 'no') { + setSetupMode('declined') + setStep('ask-export-env') + return + } + setSetupMode(value as 'with-workflow' | 'secrets-only') + setStep('checking-ci-secrets') + }} + /> + + )} + + {step === 'ask-export-env' && ( + + Export the credentials as a .env file instead? + + Writes + {' '} + {defaultExportPath(appId, 'ios').split('/').slice(-1)[0]} + {' '} + so you can wire up CI later via + {' '} + gh secret set -f + {' '} + or paste the values manually. + + + { + setStep(value === 'replace' ? 'overwrite-and-export-env' : 'build-complete') + }} + /> + + )} + + {step === 'pick-build-script' && ( + + Which script builds your web assets? + + Capgo will run this before invoking + {' '} + capgo build request + {' '} + in the workflow. Pick the script you use locally to produce the web build (typically into capacitor.config webDir, e.g. dist/). + + + { + setStep(value === 'replace' ? 'overwrite-and-write-workflow' : 'build-complete') + }} + /> + + )} + {step === 'checking-ci-secrets' && ( @@ -2439,6 +2887,54 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) )} + {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 {' '} diff --git a/cli/src/build/onboarding/workflow-generator.ts b/cli/src/build/onboarding/workflow-generator.ts new file mode 100644 index 0000000000..13f7bec879 --- /dev/null +++ b/cli/src/build/onboarding/workflow-generator.ts @@ -0,0 +1,234 @@ +/** + * Pure generator for a GitHub Actions workflow file that runs `capgo build + * request` against credentials uploaded as repository secrets. + * + * No I/O — caller decides where to write the resulting file. This keeps the + * generator trivially testable: feed it opts, get a `{ path, content }` back. + * + * v1 deliberately limited: GitHub-only (no GitLab YAML), single `workflow_dispatch` + * trigger (no push/pr triggers), one platform per dispatch (no matrix), no + * monorepo subdirectory handling. Each of these is a follow-up. + */ + +export type PackageManager = 'bun' | 'npm' | 'pnpm' | 'yarn' + +export type BuildScriptChoice + = | { type: 'npm-script', name: string } + | { type: 'custom', command: string } + | { type: 'skip' } + +export interface WorkflowGeneratorOpts { + /** The app's bundle ID, e.g. com.example.app */ + appId: string + /** Default platform when the user dispatches the workflow without overriding the input. */ + defaultPlatform: 'ios' | 'android' + /** Detected via getPMAndCommand(). Drives setup actions and the `npx`/`bunx`/etc runner. */ + packageManager: PackageManager + /** What runs to produce the web bundle before `capgo build request` fires. */ + buildScript: BuildScriptChoice + /** Exact secret names that were pushed to GitHub (drives the env: block). */ + secretKeys: string[] +} + +export interface GeneratedWorkflow { + /** Relative path inside the repo where the file should live. */ + path: string + /** Full file content, ready to write. */ + content: string +} + +/** Default path inside the repo. */ +export const WORKFLOW_PATH = '.github/workflows/capgo-build.yml' + +/** + * Build the GitHub Actions workflow YAML. + * + * The shape is intentionally simple: one job, one platform per dispatch, + * `workflow_dispatch` trigger only. Power users can fork it after the fact. + */ +export function generateWorkflow(opts: WorkflowGeneratorOpts): GeneratedWorkflow { + const lines: string[] = [] + + lines.push('name: Capgo native build') + lines.push('') + lines.push('# Generated by `capgo build init`. Safe to edit — see') + lines.push('# https://capgo.app/docs/cli/cloud-build/ for the underlying CLI flags.') + lines.push('') + lines.push('on:') + lines.push(' workflow_dispatch:') + lines.push(' inputs:') + lines.push(' platform:') + lines.push(` description: 'Platform to build'`) + lines.push(' type: choice') + lines.push(' options:') + lines.push(' - ios') + lines.push(' - android') + lines.push(` default: ${opts.defaultPlatform}`) + lines.push(' build_mode:') + lines.push(` description: 'Build mode'`) + lines.push(' type: choice') + lines.push(' options:') + lines.push(' - release') + lines.push(' - debug') + lines.push(` default: release`) + lines.push('') + lines.push('jobs:') + lines.push(' build:') + lines.push(' runs-on: ubuntu-latest') + lines.push(' steps:') + lines.push(' - uses: actions/checkout@v4') + + // PM setup — bun and pnpm need TWO actions (the package manager + Node). + // Per Capgo convention, bun runs alongside Node so build scripts that shell out + // to `node` directly or rely on native binaries that resolve through Node still + // work; bun's Node compat isn't perfect. + appendPackageManagerSetup(lines, opts.packageManager) + + // Install dependencies — only if there's any chance of needing them. Even when + // the user picked "skip build", they may still call npm scripts from + // postinstall hooks or use `npx`; keeping the install step is the safe default. + lines.push('') + lines.push(' - name: Install dependencies') + lines.push(` run: ${installCommand(opts.packageManager)}`) + + // Web build — only if the user didn't pick "skip". + if (opts.buildScript.type !== 'skip') { + lines.push('') + lines.push(' - name: Build web assets') + lines.push(` run: ${buildCommand(opts.packageManager, opts.buildScript)}`) + } + + // Capgo cloud build request. The `${{ ... }}` syntax below is GitHub Actions + // expression syntax, not a JS template literal — it must be emitted verbatim + // into the YAML. eslint's `no-template-curly-in-string` doesn't understand + // that and would otherwise mis-flag every single line here. + lines.push('') + lines.push(' - name: Capgo native build') + lines.push(' run: |') + lines.push(` ${runnerCommand(opts.packageManager)} @capgo/cli@latest build request ${opts.appId} \\`) + // eslint-disable-next-line no-template-curly-in-string + lines.push(' --platform ${{ inputs.platform }} \\') + // eslint-disable-next-line no-template-curly-in-string + lines.push(' --build-mode ${{ inputs.build_mode }} \\') + // eslint-disable-next-line no-template-curly-in-string + lines.push(' --apikey ${{ secrets.CAPGO_TOKEN }} \\') + lines.push(' --output-upload --output-record /tmp/capgo-build.json') + + // The env: block forwards every secret we just pushed to GitHub. We enumerate + // exactly the keys that the caller declares were pushed — no drift between + // what we set and what we reference. + if (opts.secretKeys.length > 0) { + lines.push(' env:') + for (const key of opts.secretKeys) + lines.push(` ${key}: \${{ secrets.${key} }}`) + } + + // Artifact URL → step summary. Reads the JSON record `--output-record` wrote; + // graceful no-op if the build didn't produce a URL (e.g. --output-upload was + // disabled out-of-band by editing the YAML). + lines.push('') + lines.push(' - name: Surface artifact URL') + lines.push(' if: success()') + lines.push(' run: |') + lines.push(` URL=$(${runnerCommand(opts.packageManager)} @capgo/cli@latest build last-output --path /tmp/capgo-build.json --field outputUrl)`) + lines.push(' if [ -n "$URL" ]; then') + lines.push(' {') + lines.push(' echo "### Capgo build ready"') + lines.push(' echo ""') + lines.push(' echo "- Artifact: $URL"') + lines.push(' } >> "$GITHUB_STEP_SUMMARY"') + lines.push(' fi') + + lines.push('') + + return { path: WORKFLOW_PATH, content: lines.join('\n') } +} + +function appendPackageManagerSetup(lines: string[], pm: PackageManager): void { + // Capgo convention: bun runs alongside Node. This matches the explicit + // request from the maintainer to always include actions/setup-node even when + // bun is the primary package manager. + if (pm === 'bun') { + lines.push('') + lines.push(' - uses: oven-sh/setup-bun@v2') + lines.push(' with:') + lines.push(' bun-version: latest') + lines.push(' - uses: actions/setup-node@v4') + lines.push(' with:') + lines.push(` node-version: '20'`) + return + } + + if (pm === 'pnpm') { + lines.push('') + lines.push(' - uses: pnpm/action-setup@v4') + lines.push(' with:') + lines.push(' version: latest') + lines.push(' - uses: actions/setup-node@v4') + lines.push(' with:') + lines.push(` node-version: '20'`) + lines.push(` cache: 'pnpm'`) + return + } + + if (pm === 'yarn') { + lines.push('') + lines.push(' - uses: actions/setup-node@v4') + lines.push(' with:') + lines.push(` node-version: '20'`) + lines.push(` cache: 'yarn'`) + return + } + + // npm + lines.push('') + lines.push(' - uses: actions/setup-node@v4') + lines.push(' with:') + lines.push(` node-version: '20'`) + lines.push(` cache: 'npm'`) +} + +function installCommand(pm: PackageManager): string { + switch (pm) { + case 'bun': + return 'bun install --frozen-lockfile' + case 'npm': + return 'npm ci' + case 'pnpm': + return 'pnpm install --frozen-lockfile' + case 'yarn': + return 'yarn install --frozen-lockfile' + } +} + +function buildCommand(pm: PackageManager, choice: BuildScriptChoice): string { + if (choice.type === 'custom') + return choice.command + if (choice.type === 'skip') + return '# build step skipped — user opted out at wizard time' + + const scriptName = choice.name + switch (pm) { + case 'bun': + return `bun run ${scriptName}` + case 'npm': + return `npm run ${scriptName}` + case 'pnpm': + return `pnpm run ${scriptName}` + case 'yarn': + return `yarn ${scriptName}` + } +} + +function runnerCommand(pm: PackageManager): string { + switch (pm) { + case 'bun': + return 'bunx' + case 'npm': + return 'npx' + case 'pnpm': + return 'pnpm dlx' + case 'yarn': + return 'yarn dlx' + } +} diff --git a/cli/src/build/onboarding/workflow-writer.ts b/cli/src/build/onboarding/workflow-writer.ts new file mode 100644 index 0000000000..0147ca3873 --- /dev/null +++ b/cli/src/build/onboarding/workflow-writer.ts @@ -0,0 +1,60 @@ +/** + * Thin wrapper that turns the pure `generateWorkflow` output into actual files + * on disk. Kept separate from `workflow-generator.ts` so the generator stays + * trivially unit-testable without mocking fs. + * + * The wizard's Ink layer owns all prompts (overwrite confirmation, etc.); this + * module only handles "does it exist?" and "write it". + */ + +import type { GeneratedWorkflow, WorkflowGeneratorOpts } from './workflow-generator.js' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { cwd } from 'node:process' +import { generateWorkflow, WORKFLOW_PATH } from './workflow-generator.js' + +// Re-export so callers don't need a second import from workflow-generator. +export { WORKFLOW_PATH } + +export interface WorkflowWriteOptions { + /** When true, overwrite an existing file. Default: false. */ + overwrite?: boolean + /** Optional base dir override. Defaults to cwd(). */ + baseDir?: string +} + +export type WorkflowWriteResult + = | { kind: 'written', absolutePath: string, content: string } + | { kind: 'exists', absolutePath: string, existingContent: string, newContent: string } + +/** + * Generate the workflow YAML and write it to `.github/workflows/capgo-build.yml`. + * Creates the `.github/workflows/` directory if it doesn't exist. + * + * If the file already exists and `overwrite` is false, returns `kind: 'exists'` + * with both the existing and proposed content so the caller can render a diff + * and ask for explicit confirmation before clobbering. + */ +export function writeWorkflowFile( + opts: WorkflowGeneratorOpts, + writeOptions: WorkflowWriteOptions = {}, +): WorkflowWriteResult { + const base = writeOptions.baseDir ?? cwd() + const absolutePath = resolve(base, WORKFLOW_PATH) + const generated: GeneratedWorkflow = generateWorkflow(opts) + + if (existsSync(absolutePath) && !writeOptions.overwrite) { + const existingContent = readFileSync(absolutePath, 'utf8') + return { + kind: 'exists', + absolutePath, + existingContent, + newContent: generated.content, + } + } + + mkdirSync(dirname(absolutePath), { recursive: true }) + writeFileSync(absolutePath, generated.content) + + return { kind: 'written', absolutePath, content: generated.content } +} diff --git a/cli/test/test-ci-secrets.mjs b/cli/test/test-ci-secrets.mjs index 8f5e57b6cd..69ddab7c0e 100644 --- a/cli/test/test-ci-secrets.mjs +++ b/cli/test/test-ci-secrets.mjs @@ -83,6 +83,27 @@ await test('creates env entries and converts provisioning map to base64', () => assert(mapEntry.masked, 'Provisioning map base64 should be masked') }) +await test('omits CAPGO_TOKEN when no API key is provided', () => { + const entries = createCiSecretEntries({ BUILD_CERTIFICATE_BASE64: 'cert' }) + const keys = entries.map(entry => entry.key) + assert(!keys.includes('CAPGO_TOKEN'), 'CAPGO_TOKEN should be absent without an API key') +}) + +await test('includes a masked CAPGO_TOKEN when an API key is provided', () => { + // The generated GitHub Actions workflow references ${{ secrets.CAPGO_TOKEN }} + // for --apikey, so the wizard must push it alongside build credentials. + const entries = createCiSecretEntries({ BUILD_CERTIFICATE_BASE64: 'cert' }, 'cap_test_apikey_xyz') + const tokenEntry = entries.find(entry => entry.key === 'CAPGO_TOKEN') + assert(tokenEntry !== undefined, 'CAPGO_TOKEN should be pushed when API key is provided') + assertEquals(tokenEntry.value, 'cap_test_apikey_xyz') + assert(tokenEntry.masked, 'CAPGO_TOKEN must be masked') +}) + +await test('treats an empty-string API key as "no token" (no entry)', () => { + const entries = createCiSecretEntries({ BUILD_CERTIFICATE_BASE64: 'cert' }, '') + assert(!entries.some(e => e.key === 'CAPGO_TOKEN'), 'Empty API key must not produce a CAPGO_TOKEN entry') +}) + await test('detects authenticated GitHub target from git remotes', () => { const runner = createRunner({ 'git remote -v': { diff --git a/cli/test/test-workflow-generator.mjs b/cli/test/test-workflow-generator.mjs new file mode 100644 index 0000000000..98c4bbed7a --- /dev/null +++ b/cli/test/test-workflow-generator.mjs @@ -0,0 +1,236 @@ +#!/usr/bin/env node + +import { generateWorkflow, WORKFLOW_PATH } from '../src/build/onboarding/workflow-generator.ts' + +console.log('🧪 Testing GitHub Actions workflow generator...\n') + +let testsPassed = 0 +let testsFailed = 0 + +async function test(name, fn) { + try { + console.log(`\n🔍 ${name}`) + await fn() + console.log(`✅ PASSED: ${name}`) + testsPassed++ + } + catch (error) { + console.error(`❌ FAILED: ${name}`) + console.error(` Error: ${error.message}`) + testsFailed++ + } +} + +function assert(condition, message) { + if (!condition) + throw new Error(message || 'Assertion failed') +} + +function assertEquals(actual, expected, message) { + if (actual !== expected) + throw new Error(message || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`) +} + +function assertIncludes(haystack, needle, message) { + if (!haystack.includes(needle)) + throw new Error(message || `Expected output to include:\n ${needle}\nbut it did not. Output was:\n${haystack}`) +} + +function assertExcludes(haystack, needle, message) { + if (haystack.includes(needle)) + throw new Error(message || `Expected output to NOT include:\n ${needle}\nbut it did. Output was:\n${haystack}`) +} + +await test('writes to .github/workflows/capgo-build.yml', () => { + const result = generateWorkflow({ + appId: 'com.example.app', + defaultPlatform: 'ios', + packageManager: 'bun', + buildScript: { type: 'npm-script', name: 'build' }, + secretKeys: [], + }) + assertEquals(result.path, '.github/workflows/capgo-build.yml') + assertEquals(result.path, WORKFLOW_PATH) +}) + +await test('bun template includes BOTH setup-bun AND setup-node', () => { + // Per maintainer convention: bun runs alongside Node so build scripts that + // shell out to `node` directly or rely on Node-resolvable native binaries + // still work; bun's Node compat isn't perfect. + const { content } = generateWorkflow({ + appId: 'com.example.app', + defaultPlatform: 'ios', + packageManager: 'bun', + buildScript: { type: 'npm-script', name: 'build' }, + secretKeys: [], + }) + assertIncludes(content, 'oven-sh/setup-bun@v2') + assertIncludes(content, 'actions/setup-node@v4') + assertIncludes(content, 'bun install --frozen-lockfile') + assertIncludes(content, 'bun run build') + assertIncludes(content, 'bunx @capgo/cli@latest build request com.example.app') +}) + +await test('npm template uses npm ci, npm run, and npx', () => { + const { content } = generateWorkflow({ + appId: 'com.example.app', + defaultPlatform: 'ios', + packageManager: 'npm', + buildScript: { type: 'npm-script', name: 'build:prod' }, + secretKeys: [], + }) + assertIncludes(content, 'actions/setup-node@v4') + assertExcludes(content, 'setup-bun', 'npm template should not include setup-bun') + assertIncludes(content, `cache: 'npm'`) + assertIncludes(content, 'npm ci') + assertIncludes(content, 'npm run build:prod') + assertIncludes(content, 'npx @capgo/cli@latest build request com.example.app') +}) + +await test('pnpm template includes pnpm setup AND setup-node with pnpm cache', () => { + const { content } = generateWorkflow({ + appId: 'com.example.app', + defaultPlatform: 'android', + packageManager: 'pnpm', + buildScript: { type: 'npm-script', name: 'build' }, + secretKeys: [], + }) + assertIncludes(content, 'pnpm/action-setup@v4') + assertIncludes(content, 'actions/setup-node@v4') + assertIncludes(content, `cache: 'pnpm'`) + assertIncludes(content, 'pnpm install --frozen-lockfile') + assertIncludes(content, 'pnpm run build') + assertIncludes(content, 'pnpm dlx @capgo/cli@latest build request') +}) + +await test('yarn template uses yarn (not yarn run) for npm-script', () => { + const { content } = generateWorkflow({ + appId: 'com.example.app', + defaultPlatform: 'ios', + packageManager: 'yarn', + buildScript: { type: 'npm-script', name: 'build' }, + secretKeys: [], + }) + assertIncludes(content, `cache: 'yarn'`) + assertIncludes(content, 'yarn install --frozen-lockfile') + // yarn classic invokes scripts without `run` + assertIncludes(content, '\n run: yarn build\n') + assertIncludes(content, 'yarn dlx @capgo/cli@latest build request') +}) + +await test('custom build command is rendered verbatim', () => { + const { content } = generateWorkflow({ + appId: 'com.example.app', + defaultPlatform: 'ios', + packageManager: 'npm', + buildScript: { type: 'custom', command: 'make web' }, + secretKeys: [], + }) + assertIncludes(content, 'run: make web') + assertExcludes(content, 'npm run', 'should not prepend npm run for custom command') +}) + +await test('skip build omits the build step but keeps install', () => { + // Plain HTML/JS Capacitor apps exist (rare but real). They still need deps + // installed (postinstall hooks, etc.) but have no separate build step. + const { content } = generateWorkflow({ + appId: 'com.example.app', + defaultPlatform: 'ios', + packageManager: 'bun', + buildScript: { type: 'skip' }, + secretKeys: [], + }) + assertIncludes(content, 'Install dependencies') + assertExcludes(content, 'Build web assets', 'skip mode must omit the web build step') +}) + +await test('secret keys appear verbatim in env: block', () => { + const { content } = generateWorkflow({ + appId: 'com.example.app', + defaultPlatform: 'ios', + packageManager: 'bun', + buildScript: { type: 'npm-script', name: 'build' }, + secretKeys: [ + 'P12_PASSWORD', + 'BUILD_CERTIFICATE_BASE64', + 'CAPGO_IOS_PROVISIONING_MAP_BASE64', + 'APP_STORE_CONNECT_KEY_ID', + 'CAPGO_TOKEN', + ], + }) + assertIncludes(content, ' env:') + assertIncludes(content, ' P12_PASSWORD: ${{ secrets.P12_PASSWORD }}') + assertIncludes(content, ' BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}') + assertIncludes(content, ' CAPGO_IOS_PROVISIONING_MAP_BASE64: ${{ secrets.CAPGO_IOS_PROVISIONING_MAP_BASE64 }}') + assertIncludes(content, ' APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}') + // CAPGO_TOKEN gets used both in --apikey and the env block; both must be present. + assertIncludes(content, '--apikey ${{ secrets.CAPGO_TOKEN }}') +}) + +await test('empty secretKeys produces no env block', () => { + const { content } = generateWorkflow({ + appId: 'com.example.app', + defaultPlatform: 'ios', + packageManager: 'bun', + buildScript: { type: 'npm-script', name: 'build' }, + secretKeys: [], + }) + assertExcludes(content, '\n env:\n', 'empty secret list must not emit env block') + // --apikey reference is still there even without an env block — the user can + // wire CAPGO_TOKEN manually if they declined the secrets push. + assertIncludes(content, '--apikey ${{ secrets.CAPGO_TOKEN }}') +}) + +await test('default platform respects the wizard-side selection', () => { + const ios = generateWorkflow({ + appId: 'com.example.app', + defaultPlatform: 'ios', + packageManager: 'bun', + buildScript: { type: 'npm-script', name: 'build' }, + secretKeys: [], + }) + assertIncludes(ios.content, ' default: ios') + + const android = generateWorkflow({ + appId: 'com.example.app', + defaultPlatform: 'android', + packageManager: 'bun', + buildScript: { type: 'npm-script', name: 'build' }, + secretKeys: [], + }) + assertIncludes(android.content, ' default: android') +}) + +await test('workflow_dispatch trigger present, push/pr triggers absent', () => { + // v1 intentionally limits to manual trigger. Push/PR triggers are out of + // scope so we never accidentally fire a build on every commit. + const { content } = generateWorkflow({ + appId: 'com.example.app', + defaultPlatform: 'ios', + packageManager: 'bun', + buildScript: { type: 'npm-script', name: 'build' }, + secretKeys: [], + }) + assertIncludes(content, 'workflow_dispatch:') + assertExcludes(content, '\n push:\n', 'v1 must not include push trigger') + assertExcludes(content, '\n pull_request:\n', 'v1 must not include pull_request trigger') +}) + +await test('artifact URL surface step uses GITHUB_STEP_SUMMARY', () => { + const { content } = generateWorkflow({ + appId: 'com.example.app', + defaultPlatform: 'ios', + packageManager: 'bun', + buildScript: { type: 'npm-script', name: 'build' }, + secretKeys: [], + }) + assertIncludes(content, '--output-record /tmp/capgo-build.json') + assertIncludes(content, 'build last-output --path /tmp/capgo-build.json --field outputUrl') + assertIncludes(content, '$GITHUB_STEP_SUMMARY') +}) + +if (testsFailed > 0) { + console.error(`\n❌ ${testsFailed} test(s) failed`) + process.exit(1) +} +console.log(`\n✅ Workflow generator tests passed (${testsPassed})`) From 07a0b540f03615f90fac7603464ed2ea88da45ad Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Fri, 22 May 2026 10:22:22 +0200 Subject: [PATCH 2/9] feat(cli): add build onboarding workflow preview --- bun.lock | 2 +- cli/src/build/onboarding/analytics.ts | 117 ++++++ cli/src/build/onboarding/android/types.ts | 14 +- cli/src/build/onboarding/android/ui/app.tsx | 391 +++++++++++++++++-- cli/src/build/onboarding/ci-secrets.ts | 225 ++++++++++- cli/src/build/onboarding/command.ts | 16 +- cli/src/build/onboarding/diff-utils.ts | 86 ++++ cli/src/build/onboarding/types.ts | 14 +- cli/src/build/onboarding/ui/app.tsx | 410 ++++++++++++++++++-- cli/src/build/onboarding/ui/components.tsx | 272 ++++++++++++- cli/test/test-ci-secrets.mjs | 42 ++ cli/test/test-diff-utils.mjs | 107 +++++ 12 files changed, 1608 insertions(+), 88 deletions(-) create mode 100644 cli/src/build/onboarding/analytics.ts create mode 100644 cli/src/build/onboarding/diff-utils.ts create mode 100644 cli/test/test-diff-utils.mjs diff --git a/bun.lock b/bun.lock index 2501a44723..9a30924da2 100644 --- a/bun.lock +++ b/bun.lock @@ -202,7 +202,7 @@ }, "cli": { "name": "@capgo/cli", - "version": "7.104.0", + "version": "7.108.3", "bin": { "capgo": "dist/index.js", }, diff --git a/cli/src/build/onboarding/analytics.ts b/cli/src/build/onboarding/analytics.ts new file mode 100644 index 0000000000..d7b8dca7a9 --- /dev/null +++ b/cli/src/build/onboarding/analytics.ts @@ -0,0 +1,117 @@ +import type { DiffLine } from './diff-utils.js' +import type { BuildScriptChoice, PackageManager } from './workflow-generator.js' +import { createSupabaseClient, findSavedKeySilent, sendEvent } from '../../utils.js' + +export type BuildOnboardingWorkflowEvent + = | 'workflow-preview-prepared' + | 'workflow-preview-action' + | 'workflow-diff-opened' + | 'workflow-diff-closed' + | 'workflow-file-written' + +export type BuildOnboardingWorkflowDecision = 'write' | 'view' | 'cancel' | 'escape' | 'close' +export type BuildOnboardingWorkflowState = 'new' | 'replace' | 'identical' + +export interface WorkflowDiffTelemetry { + workflowState: BuildOnboardingWorkflowState + diffLines: number + diffAdded: number + diffRemoved: number +} + +interface TrackBuildOnboardingWorkflowOptions extends WorkflowDiffTelemetry { + event: BuildOnboardingWorkflowEvent + appId: string + platform: 'ios' | 'android' + apikey?: string + decision?: BuildOnboardingWorkflowDecision + packageManager?: PackageManager + buildScriptType?: BuildScriptChoice['type'] +} + +const WORKFLOW_EVENT_NAMES: Record = { + 'workflow-preview-prepared': 'Build onboarding workflow preview prepared', + 'workflow-preview-action': 'Build onboarding workflow preview action', + 'workflow-diff-opened': 'Build onboarding workflow diff opened', + 'workflow-diff-closed': 'Build onboarding workflow diff closed', + 'workflow-file-written': 'Build onboarding workflow file written', +} + +const orgIdCache = new Map>() + +export function getWorkflowDiffTelemetry(lines: DiffLine[], isNew: boolean): WorkflowDiffTelemetry { + const diffAdded = lines.filter(line => line.kind === 'add').length + const diffRemoved = lines.filter(line => line.kind === 'del').length + const workflowState = lines.length > 0 && diffAdded === 0 && diffRemoved === 0 + ? 'identical' + : (isNew ? 'new' : 'replace') + + return { + workflowState, + diffLines: lines.length, + diffAdded, + diffRemoved, + } +} + +export function trackBuildOnboardingWorkflowEvent(options: TrackBuildOnboardingWorkflowOptions): void { + void trackBuildOnboardingWorkflowEventAsync(options) +} + +async function trackBuildOnboardingWorkflowEventAsync(options: TrackBuildOnboardingWorkflowOptions): Promise { + const apikey = options.apikey?.trim() || findSavedKeySilent() + if (!apikey) + return + + const orgId = await resolveOrganizationId(apikey, options.appId) + const tags: Record = { + 'app-id': options.appId, + 'platform': options.platform, + 'workflow-state': options.workflowState, + 'diff-lines': options.diffLines, + 'diff-added': options.diffAdded, + 'diff-removed': options.diffRemoved, + } + + if (options.decision) + tags.decision = options.decision + if (options.packageManager) + tags['package-manager'] = options.packageManager + if (options.buildScriptType) + tags['build-script-type'] = options.buildScriptType + + await sendEvent(apikey, { + channel: 'native-builder', + event: WORKFLOW_EVENT_NAMES[options.event], + icon: '🧭', + user_id: orgId, + tags, + notify: false, + }) +} + +async function resolveOrganizationId(apikey: string, appId: string): Promise { + const cacheKey = `${apikey}:${appId}` + const cached = orgIdCache.get(cacheKey) + if (cached) + return cached + + const promise = (async () => { + try { + const supabase = await createSupabaseClient(apikey, undefined, undefined, true) + const { data } = await supabase + .from('apps') + .select('owner_org') + .eq('app_id', appId) + .maybeSingle() + + return data?.owner_org + } + catch { + return undefined + } + })() + + orgIdCache.set(cacheKey, promise) + return promise +} diff --git a/cli/src/build/onboarding/android/types.ts b/cli/src/build/onboarding/android/types.ts index 49ba981bb8..3a00aa374d 100644 --- a/cli/src/build/onboarding/android/types.ts +++ b/cli/src/build/onboarding/android/types.ts @@ -48,12 +48,16 @@ export type AndroidOnboardingStep | 'ci-secrets-failed' // GitHub Actions workflow + .env export sub-flow (post-secrets-upload) | 'ask-github-actions-setup' + | 'confirm-secrets-push' | 'ask-export-env' | 'exporting-env' | 'confirm-env-export-overwrite' | 'overwrite-and-export-env' + | 'pick-package-manager' | 'pick-build-script' | 'pick-build-script-custom' + | 'preview-workflow-file' + | 'view-workflow-diff' | 'writing-workflow-file' | 'confirm-workflow-overwrite' | 'overwrite-and-write-workflow' @@ -188,13 +192,17 @@ export const ANDROID_STEP_PROGRESS: Record = { 'ci-secrets-failed': 88, // GitHub Actions + .env export branch — post-build, ~96 'ask-github-actions-setup': 86, + 'confirm-secrets-push': 87, 'ask-export-env': 96, 'exporting-env': 96, 'confirm-env-export-overwrite': 96, 'overwrite-and-export-env': 96, + 'pick-package-manager': 95, 'pick-build-script': 96, 'pick-build-script-custom': 96, - 'writing-workflow-file': 97, + 'preview-workflow-file': 97, + 'view-workflow-diff': 97, + 'writing-workflow-file': 98, 'confirm-workflow-overwrite': 97, 'overwrite-and-write-workflow': 97, 'ask-build': 90, @@ -243,6 +251,7 @@ export function getAndroidPhaseLabel(step: AndroidOnboardingStep): string { case 'ci-secrets-target-select': case 'ask-ci-secrets': case 'ask-github-actions-setup': + case 'confirm-secrets-push': case 'checking-ci-secrets': case 'confirm-ci-secret-overwrite': case 'uploading-ci-secrets': @@ -251,8 +260,11 @@ export function getAndroidPhaseLabel(step: AndroidOnboardingStep): string { case 'exporting-env': case 'confirm-env-export-overwrite': case 'overwrite-and-export-env': + case 'pick-package-manager': case 'pick-build-script': case 'pick-build-script-custom': + case 'preview-workflow-file': + case 'view-workflow-diff': case 'writing-workflow-file': case 'confirm-workflow-overwrite': case 'overwrite-and-write-workflow': diff --git a/cli/src/build/onboarding/android/ui/app.tsx b/cli/src/build/onboarding/android/ui/app.tsx index d5d7b36960..a59d13c0d5 100644 --- a/cli/src/build/onboarding/android/ui/app.tsx +++ b/cli/src/build/onboarding/android/ui/app.tsx @@ -13,19 +13,19 @@ import type { ServiceAccountProvisioned, } from '../types.js' import { handleCustomMsg } from '../../../qr.js' -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' 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, useStdout } from 'ink' +import { Box, Newline, Text, useApp, useInput } from 'ink' // src/build/onboarding/android/ui/app.tsx import React, { useCallback, useEffect, useRef, useState } from 'react' import { findSavedKey } from '../../../../utils.js' import { loadSavedCredentials, updateSavedCredentials } from '../../../credentials.js' import { requestBuildInternal } from '../../../request.js' -import { createCiSecretEntries, detectCiSecretTargets, getCiSecretTargetLabel, listExistingCiSecretKeys, uploadCiSecrets } from '../../ci-secrets.js' +import { createCiSecretEntries, detectCiSecretTargets, getCiSecretRepoLabelAsync, getCiSecretTargetLabel, listExistingCiSecretKeysAsync, uploadCiSecretsAsync } from '../../ci-secrets.js' import type { CiSecretEntry, CiSecretSetupAdvice, CiSecretTarget } from '../../ci-secrets.js' import { defaultExportPath, exportCredentialsToEnv } from '../../env-export.js' import { writeWorkflowFile, WORKFLOW_PATH } from '../../workflow-writer.js' @@ -34,7 +34,12 @@ import { findBuildCommandForProjectType, findProjectType, getPackageScripts, get import type { BuildCredentials } from '../../../../schemas/build.js' import { canUseFilePicker, openKeystorePicker } from '../../file-picker.js' import { findAndroidApplicationIds } from '../gradle-parser.js' -import { Divider, ErrorLine, FilteredTextInput, Header, SpinnerLine, SuccessLine } from '../../ui/components.js' +import { DiffSummary, Divider, ErrorLine, FilteredTextInput, FullscreenDiffViewer, Header, SecretsTable, SpinnerLine, SuccessLine } from '../../ui/components.js' +import { diffLines } from '../../diff-utils.js' +import type { DiffLine } from '../../diff-utils.js' +import { generateWorkflow, WORKFLOW_PATH as WORKFLOW_GEN_PATH } from '../../workflow-generator.js' +import { getWorkflowDiffTelemetry, trackBuildOnboardingWorkflowEvent } from '../../analytics.js' +import type { BuildOnboardingWorkflowDecision, BuildOnboardingWorkflowEvent, WorkflowDiffTelemetry } from '../../analytics.js' import { ANDROIDPUBLISHER_API, createServiceAccountKey, @@ -81,6 +86,7 @@ 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,6 +134,7 @@ function normalizePackageManager(pm: string): PackageManager { return 'npm' } + interface BuildScriptOption { label: string value: string @@ -158,7 +165,7 @@ function buildScriptPickerOptions(scripts: Record, recommended: return options } -const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir, apikey }) => { +const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir, apikey, terminalRows = 24 }) => { const { exit } = useApp() const startStep: AndroidOnboardingStep = getAndroidResumeStep(initialProgress) @@ -254,6 +261,24 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const [ciSecretTarget, setCiSecretTarget] = useState(null) const [ciSecretSetupAdvice, setCiSecretSetupAdvice] = useState([]) const [ciSecretExistingKeys, setCiSecretExistingKeys] = useState([]) + // Concrete `owner/repo` for GitHub. Resolved in checking-ci-secrets via + // `gh repo view`. Shown in confirm-secrets-push so the user knows EXACTLY + // which repo they're about to mutate before `gh secret set` runs. + const [ciSecretRepoLabel, setCiSecretRepoLabel] = useState(null) + // Sub-phase text for the spinner while gh shell-outs are in flight — keeps + // the user informed instead of showing a single static "Checking…" line + // that freezes for multiple seconds. + const [ciSecretCheckPhase, setCiSecretCheckPhase] = useState('Resolving GitHub repository…') + const [ciSecretUploadProgress, setCiSecretUploadProgress] = useState<{ current: number, total: number, key: string } | null>(null) + // User-chosen package manager (asked at pick-package-manager). Overrides + // the auto-detected one. Falls back to detection when not set. + const [selectedPackageManager, setSelectedPackageManager] = useState(null) + // preview-workflow-file viewer state. The large diff is only shown in the + // bounded `view-workflow-diff` live Ink screen. + const [previewDiff, setPreviewDiff] = useState([]) + const [previewExistingPath, setPreviewExistingPath] = useState(null) + const [previewIsNew, setPreviewIsNew] = useState(true) + const [previewTelemetry, setPreviewTelemetry] = useState(null) const [ciSecretError, setCiSecretError] = useState(null) const [ciSecretUploadSummary, setCiSecretUploadSummary] = useState(null) // GitHub Actions workflow setup state. setupMode tracks the 3-way choice at @@ -273,8 +298,25 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const [savedCredentials, setSavedCredentials] = useState | null>(null) const pm = getPMAndCommand() - const { stdout } = useStdout() - const terminalRows = stdout?.rows ?? 24 + const trackWorkflowEvent = ( + event: BuildOnboardingWorkflowEvent, + options: { decision?: BuildOnboardingWorkflowDecision, diff?: DiffLine[], isNew?: boolean } = {}, + ) => { + const telemetry = options.diff + ? getWorkflowDiffTelemetry(options.diff, options.isNew ?? previewIsNew) + : (previewTelemetry ?? getWorkflowDiffTelemetry(previewDiff, options.isNew ?? previewIsNew)) + + trackBuildOnboardingWorkflowEvent({ + event, + appId, + platform: 'android', + apikey, + packageManager: selectedPackageManager ?? normalizePackageManager(pm.pm), + buildScriptType: buildScriptChoice?.type, + decision: options.decision, + ...telemetry, + }) + } const addLog = useCallback((text: string, color = 'green') => { setLogLines(prev => [...prev, { text, color }]) @@ -296,6 +338,13 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir useInput((input, key) => { if (key.ctrl && input === 'c') process.kill(process.pid, 'SIGINT') + + // preview-workflow-file: Esc skips. Arrows/Enter go to the Select. + if (step === 'preview-workflow-file' && key.escape) { + trackWorkflowEvent('workflow-preview-action', { decision: 'escape' }) + setPreviewDiff([]) + setStep('build-complete') + } }) const persist = useCallback( @@ -1095,10 +1144,28 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir try { if (!ciSecretTarget) throw new Error('No git hosting target selected.') - const existing = listExistingCiSecretKeys(ciSecretTarget, ciSecretEntries.map(entry => entry.key)) + // Phase 1: resolve target repo via async gh — non-blocking so the + // spinner keeps animating. + setCiSecretCheckPhase('Resolving GitHub repository…') + let repoLabel: string | null = null + if (ciSecretTarget.provider === 'github') { + repoLabel = await getCiSecretRepoLabelAsync(ciSecretTarget) + if (cancelled) + return + setCiSecretRepoLabel(repoLabel) + } + // Phase 2: list existing secrets. + setCiSecretCheckPhase(repoLabel + ? `Checking existing env vars in ${repoLabel}…` + : `Checking existing env vars in ${getCiSecretTargetLabel(ciSecretTarget)}…`) + const existing = await listExistingCiSecretKeysAsync(ciSecretTarget, ciSecretEntries.map(entry => entry.key)) if (cancelled) return setCiSecretExistingKeys(existing) + if (ciSecretTarget.provider === 'github') { + setStep('confirm-secrets-push') + return + } setStep(existing.length > 0 ? 'confirm-ci-secret-overwrite' : 'uploading-ci-secrets') } catch (err) { @@ -1115,9 +1182,19 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir try { if (!ciSecretTarget) throw new Error('No git hosting target selected.') - uploadCiSecrets(ciSecretTarget, ciSecretEntries, ciSecretExistingKeys) + await uploadCiSecretsAsync( + ciSecretTarget, + ciSecretEntries, + ciSecretExistingKeys, + undefined, + (current, total, key) => { + if (!cancelled) + setCiSecretUploadProgress({ current, total, key }) + }, + ) if (cancelled) return + setCiSecretUploadProgress(null) const summary = `Uploaded ${ciSecretEntries.length} env var${ciSecretEntries.length === 1 ? '' : 's'} to ${getCiSecretTargetLabel(ciSecretTarget)}` setCiSecretUploadSummary(summary) addLog(`✔ ${summary}`) @@ -1137,7 +1214,8 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir catch { // Best-effort; pick-build-script falls back to empty list + escape hatches. } - setStep('pick-build-script') + // Ask the user to confirm the package manager before we build the workflow. + setStep('pick-package-manager') return } setStep('build-complete') @@ -1151,34 +1229,84 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir })() } - if (step === 'writing-workflow-file') { + if (step === 'preview-workflow-file') { ;(() => { try { if (!buildScriptChoice) throw new Error('Internal error: no build script choice recorded.') - const result = writeWorkflowFile({ + const proposed = generateWorkflow({ appId, defaultPlatform: 'android', - packageManager: normalizePackageManager(pm.pm), + packageManager: selectedPackageManager ?? normalizePackageManager(pm.pm), buildScript: buildScriptChoice, secretKeys: ciSecretEntries.map(entry => entry.key), }) + const absolutePath = resolvePath(process.cwd(), WORKFLOW_GEN_PATH) + let existing = '' + let isNew = true + if (existsSync(absolutePath)) { + try { + existing = readFileSync(absolutePath, 'utf8') + isNew = false + } + catch { + // Treat unreadable file as new. + } + } if (cancelled) return - if (result.kind === 'exists') { - setWorkflowExistingContent(result.existingContent) - setWorkflowProposedContent(result.newContent) - setStep('confirm-workflow-overwrite') + const diff = diffLines(existing, proposed.content) + const telemetry = getWorkflowDiffTelemetry(diff, isNew) + setWorkflowProposedContent(proposed.content) + setPreviewExistingPath(absolutePath) + setPreviewIsNew(isNew) + setPreviewTelemetry(telemetry) + setPreviewDiff(diff) + trackWorkflowEvent('workflow-preview-prepared', { diff, isNew }) + } + catch (err) { + if (!cancelled) { + addLog(`⚠ Failed to build workflow preview: ${err instanceof Error ? err.message : String(err)}`, 'yellow') + setStep('build-complete') + } + } + })() + } + + if (step === 'writing-workflow-file') { + ;(() => { + try { + if (!buildScriptChoice) + throw new Error('Internal error: no build script choice recorded.') + const result = writeWorkflowFile( + { + appId, + defaultPlatform: 'android', + packageManager: selectedPackageManager ?? normalizePackageManager(pm.pm), + buildScript: buildScriptChoice, + secretKeys: ciSecretEntries.map(entry => entry.key), + }, + { overwrite: true }, + ) + if (cancelled) return + if (result.kind === 'written') { + setWorkflowWrittenPath(result.absolutePath) + addLog(`✔ ${previewIsNew ? 'Wrote' : 'Overwrote'} ${WORKFLOW_PATH}`) + trackWorkflowEvent('workflow-file-written', { decision: 'write' }) } - setWorkflowWrittenPath(result.absolutePath) - addLog(`✔ Wrote ${WORKFLOW_PATH}`) - setStep('build-complete') + setTimeout(() => { + if (!cancelled) + setStep('build-complete') + }, 150) } catch (err) { if (!cancelled) { addLog(`⚠ Failed to write workflow file: ${err instanceof Error ? err.message : String(err)}`, 'yellow') - setStep('build-complete') + setTimeout(() => { + if (!cancelled) + setStep('build-complete') + }, 150) } } })() @@ -1373,10 +1501,38 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const progressPct = ANDROID_STEP_PROGRESS[step] ?? 0 const phaseLabel = getAndroidPhaseLabel(step) - const showProgress = step !== 'welcome' && step !== 'error' && step !== 'build-complete' && step !== 'requesting-build' - const showHeader = step !== 'requesting-build' - const showLog = step !== 'requesting-build' && step !== 'build-complete' - + // 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' + || step === 'checking-ci-secrets' + || step === 'ask-github-actions-setup' + || step === 'confirm-secrets-push' + || step === 'uploading-ci-secrets' + || step === 'ci-secrets-target-select' + || step === 'ci-secrets-setup' + || step === 'ask-ci-secrets' + || step === 'pick-package-manager' + || step === 'pick-build-script' + || step === 'pick-build-script-custom' + || step === 'preview-workflow-file' + || step === 'view-workflow-diff' + || step === 'writing-workflow-file' + || step === 'ask-export-env' + || step === 'exporting-env' + || step === 'confirm-env-export-overwrite' + || step === 'overwrite-and-export-env' + || step === 'overwrite-and-write-workflow' + || 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 return ( {showHeader &&
} @@ -2220,9 +2376,9 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir { if (value === 'yes') { @@ -2286,8 +2442,8 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir { + setSelectedPackageManager(value as PackageManager) + setStep('pick-build-script') + }} + /> + + ) + })()} + {step === 'pick-build-script' && ( Which script builds your web assets? @@ -2312,7 +2498,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir onChange={(value) => { if (value === '__skip__') { setBuildScriptChoice({ type: 'skip' }) - setStep('writing-workflow-file') + setStep('preview-workflow-file') return } if (value === '__custom__') { @@ -2320,7 +2506,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir return } setBuildScriptChoice({ type: 'npm-script', name: value }) - setStep('writing-workflow-file') + setStep('preview-workflow-file') }} /> @@ -2350,13 +2536,73 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir if (!cleaned) return setBuildScriptChoice({ type: 'custom', command: cleaned }) - setStep('writing-workflow-file') + setStep('preview-workflow-file') }} /> )} + {step === 'preview-workflow-file' && previewDiff.length > 0 && (() => { + const allEqual = previewDiff.every(l => l.kind === 'eq') + const writeLabel = allEqual + ? '✏️ Write file anyway (re-writes identical content)' + : (previewIsNew ? '✏️ Write file' : '✏️ Replace existing file') + const skipLabel = '❌ Do not write file' + const title = previewIsNew + ? `🆕 Proposed new file — ${previewExistingPath ?? WORKFLOW_PATH}` + : `✏️ Proposed changes — ${previewExistingPath ?? WORKFLOW_PATH}` + const subtitle = previewIsNew + ? 'Nothing exists on disk yet. Every line below is what would be written.' + : 'Proposed diff vs the file on disk. Lines marked - would be removed, lines marked + would be added.' + return ( + + + + What should we do with {WORKFLOW_PATH}? + { setStep(value === 'replace' ? 'overwrite-and-write-workflow' : 'build-complete') @@ -2406,7 +2652,68 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir )} {step === 'checking-ci-secrets' && ( - + + )} + + {step === 'confirm-secrets-push' && ( + (() => { + const existingSet = new Set(ciSecretExistingKeys) + const newCount = ciSecretEntries.filter(entry => !existingSet.has(entry.key)).length + const replaceCount = ciSecretEntries.length - newCount + const repoLine = ciSecretRepoLabel + ? ciSecretRepoLabel + : '(could not resolve repository — gh repo view failed)' + return ( + + ⚠ Confirm before pushing secrets + + + Repository: + {' '} + {repoLine} + + (resolved via `gh repo view` from this directory) + + + {`Will push ${ciSecretEntries.length} env var${ciSecretEntries.length === 1 ? '' : 's'}`} + {replaceCount > 0 ? ` — ${newCount} new, ${replaceCount} REPLACING existing:` : ' — all new:'} + + + ({ + name: entry.key, + status: existingSet.has(entry.key) ? 'REPLACE' : 'NEW', + }))} + /> + + {replaceCount > 0 && ( + + ⚠ `gh secret set` overwrites silently — replaced values cannot be recovered. + + )} + + ) + })() + )} + {step === 'confirm-secrets-push' && ( + <> + + { if (value === 'no') { @@ -2494,8 +2665,8 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) { setStep(value === 'replace' ? 'overwrite-and-export-env' : 'build-complete') @@ -2536,6 +2707,36 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) )} + {step === 'pick-package-manager' && (() => { + const detected = normalizePackageManager(pm.pm) + const detectionNote = pm.pm === 'unknown' + ? '(no recognizable lockfile in this project — pick whichever you actually use)' + : `(detected from your lockfile — ${pm.pm})` + return ( + + Which package manager does this project use? + + Drives the install + build steps in the generated workflow. We + {' '} + {detectionNote} + + + { + if (value === 'view') { + trackWorkflowEvent('workflow-preview-action', { decision: 'view' }) + trackWorkflowEvent('workflow-diff-opened', { decision: 'view' }) + setStep('view-workflow-diff') + return + } + trackWorkflowEvent('workflow-preview-action', { decision: value === 'write' ? 'write' : 'cancel' }) + setPreviewDiff([]) + setStep(value === 'write' ? 'writing-workflow-file' : 'build-complete') + }} + /> + + + ) + })()} + {step === 'preview-workflow-file' && previewDiff.length === 0 && ( + + + + )} + + {step === 'view-workflow-diff' && previewDiff.length > 0 && ( + { + trackWorkflowEvent('workflow-diff-closed', { decision: 'close' }) + setStep('preview-workflow-file') + }} + /> + )} + {step === 'writing-workflow-file' && ( @@ -2636,8 +2899,8 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) { + setStep(value === 'confirm' ? 'uploading-ci-secrets' : 'build-complete') + }} + /> + + + )} + {step === 'confirm-ci-secret-overwrite' && ( These env vars already exist and will be replaced: @@ -2675,7 +2999,11 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) {step === 'uploading-ci-secrets' && ( - + )} diff --git a/cli/src/build/onboarding/ui/components.tsx b/cli/src/build/onboarding/ui/components.tsx index a303bc6d76..f75eb65094 100644 --- a/cli/src/build/onboarding/ui/components.tsx +++ b/cli/src/build/onboarding/ui/components.tsx @@ -2,7 +2,8 @@ import type { FC } from 'react' import { Box, Text, useInput } from 'ink' import Spinner from 'ink-spinner' // src/build/onboarding/ui/components.tsx -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' +import type { DiffLine } from '../diff-utils.js' export const Divider: FC<{ width?: number }> = ({ width = 60 }) => ( {'─'.repeat(width)} @@ -98,3 +99,272 @@ export const Header: FC = () => ( ) + +/** + * Minimal bordered table component for the confirm-secrets-push step. + * + * Rolled in-house instead of pulling `ink-table` because that package is + * CommonJS-only and Ink 5 uses top-level await — bun can't bundle the combo. + * Replicates the visual style (box-drawing borders, aligned columns) with + * ~50 lines of Ink primitives, lets us color the Status column per-row, and + * leaves nothing to maintain outside this repo. + */ +export interface SecretRow { + name: string + status: 'NEW' | 'REPLACE' +} + +/** + * Diff viewer building blocks for the workflow-file preview flow. + * + * When the proposed content is byte-identical to what's on disk we skip the + * line-by-line dump entirely and show a short "matches — no diff" banner — + * dumping 70 lines of `[eq]` content would only add noise. + */ +export interface DiffViewerProps { + title: string + subtitle?: string + lines: DiffLine[] +} + +function getDiffCounts(lines: DiffLine[]): { addCount: number, delCount: number, total: number } { + return { + addCount: lines.filter(l => l.kind === 'add').length, + delCount: lines.filter(l => l.kind === 'del').length, + total: lines.length, + } +} + +export const DiffSummary: FC<{ title: string, subtitle?: string, lines: DiffLine[] }> = ({ title, subtitle, lines }) => { + const { addCount, delCount, total } = getDiffCounts(lines) + const allEqual = total > 0 && lines.every(l => l.kind === 'eq') + + return ( + + {title} + {subtitle && {subtitle}} + {'─'.repeat(60)} + {allEqual + ? ( + + ✓ File on disk already matches the proposed content — + {' '} + {total} + {' '} + identical line + {total === 1 ? '' : 's'} + , no diff to show. + + ) + : ( + + {'Summary: '} + {`+${addCount} added`} + {' '} + {`-${delCount} removed`} + {' '} + {`${total} line${total === 1 ? '' : 's'} total`} + + )} + + ) +} + +export const FullscreenDiffViewer: FC<{ + title: string + subtitle?: string + lines: DiffLine[] + terminalRows: number + onExit: () => void +}> = ({ title, subtitle, lines, terminalRows, onExit }) => { + const viewportRows = Math.max(1, Math.min(lines.length || 1, terminalRows - 12)) + const [scrollOffset, setScrollOffset] = useState(0) + const { addCount, delCount, total } = getDiffCounts(lines) + const maxScrollOffset = Math.max(0, lines.length - viewportRows) + + useEffect(() => { + setScrollOffset(prev => Math.min(prev, maxScrollOffset)) + }, [maxScrollOffset]) + + useInput((input, key) => { + if (key.escape) { + 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') { + setScrollOffset(prev => Math.min(prev + viewportRows, maxScrollOffset)) + return + } + if (key.pageUp || input === 'u') { + setScrollOffset(prev => Math.max(prev - viewportRows, 0)) + } + }) + + const visibleLines = lines.slice(scrollOffset, scrollOffset + viewportRows) + const firstVisibleLine = total === 0 ? 0 : scrollOffset + 1 + const lastVisibleLine = Math.min(total, scrollOffset + visibleLines.length) + const lineNumberWidth = String(Math.max(total, 1)).length + + return ( + + {title} + {subtitle && {subtitle}} + {'─'.repeat(60)} + + {'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') { + return ( + + {`${lineNumber} - `} + {line.text} + + ) + } + return ( + + {`${lineNumber} `} + {line.text} + + ) + })} + {'─'.repeat(60)} + + {`Showing ${firstVisibleLine}-${lastVisibleLine} of ${total} lines. Use ↑/↓ or k/j to scroll.`} + + Click Escape to exit diff viewer + + ) +} + +export const DiffViewer: FC = ({ title, subtitle, lines }) => { + const total = lines.length + const allEqual = total > 0 && lines.every(l => l.kind === 'eq') + const addCount = lines.filter(l => l.kind === 'add').length + const delCount = lines.filter(l => l.kind === 'del').length + + // When the proposed file matches disk byte-for-byte, don't bother streaming + // every line — render a compact dynamic banner instead. + if (allEqual) { + return ( + + {title} + {subtitle && {subtitle}} + + + ✓ File on disk already matches the proposed content — + {' '} + {total} + {' '} + identical line + {total === 1 ? '' : 's'} + , no diff to show. + + + + ) + } + + return ( + + {title} + {subtitle && {subtitle}} + {'─'.repeat(60)} + + {'Summary: '} + {`+${addCount} added`} + {' '} + {`-${delCount} removed`} + + {lines.map((line, index) => { + const lineNumber = String(index + 1).padStart(4, ' ') + if (line.kind === 'add') { + return ( + + {`${lineNumber} + `} + {line.text} + + ) + } + if (line.kind === 'del') { + return ( + + {`${lineNumber} - `} + {line.text} + + ) + } + return ( + + {`${lineNumber} `} + {line.text} + + ) + })} + {'─'.repeat(60)} + {`End of proposed diff (${total} line${total === 1 ? '' : 's'} total). Scroll your terminal up to review.`} + + ) +} + +/** + * Render the secrets table inline. Keep this dynamic so the onboarding header + * and prompt stay in one live Ink frame. + */ +export const SecretsTable: FC<{ rows: SecretRow[] }> = ({ rows }) => { + const nameHeader = 'Secret name' + const statusHeader = 'Status' + const nameWidth = Math.max(nameHeader.length, ...rows.map(r => r.name.length)) + const statusWidth = Math.max(statusHeader.length, ...rows.map(r => r.status.length)) + + const top = `┌─${'─'.repeat(nameWidth)}─┬─${'─'.repeat(statusWidth)}─┐` + const sep = `├─${'─'.repeat(nameWidth)}─┼─${'─'.repeat(statusWidth)}─┤` + const bot = `└─${'─'.repeat(nameWidth)}─┴─${'─'.repeat(statusWidth)}─┘` + + return ( + + {top} + + + {nameHeader.padEnd(nameWidth, ' ')} + + {statusHeader.padEnd(statusWidth, ' ')} + + + {sep} + {rows.map(row => ( + + + {row.name.padEnd(nameWidth, ' ')} + + + {row.status.padEnd(statusWidth, ' ')} + + + + ))} + {bot} + + ) +} diff --git a/cli/test/test-ci-secrets.mjs b/cli/test/test-ci-secrets.mjs index 69ddab7c0e..f6595cb7eb 100644 --- a/cli/test/test-ci-secrets.mjs +++ b/cli/test/test-ci-secrets.mjs @@ -3,6 +3,7 @@ import { createCiSecretEntries, detectCiSecretTargets, + getCiSecretRepoLabel, listExistingCiSecretKeys, uploadCiSecrets, } from '../src/build/onboarding/ci-secrets.ts' @@ -243,6 +244,47 @@ await test('uploads GitLab variables using set/update and masks only secret keys ]) }) +const GITHUB_TARGET = { provider: 'github', label: 'GitHub Actions repository secrets', cli: 'gh' } +const GITLAB_TARGET = { provider: 'gitlab', label: 'GitLab CI/CD variables', cli: 'glab' } + +await test('getCiSecretRepoLabel returns the gh-resolved nameWithOwner for GitHub', () => { + const runner = createRunner({ + 'gh repo view --json nameWithOwner -q .nameWithOwner': { status: 0, stdout: 'Cap-go/capgo\n', stderr: '' }, + }) + assertEquals(getCiSecretRepoLabel(GITHUB_TARGET, runner), 'Cap-go/capgo') +}) + +await test('getCiSecretRepoLabel returns null when gh repo view fails', () => { + // No matching handler → createRunner returns status: 1 by default + const runner = createRunner({}) + assertEquals(getCiSecretRepoLabel(GITHUB_TARGET, runner), null) +}) + +await test('getCiSecretRepoLabel trims trailing whitespace from gh output', () => { + const runner = createRunner({ + 'gh repo view --json nameWithOwner -q .nameWithOwner': { status: 0, stdout: ' owner/repo \n', stderr: '' }, + }) + assertEquals(getCiSecretRepoLabel(GITHUB_TARGET, runner), 'owner/repo') +}) + +await test('getCiSecretRepoLabel parses path_with_namespace from glab JSON output', () => { + const runner = createRunner({ + 'glab repo view -F json': { + status: 0, + stdout: JSON.stringify({ path_with_namespace: 'group/sub/project', name: 'project' }), + stderr: '', + }, + }) + assertEquals(getCiSecretRepoLabel(GITLAB_TARGET, runner), 'group/sub/project') +}) + +await test('getCiSecretRepoLabel returns null on glab JSON parse failure', () => { + const runner = createRunner({ + 'glab repo view -F json': { status: 0, stdout: 'not-valid-json', stderr: '' }, + }) + assertEquals(getCiSecretRepoLabel(GITLAB_TARGET, runner), null) +}) + if (testsFailed > 0) { console.error(`\n❌ ${testsFailed} CI secret helper test(s) failed`) process.exit(1) diff --git a/cli/test/test-diff-utils.mjs b/cli/test/test-diff-utils.mjs new file mode 100644 index 0000000000..fbe83af06a --- /dev/null +++ b/cli/test/test-diff-utils.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node + +import { diffLines } from '../src/build/onboarding/diff-utils.ts' + +console.log('🧪 Testing diff-utils...\n') + +let testsPassed = 0 +let testsFailed = 0 + +async function test(name, fn) { + try { + console.log(`\n🔍 ${name}`) + await fn() + console.log(`✅ PASSED: ${name}`) + testsPassed++ + } + catch (error) { + console.error(`❌ FAILED: ${name}`) + console.error(` Error: ${error.message}`) + testsFailed++ + } +} + +function assert(condition, message) { + if (!condition) + throw new Error(message || 'Assertion failed') +} + +function assertDeepEquals(actual, expected, message) { + const a = JSON.stringify(actual) + const e = JSON.stringify(expected) + if (a !== e) + throw new Error(message || `Expected ${e}, got ${a}`) +} + +await test('new file (empty before) → every line is an addition', () => { + const result = diffLines('', 'name: Capgo\non: workflow_dispatch') + assertDeepEquals(result, [ + { kind: 'add', text: 'name: Capgo' }, + { kind: 'add', text: 'on: workflow_dispatch' }, + ]) +}) + +await test('removed file (empty after) → every line is a deletion', () => { + const result = diffLines('foo\nbar', '') + assertDeepEquals(result, [ + { kind: 'del', text: 'foo' }, + { kind: 'del', text: 'bar' }, + ]) +}) + +await test('identical files → all lines marked eq', () => { + const result = diffLines('one\ntwo\nthree', 'one\ntwo\nthree') + assertDeepEquals(result, [ + { kind: 'eq', text: 'one' }, + { kind: 'eq', text: 'two' }, + { kind: 'eq', text: 'three' }, + ]) +}) + +await test('single-line replacement → del + add', () => { + const result = diffLines('hello\nworld', 'hello\nthere') + assertDeepEquals(result, [ + { kind: 'eq', text: 'hello' }, + { kind: 'del', text: 'world' }, + { kind: 'add', text: 'there' }, + ]) +}) + +await test('insertion in the middle → context preserved', () => { + const result = diffLines('a\nb\nc', 'a\nNEW\nb\nc') + assertDeepEquals(result, [ + { kind: 'eq', text: 'a' }, + { kind: 'add', text: 'NEW' }, + { kind: 'eq', text: 'b' }, + { kind: 'eq', text: 'c' }, + ]) +}) + +await test('deletion at the end', () => { + const result = diffLines('a\nb\nc', 'a\nb') + assertDeepEquals(result, [ + { kind: 'eq', text: 'a' }, + { kind: 'eq', text: 'b' }, + { kind: 'del', text: 'c' }, + ]) +}) + +await test('empty strings (both sides) → empty result', () => { + const result = diffLines('', '') + assertDeepEquals(result, []) +}) + +await test('preserves trailing-newline split semantics', () => { + // 'a\n' splits to ['a', ''] — both pieces flow through unchanged. + const result = diffLines('a\n', 'a\n') + assertDeepEquals(result, [ + { kind: 'eq', text: 'a' }, + { kind: 'eq', text: '' }, + ]) +}) + +if (testsFailed > 0) { + console.error(`\n❌ ${testsFailed} test(s) failed`) + process.exit(1) +} +console.log(`\n✅ diff-utils tests passed (${testsPassed})`) From a2749273facfb16a32991d1b4f74d3b54e263f8c Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Fri, 22 May 2026 10:35:56 +0200 Subject: [PATCH 3/9] fix(cli): support yarn classic workflow generation --- cli/src/build/credentials-manage.ts | 56 +---------------- cli/src/build/env-render.ts | 51 +++++++++++++++ cli/src/build/onboarding/android/ui/app.tsx | 62 +++---------------- cli/src/build/onboarding/ci-secrets.ts | 5 +- cli/src/build/onboarding/env-export.ts | 2 +- cli/src/build/onboarding/ui/app.tsx | 56 +---------------- .../build/onboarding/workflow-generator.ts | 2 +- .../build/onboarding/workflow-ui-helpers.ts | 42 +++++++++++++ cli/test/test-ci-secrets.mjs | 8 +++ cli/test/test-workflow-generator.mjs | 3 +- 10 files changed, 119 insertions(+), 168 deletions(-) create mode 100644 cli/src/build/env-render.ts create mode 100644 cli/src/build/onboarding/workflow-ui-helpers.ts diff --git a/cli/src/build/credentials-manage.ts b/cli/src/build/credentials-manage.ts index 984f2fc2d2..8c57c4078a 100644 --- a/cli/src/build/credentials-manage.ts +++ b/cli/src/build/credentials-manage.ts @@ -27,6 +27,7 @@ import { removeSavedCredentialKeys, updateSavedCredentials, } from './credentials' +import { escapeDotenvValue, renderEnvFile } from './env-render' import { onboardingBuilderCommand } from './onboarding/command' import { canUseFilePicker, openSaveFilePicker } from './onboarding/file-picker' @@ -1392,49 +1393,6 @@ async function resolveExportTarget(entry: AppEntry, label: 'ios' | 'android' | ' return { path: resolved } } -/** - * Render a single-platform .env file from credentials. Exported so the build - * onboarding wizard can produce the same format on the "export-instead-of-CI" - * branch without duplicating the section headers, escaping rules, and - * provisioning-map base64 trick. - */ -export function renderEnvFile(args: { appId: string, local: boolean, platform: 'ios' | 'android', creds: Partial }): string { - const { appId, local, platform, creds } = args - const lines: string[] = [] - const generated = new Date().toISOString() - lines.push('# Capgo build credentials — CI/CD environment file') - lines.push(`# App: ${appId}`) - lines.push(`# Platform: ${platform}`) - lines.push(`# Source: ${local ? 'local' : 'global'} credentials store`) - lines.push(`# Generated: ${generated}`) - lines.push('#') - lines.push('# Paste these into your CI/CD provider as secrets, or source the file locally:') - lines.push('# set -a; . ./this-file; set +a') - lines.push('#') - lines.push('# DO NOT commit this file. Add to .gitignore: .env.capgo.*') - lines.push('') - - const provisioningMapRaw = creds.CAPGO_IOS_PROVISIONING_MAP - for (const [key, value] of Object.entries(creds)) { - if (typeof value !== 'string' || value.length === 0) - continue - if (key === 'CAPGO_IOS_PROVISIONING_MAP') - continue - lines.push(`${key}=${escapeDotenvValue(value)}`) - } - - if (provisioningMapRaw) { - const base64 = Buffer.from(provisioningMapRaw, 'utf-8').toString('base64') - lines.push('') - lines.push('# Provisioning map — base64 form is preferred to avoid newline/quoting issues in CI.') - lines.push(`CAPGO_IOS_PROVISIONING_MAP_BASE64=${base64}`) - lines.push(`# CAPGO_IOS_PROVISIONING_MAP=${escapeDotenvValue(provisioningMapRaw)}`) - } - - lines.push('') - return lines.join('\n') -} - function renderEnvFileCombined( entry: AppEntry, sections: Array<{ platform: 'ios' | 'android', creds: Partial }>, @@ -1486,18 +1444,6 @@ function renderEnvFileCombined( return lines.join('\n') } -function escapeDotenvValue(value: string): string { - if (/^[\w./+=:-]+$/.test(value)) - return value - const escaped = value - .replaceAll('\\', '\\\\') - .replaceAll('"', '\\"') - .replaceAll('$', '\\$') - .replaceAll('`', '\\`') - .replaceAll('\n', '\\n') - return `"${escaped}"` -} - async function deletePlatformInteractive(entry: AppEntry): Promise { if (entry.platforms.length === 0) { pLog.warn('Nothing to delete — no platforms configured.') diff --git a/cli/src/build/env-render.ts b/cli/src/build/env-render.ts new file mode 100644 index 0000000000..05cfdb5337 --- /dev/null +++ b/cli/src/build/env-render.ts @@ -0,0 +1,51 @@ +import type { BuildCredentials } from '../schemas/build' +import { Buffer } from 'node:buffer' + +export function renderEnvFile(args: { appId: string, local: boolean, platform: 'ios' | 'android', creds: Partial }): string { + const { appId, local, platform, creds } = args + const lines: string[] = [] + const generated = new Date().toISOString() + lines.push('# Capgo build credentials — CI/CD environment file') + lines.push(`# App: ${appId}`) + lines.push(`# Platform: ${platform}`) + lines.push(`# Source: ${local ? 'local' : 'global'} credentials store`) + lines.push(`# Generated: ${generated}`) + lines.push('#') + lines.push('# Paste these into your CI/CD provider as secrets, or source the file locally:') + lines.push('# set -a; . ./this-file; set +a') + lines.push('#') + lines.push('# DO NOT commit this file. Add to .gitignore: .env.capgo.*') + lines.push('') + + const provisioningMapRaw = creds.CAPGO_IOS_PROVISIONING_MAP + for (const [key, value] of Object.entries(creds)) { + if (typeof value !== 'string' || value.length === 0) + continue + if (key === 'CAPGO_IOS_PROVISIONING_MAP') + continue + lines.push(`${key}=${escapeDotenvValue(value)}`) + } + + if (provisioningMapRaw) { + const base64 = Buffer.from(provisioningMapRaw, 'utf-8').toString('base64') + lines.push('') + lines.push('# Provisioning map — base64 form is preferred to avoid newline/quoting issues in CI.') + lines.push(`CAPGO_IOS_PROVISIONING_MAP_BASE64=${base64}`) + lines.push(`# CAPGO_IOS_PROVISIONING_MAP=${escapeDotenvValue(provisioningMapRaw)}`) + } + + lines.push('') + return lines.join('\n') +} + +export function escapeDotenvValue(value: string): string { + if (/^[\w./+=:-]+$/.test(value)) + return value + const escaped = value + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"') + .replaceAll('$', '\\$') + .replaceAll('`', '\\`') + .replaceAll('\n', '\\n') + return `"${escaped}"` +} diff --git a/cli/src/build/onboarding/android/ui/app.tsx b/cli/src/build/onboarding/android/ui/app.tsx index a59d13c0d5..7ccf92c3cc 100644 --- a/cli/src/build/onboarding/android/ui/app.tsx +++ b/cli/src/build/onboarding/android/ui/app.tsx @@ -22,7 +22,7 @@ import { Alert, ProgressBar, Select } from '@inkjs/ui' import { Box, Newline, Text, useApp, useInput } from 'ink' // src/build/onboarding/android/ui/app.tsx import React, { useCallback, useEffect, useRef, useState } from 'react' -import { findSavedKey } from '../../../../utils.js' +import { findBuildCommandForProjectType, findProjectType, findSavedKeySilent, getPackageScripts, getPMAndCommand } from '../../../../utils.js' import { loadSavedCredentials, updateSavedCredentials } from '../../../credentials.js' import { requestBuildInternal } from '../../../request.js' import { createCiSecretEntries, detectCiSecretTargets, getCiSecretRepoLabelAsync, getCiSecretTargetLabel, listExistingCiSecretKeysAsync, uploadCiSecretsAsync } from '../../ci-secrets.js' @@ -30,7 +30,6 @@ import type { CiSecretEntry, CiSecretSetupAdvice, CiSecretTarget } from '../../c import { defaultExportPath, exportCredentialsToEnv } from '../../env-export.js' import { writeWorkflowFile, WORKFLOW_PATH } from '../../workflow-writer.js' import type { BuildScriptChoice, PackageManager } from '../../workflow-generator.js' -import { findBuildCommandForProjectType, findProjectType, getPackageScripts, getPMAndCommand } from '../../../../utils.js' import type { BuildCredentials } from '../../../../schemas/build.js' import { canUseFilePicker, openKeystorePicker } from '../../file-picker.js' import { findAndroidApplicationIds } from '../gradle-parser.js' @@ -40,6 +39,7 @@ import type { DiffLine } from '../../diff-utils.js' import { generateWorkflow, WORKFLOW_PATH as WORKFLOW_GEN_PATH } from '../../workflow-generator.js' import { getWorkflowDiffTelemetry, trackBuildOnboardingWorkflowEvent } from '../../analytics.js' import type { BuildOnboardingWorkflowDecision, BuildOnboardingWorkflowEvent, WorkflowDiffTelemetry } from '../../analytics.js' +import { buildScriptPickerOptions, normalizePackageManager } from '../../workflow-ui-helpers.js' import { ANDROIDPUBLISHER_API, createServiceAccountKey, @@ -123,48 +123,6 @@ function emptyProgress(appId: string): AndroidOnboardingProgress { } } -/** - * `getPMAndCommand()` returns 'unknown' when no recognizable lockfile is - * present. The workflow generator only knows the four real ones — fall back - * to 'npm' for the generator template. - */ -function normalizePackageManager(pm: string): PackageManager { - if (pm === 'bun' || pm === 'npm' || pm === 'pnpm' || pm === 'yarn') - return pm - return 'npm' -} - - -interface BuildScriptOption { - label: string - value: string -} - -/** - * Build the picker options for `pick-build-script`. Shows ALL scripts from - * package.json (the user picks; we don't auto-guess), with the project-type - * recommendation surfaced at the top, plus escape hatches for custom commands - * and "skip build entirely" (raw HTML Capacitor apps). - */ -function buildScriptPickerOptions(scripts: Record, recommended: string | null): BuildScriptOption[] { - const options: BuildScriptOption[] = [] - const seen = new Set() - - if (recommended && Object.prototype.hasOwnProperty.call(scripts, recommended)) { - options.push({ label: `${recommended} (recommended — matches your project type)`, value: recommended }) - seen.add(recommended) - } - - const others = Object.keys(scripts).filter(name => !seen.has(name)).sort((a, b) => a.localeCompare(b)) - for (const name of others) - options.push({ label: name, value: name }) - - options.push({ label: 'Type a custom command…', value: '__custom__' }) - options.push({ label: 'Skip build step (my app is raw HTML)', value: '__skip__' }) - - return options -} - const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir, apikey, terminalRows = 24 }) => { const { exit } = useApp() const startStep: AndroidOnboardingStep = getAndroidResumeStep(initialProgress) @@ -1083,10 +1041,8 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir // --apikey, and users who pick "secrets only" still benefit from // having it ready in their repo for a workflow they'll write later. let capgoKey: string | undefined = apikey - if (!capgoKey) { - try { capgoKey = findSavedKey(true) } - catch {} - } + if (!capgoKey) + capgoKey = findSavedKeySilent() const entries = createCiSecretEntries(credentials, capgoKey) setCiSecretEntries(entries) // Stash the raw credentials so the .env-export branch can write the @@ -1207,7 +1163,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const projectType = await findProjectType({ quiet: true }).catch(() => null) if (projectType) { const recommended = await findBuildCommandForProjectType(projectType).catch(() => null) - if (recommended && Object.prototype.hasOwnProperty.call(scripts, recommended)) + if (recommended && Object.hasOwn(scripts, recommended)) setRecommendedScript(recommended) } } @@ -1414,12 +1370,8 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir // `build init --platform android --apikey FOO` silently ignored FOO // and fell back to whichever key was on disk. let capgoKey: string | undefined = apikey - if (!capgoKey) { - try { - capgoKey = findSavedKey(true) - } - catch {} - } + if (!capgoKey) + capgoKey = findSavedKeySilent() if (!capgoKey) { setBuildOutput(prev => [...prev, '⚠ No Capgo API key found.']) setBuildOutput(prev => [...prev, 'Run `capgo login` first, then `capgo build request --platform android`.']) diff --git a/cli/src/build/onboarding/ci-secrets.ts b/cli/src/build/onboarding/ci-secrets.ts index 148dba0811..19cb847186 100644 --- a/cli/src/build/onboarding/ci-secrets.ts +++ b/cli/src/build/onboarding/ci-secrets.ts @@ -183,10 +183,11 @@ export function createCiSecretEntries( // generated GitHub Actions workflow (and any user-authored workflow that // follows the same convention) can authenticate without the user having to // manually `gh secret set CAPGO_TOKEN` after the wizard finishes. - if (apiKey && apiKey.length > 0) { + const trimmedApiKey = apiKey?.trim() + if (trimmedApiKey) { entries.push({ key: 'CAPGO_TOKEN', - value: apiKey, + value: trimmedApiKey, masked: true, }) } diff --git a/cli/src/build/onboarding/env-export.ts b/cli/src/build/onboarding/env-export.ts index 3cf1ce4b28..8cff7bec45 100644 --- a/cli/src/build/onboarding/env-export.ts +++ b/cli/src/build/onboarding/env-export.ts @@ -12,7 +12,7 @@ import type { BuildCredentials } from '../../schemas/build.js' import { chmodSync, existsSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { cwd } from 'node:process' -import { renderEnvFile } from '../credentials-manage.js' +import { renderEnvFile } from '../env-render.js' export interface EnvExportOpts { appId: string diff --git a/cli/src/build/onboarding/ui/app.tsx b/cli/src/build/onboarding/ui/app.tsx index 5694a662b5..de56ec5e80 100644 --- a/cli/src/build/onboarding/ui/app.tsx +++ b/cli/src/build/onboarding/ui/app.tsx @@ -17,7 +17,7 @@ import open from 'open' import React, { useCallback, useEffect, useRef, useState } from 'react' import { writeOnboardingSupportBundle } from '../../../onboarding-support.js' import { formatRunnerCommand, splitRunnerCommand } from '../../../runner-command.js' -import { findSavedKeySilent, getPMAndCommand } from '../../../utils.js' +import { findBuildCommandForProjectType, findProjectType, findSavedKeySilent, getPackageScripts, getPMAndCommand } from '../../../utils.js' import { loadSavedCredentials, updateSavedCredentials } from '../../credentials.js' import { requestBuildInternal } from '../../request.js' import { CertificateLimitError, createCertificate, createProfile, deleteProfile, DuplicateProfileError, ensureBundleId, findCertIdBySha1, generateJwt, listProfilesForCert, revokeCertificate, verifyApiKey } from '../apple-api.js' @@ -31,7 +31,6 @@ import type { CiSecretEntry, CiSecretSetupAdvice, CiSecretTarget } from '../ci-s import { defaultExportPath, exportCredentialsToEnv } from '../env-export.js' import { writeWorkflowFile, WORKFLOW_PATH } from '../workflow-writer.js' import type { BuildScriptChoice, PackageManager } from '../workflow-generator.js' -import { findBuildCommandForProjectType, findProjectType, getPackageScripts } from '../../../utils.js' import type { BuildCredentials } from '../../../schemas/build.js' import { getPhaseLabel, @@ -44,6 +43,7 @@ import type { DiffLine } from '../diff-utils.js' import { generateWorkflow, WORKFLOW_PATH as WORKFLOW_GEN_PATH } from '../workflow-generator.js' import { getWorkflowDiffTelemetry, trackBuildOnboardingWorkflowEvent } from '../analytics.js' import type { BuildOnboardingWorkflowDecision, BuildOnboardingWorkflowEvent, WorkflowDiffTelemetry } from '../analytics.js' +import { buildScriptPickerOptions, normalizePackageManager } from '../workflow-ui-helpers.js' const OUTPUT_LINE_SPLIT_RE = /\r?\n/ const CARRIAGE_RETURN_RE = /\r/g @@ -97,54 +97,6 @@ async function runRunnerCommand(runner: string, args: string[]): Promise<{ succe }) } -/** - * `getPMAndCommand()` returns the literal string 'unknown' when no recognizable - * lockfile is present. The workflow generator only knows the four real ones — - * fall back to 'npm' for the generator template (universal coverage, even if - * the user is using something exotic; they can edit the YAML after). - */ -function normalizePackageManager(pm: string): PackageManager { - if (pm === 'bun' || pm === 'npm' || pm === 'pnpm' || pm === 'yarn') - return pm - return 'npm' -} - - -interface BuildScriptOption { - label: string - value: string -} - -/** - * Build the picker options for `pick-build-script`. Layout: - * 1. Recommended script (if any) at the top, with a hint label - * 2. Every other script in scripts{} in alphabetical order - * 3. "Type a custom command" escape hatch - * 4. "Skip build step" escape hatch (for raw HTML Capacitor apps) - * - * Showing ALL scripts (not just "build"-ish ones) matches what the user - * asked for: pick from package.json, never auto-guess. Filtering risks - * hiding the exotic name a user actually wants. - */ -function buildScriptPickerOptions(scripts: Record, recommended: string | null): BuildScriptOption[] { - const options: BuildScriptOption[] = [] - const seen = new Set() - - if (recommended && Object.prototype.hasOwnProperty.call(scripts, recommended)) { - options.push({ label: `${recommended} (recommended — matches your project type)`, value: recommended }) - seen.add(recommended) - } - - const others = Object.keys(scripts).filter(name => !seen.has(name)).sort((a, b) => a.localeCompare(b)) - for (const name of others) - options.push({ label: name, value: name }) - - options.push({ label: 'Type a custom command…', value: '__custom__' }) - options.push({ label: 'Skip build step (my app is raw HTML)', value: '__skip__' }) - - return options -} - const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, terminalRows = 24 }) => { const { exit } = useApp() const startStep = getResumeStep(initialProgress) @@ -233,7 +185,6 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, t const [availableScripts, setAvailableScripts] = useState>({}) const [recommendedScript, setRecommendedScript] = useState(null) const [buildScriptChoice, setBuildScriptChoice] = useState(null) - const [pendingCustomCommand, setPendingCustomCommand] = useState('') const [workflowExistingContent, setWorkflowExistingContent] = useState(null) const [workflowProposedContent, setWorkflowProposedContent] = useState(null) const [workflowWrittenPath, setWorkflowWrittenPath] = useState(null) @@ -1241,7 +1192,7 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, t const projectType = await findProjectType({ quiet: true }).catch(() => null) if (projectType) { const recommended = await findBuildCommandForProjectType(projectType).catch(() => null) - if (recommended && Object.prototype.hasOwnProperty.call(scripts, recommended)) + if (recommended && Object.hasOwn(scripts, recommended)) setRecommendedScript(recommended) } } @@ -2791,7 +2742,6 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, t if (!cleaned) return setBuildScriptChoice({ type: 'custom', command: cleaned }) - setPendingCustomCommand(cleaned) setStep('preview-workflow-file') }} /> diff --git a/cli/src/build/onboarding/workflow-generator.ts b/cli/src/build/onboarding/workflow-generator.ts index 13f7bec879..7ff00e4208 100644 --- a/cli/src/build/onboarding/workflow-generator.ts +++ b/cli/src/build/onboarding/workflow-generator.ts @@ -229,6 +229,6 @@ function runnerCommand(pm: PackageManager): string { case 'pnpm': return 'pnpm dlx' case 'yarn': - return 'yarn dlx' + return 'npx' } } diff --git a/cli/src/build/onboarding/workflow-ui-helpers.ts b/cli/src/build/onboarding/workflow-ui-helpers.ts new file mode 100644 index 0000000000..b9d9ce83e6 --- /dev/null +++ b/cli/src/build/onboarding/workflow-ui-helpers.ts @@ -0,0 +1,42 @@ +import type { PackageManager } from './workflow-generator.js' + +export interface BuildScriptOption { + label: string + value: string +} + +/** + * `getPMAndCommand()` returns the literal string 'unknown' when no recognizable + * lockfile is present. The workflow generator only knows the four real ones — + * fall back to 'npm' for the generator template. + */ +export function normalizePackageManager(pm: string): PackageManager { + if (pm === 'bun' || pm === 'npm' || pm === 'pnpm' || pm === 'yarn') + return pm + return 'npm' +} + +/** + * Build the picker options for `pick-build-script`. Shows ALL scripts from + * package.json (the user picks; we don't auto-guess), with the project-type + * recommendation surfaced at the top, plus escape hatches for custom commands + * and "skip build entirely" (raw HTML Capacitor apps). + */ +export function buildScriptPickerOptions(scripts: Record, recommended: string | null): BuildScriptOption[] { + const options: BuildScriptOption[] = [] + const seen = new Set() + + if (recommended && Object.hasOwn(scripts, recommended)) { + options.push({ label: `${recommended} (recommended — matches your project type)`, value: recommended }) + seen.add(recommended) + } + + const others = Object.keys(scripts).filter(name => !seen.has(name)).sort((a, b) => a.localeCompare(b)) + for (const name of others) + options.push({ label: name, value: name }) + + options.push({ label: 'Type a custom command…', value: '__custom__' }) + options.push({ label: 'Skip build step (my app is raw HTML)', value: '__skip__' }) + + return options +} diff --git a/cli/test/test-ci-secrets.mjs b/cli/test/test-ci-secrets.mjs index f6595cb7eb..c14d1c13ab 100644 --- a/cli/test/test-ci-secrets.mjs +++ b/cli/test/test-ci-secrets.mjs @@ -105,6 +105,14 @@ await test('treats an empty-string API key as "no token" (no entry)', () => { assert(!entries.some(e => e.key === 'CAPGO_TOKEN'), 'Empty API key must not produce a CAPGO_TOKEN entry') }) +await test('trims the API key before creating CAPGO_TOKEN', () => { + const entries = createCiSecretEntries({ BUILD_CERTIFICATE_BASE64: 'cert' }, ' capgo_token_value ') + const tokenEntry = entries.find(entry => entry.key === 'CAPGO_TOKEN') + assert(tokenEntry !== undefined, 'Trimmed API key should produce a CAPGO_TOKEN entry') + assertEquals(tokenEntry.value, 'capgo_token_value') + assert(tokenEntry.masked, 'CAPGO_TOKEN must stay masked') +}) + await test('detects authenticated GitHub target from git remotes', () => { const runner = createRunner({ 'git remote -v': { diff --git a/cli/test/test-workflow-generator.mjs b/cli/test/test-workflow-generator.mjs index 98c4bbed7a..6ef996e04e 100644 --- a/cli/test/test-workflow-generator.mjs +++ b/cli/test/test-workflow-generator.mjs @@ -115,7 +115,8 @@ await test('yarn template uses yarn (not yarn run) for npm-script', () => { assertIncludes(content, 'yarn install --frozen-lockfile') // yarn classic invokes scripts without `run` assertIncludes(content, '\n run: yarn build\n') - assertIncludes(content, 'yarn dlx @capgo/cli@latest build request') + assertIncludes(content, 'npx @capgo/cli@latest build request') + assertIncludes(content, 'URL=$(npx @capgo/cli@latest build last-output') }) await test('custom build command is rendered verbatim', () => { From 0d99cdaefb7436d1b071b71ab9ee4b965c941c10 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Fri, 22 May 2026 12:57:53 +0200 Subject: [PATCH 4/9] fix(cli): require resolved github repo for secret push --- cli/src/build/onboarding/android/ui/app.tsx | 15 ++++++++------- cli/src/build/onboarding/ui/app.tsx | 15 ++++++++------- cli/src/build/onboarding/ui/components.tsx | 2 +- cli/test/test-diff-utils.mjs | 6 +----- cli/test/test-workflow-generator.mjs | 6 +----- 5 files changed, 19 insertions(+), 25 deletions(-) diff --git a/cli/src/build/onboarding/android/ui/app.tsx b/cli/src/build/onboarding/android/ui/app.tsx index 7ccf92c3cc..269168a1dc 100644 --- a/cli/src/build/onboarding/android/ui/app.tsx +++ b/cli/src/build/onboarding/android/ui/app.tsx @@ -1108,6 +1108,12 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir repoLabel = await getCiSecretRepoLabelAsync(ciSecretTarget) if (cancelled) return + if (!repoLabel) { + setCiSecretRepoLabel(null) + setCiSecretError('Could not resolve the GitHub repository. Run `gh repo view` from this directory, then try again.') + setStep('ci-secrets-failed') + return + } setCiSecretRepoLabel(repoLabel) } // Phase 2: list existing secrets. @@ -2612,9 +2618,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const existingSet = new Set(ciSecretExistingKeys) const newCount = ciSecretEntries.filter(entry => !existingSet.has(entry.key)).length const replaceCount = ciSecretEntries.length - newCount - const repoLine = ciSecretRepoLabel - ? ciSecretRepoLabel - : '(could not resolve repository — gh repo view failed)' return ( ⚠ Confirm before pushing secrets @@ -2622,7 +2625,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir Repository: {' '} - {repoLine} + {ciSecretRepoLabel} (resolved via `gh repo view` from this directory) @@ -2653,9 +2656,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir = ({ width = 60 }) => ( {'─'.repeat(width)} diff --git a/cli/test/test-diff-utils.mjs b/cli/test/test-diff-utils.mjs index fbe83af06a..4e8298d42a 100644 --- a/cli/test/test-diff-utils.mjs +++ b/cli/test/test-diff-utils.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node +import process from 'node:process' import { diffLines } from '../src/build/onboarding/diff-utils.ts' console.log('🧪 Testing diff-utils...\n') @@ -21,11 +22,6 @@ async function test(name, fn) { } } -function assert(condition, message) { - if (!condition) - throw new Error(message || 'Assertion failed') -} - function assertDeepEquals(actual, expected, message) { const a = JSON.stringify(actual) const e = JSON.stringify(expected) diff --git a/cli/test/test-workflow-generator.mjs b/cli/test/test-workflow-generator.mjs index 6ef996e04e..7c41950268 100644 --- a/cli/test/test-workflow-generator.mjs +++ b/cli/test/test-workflow-generator.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node +import process from 'node:process' import { generateWorkflow, WORKFLOW_PATH } from '../src/build/onboarding/workflow-generator.ts' console.log('🧪 Testing GitHub Actions workflow generator...\n') @@ -21,11 +22,6 @@ async function test(name, fn) { } } -function assert(condition, message) { - if (!condition) - throw new Error(message || 'Assertion failed') -} - function assertEquals(actual, expected, message) { if (actual !== expected) throw new Error(message || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`) From 45fc5a618f4d67b7938a45bdf1312dda2a9e78e6 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Fri, 22 May 2026 13:38:09 +0200 Subject: [PATCH 5/9] fix(cli): remove stale workflow overwrite path --- cli/src/build/onboarding/android/types.ts | 6 -- cli/src/build/onboarding/android/ui/app.tsx | 78 --------------------- cli/src/build/onboarding/types.ts | 6 -- cli/src/build/onboarding/ui/app.tsx | 78 --------------------- cli/src/build/onboarding/ui/components.tsx | 2 +- cli/test/fixtures/setup-test-projects.sh | 9 ++- 6 files changed, 8 insertions(+), 171 deletions(-) diff --git a/cli/src/build/onboarding/android/types.ts b/cli/src/build/onboarding/android/types.ts index aec7397ab5..3ae0d7d902 100644 --- a/cli/src/build/onboarding/android/types.ts +++ b/cli/src/build/onboarding/android/types.ts @@ -66,8 +66,6 @@ export type AndroidOnboardingStep | 'preview-workflow-file' | 'view-workflow-diff' | 'writing-workflow-file' - | 'confirm-workflow-overwrite' - | 'overwrite-and-write-workflow' | 'ask-build' | 'requesting-build' | 'build-complete' @@ -254,8 +252,6 @@ export const ANDROID_STEP_PROGRESS: Record = { 'preview-workflow-file': 97, 'view-workflow-diff': 97, 'writing-workflow-file': 98, - 'confirm-workflow-overwrite': 97, - 'overwrite-and-write-workflow': 97, 'ask-build': 90, 'requesting-build': 95, 'build-complete': 100, @@ -324,8 +320,6 @@ export function getAndroidPhaseLabel(step: AndroidOnboardingStep): string { case 'preview-workflow-file': case 'view-workflow-diff': case 'writing-workflow-file': - case 'confirm-workflow-overwrite': - case 'overwrite-and-write-workflow': case 'ask-build': case 'requesting-build': return 'Step 4 of 4 · Save & Build' diff --git a/cli/src/build/onboarding/android/ui/app.tsx b/cli/src/build/onboarding/android/ui/app.tsx index 09829de835..e93d69e5f1 100644 --- a/cli/src/build/onboarding/android/ui/app.tsx +++ b/cli/src/build/onboarding/android/ui/app.tsx @@ -465,8 +465,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const [availableScripts, setAvailableScripts] = useState>({}) const [recommendedScript, setRecommendedScript] = useState(null) const [buildScriptChoice, setBuildScriptChoice] = useState(null) - const [workflowExistingContent, setWorkflowExistingContent] = useState(null) - const [workflowProposedContent, setWorkflowProposedContent] = useState(null) const [workflowWrittenPath, setWorkflowWrittenPath] = useState(null) const [envExportPath, setEnvExportPath] = useState(null) const [envExportError, setEnvExportError] = useState(null) @@ -1561,7 +1559,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir return const diff = diffLines(existing, proposed.content) const telemetry = getWorkflowDiffTelemetry(diff, isNew) - setWorkflowProposedContent(proposed.content) setPreviewExistingPath(absolutePath) setPreviewIsNew(isNew) setPreviewTelemetry(telemetry) @@ -1616,38 +1613,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir })() } - if (step === 'overwrite-and-write-workflow') { - ;(() => { - try { - if (!buildScriptChoice) - throw new Error('Internal error: no build script choice recorded.') - const result = writeWorkflowFile( - { - appId, - defaultPlatform: 'android', - packageManager: normalizePackageManager(pm.pm), - buildScript: buildScriptChoice, - secretKeys: ciSecretEntries.map(entry => entry.key), - }, - { overwrite: true }, - ) - if (cancelled) - return - if (result.kind === 'written') { - setWorkflowWrittenPath(result.absolutePath) - addLog(`✔ Overwrote ${WORKFLOW_PATH}`) - } - setStep('build-complete') - } - catch (err) { - if (!cancelled) { - addLog(`⚠ Failed to overwrite workflow file: ${err instanceof Error ? err.message : String(err)}`, 'yellow') - setStep('build-complete') - } - } - })() - } - if (step === 'exporting-env') { ;(() => { try { @@ -1839,7 +1804,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir || step === 'exporting-env' || step === 'confirm-env-export-overwrite' || step === 'overwrite-and-export-env' - || step === 'overwrite-and-write-workflow' || step === 'ci-secrets-failed' || step === 'confirm-ci-secret-overwrite' const showProgress = step !== 'welcome' && step !== 'error' && step !== 'build-complete' && !tallStep @@ -3138,48 +3102,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir )} - {step === 'overwrite-and-write-workflow' && ( - - - - )} - - {step === 'confirm-workflow-overwrite' && ( - - - {WORKFLOW_PATH} - {' '} - already exists. - - Replace it with the new Capgo workflow, or keep your version? - {workflowExistingContent && workflowProposedContent && ( - - - Existing: - {' '} - {workflowExistingContent.split('\n').length} - {' '} - lines · New: - {' '} - {workflowProposedContent.split('\n').length} - {' '} - lines - - - )} - - { - setStep(value === 'replace' ? 'overwrite-and-write-workflow' : 'build-complete') - }} - /> - - )} - {step === 'checking-ci-secrets' && ( diff --git a/cli/src/build/onboarding/ui/components.tsx b/cli/src/build/onboarding/ui/components.tsx index 3fbc7a3fdf..8bd80c2b21 100644 --- a/cli/src/build/onboarding/ui/components.tsx +++ b/cli/src/build/onboarding/ui/components.tsx @@ -253,7 +253,7 @@ export const FullscreenDiffViewer: FC<{ {`Showing ${firstVisibleLine}-${lastVisibleLine} of ${total} lines. Use ↑/↓ or k/j to scroll.`} - Click Escape to exit diff viewer + Press Escape to exit diff viewer ) } diff --git a/cli/test/fixtures/setup-test-projects.sh b/cli/test/fixtures/setup-test-projects.sh index 886e2078bd..dcf66cd646 100755 --- a/cli/test/fixtures/setup-test-projects.sh +++ b/cli/test/fixtures/setup-test-projects.sh @@ -54,6 +54,7 @@ cat > package.json << EOF "name": "yarn-test-project", "version": "1.0.0", "private": true, + "packageManager": "yarn@1.22.22", "dependencies": { "$PACKAGE_NAME": "$PACKAGE_VERSION" } @@ -74,6 +75,7 @@ cat > package.json << EOF "name": "pnpm-test-project", "version": "1.0.0", "private": true, + "packageManager": "pnpm@10.15.0", "dependencies": { "$PACKAGE_NAME": "$PACKAGE_VERSION" } @@ -114,6 +116,7 @@ cat > package.json << EOF "name": "yarn-workspaces-monorepo", "version": "1.0.0", "private": true, + "packageManager": "yarn@1.22.22", "workspaces": ["apps/*"] } EOF @@ -141,7 +144,8 @@ cat > package.json << EOF { "name": "pnpm-workspaces-monorepo", "version": "1.0.0", - "private": true + "private": true, + "packageManager": "pnpm@10.15.0" } EOF cat > pnpm-workspace.yaml << EOF @@ -172,7 +176,8 @@ cat > package.json << EOF { "name": "pnpm-catalog-monorepo", "version": "1.0.0", - "private": true + "private": true, + "packageManager": "pnpm@10.15.0" } EOF cat > pnpm-workspace.yaml << EOF From 073af27a82e68ad4a866464c643381fd8d894f3b Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Fri, 22 May 2026 13:44:37 +0200 Subject: [PATCH 6/9] fix(cli): remove unused diff viewer export --- cli/src/build/onboarding/ui/components.tsx | 76 ---------------------- 1 file changed, 76 deletions(-) diff --git a/cli/src/build/onboarding/ui/components.tsx b/cli/src/build/onboarding/ui/components.tsx index 8bd80c2b21..2a00ec0265 100644 --- a/cli/src/build/onboarding/ui/components.tsx +++ b/cli/src/build/onboarding/ui/components.tsx @@ -121,12 +121,6 @@ export interface SecretRow { * line-by-line dump entirely and show a short "matches — no diff" banner — * dumping 70 lines of `[eq]` content would only add noise. */ -export interface DiffViewerProps { - title: string - subtitle?: string - lines: DiffLine[] -} - function getDiffCounts(lines: DiffLine[]): { addCount: number, delCount: number, total: number } { return { addCount: lines.filter(l => l.kind === 'add').length, @@ -258,76 +252,6 @@ export const FullscreenDiffViewer: FC<{ ) } -export const DiffViewer: FC = ({ title, subtitle, lines }) => { - const total = lines.length - const allEqual = total > 0 && lines.every(l => l.kind === 'eq') - const addCount = lines.filter(l => l.kind === 'add').length - const delCount = lines.filter(l => l.kind === 'del').length - - // When the proposed file matches disk byte-for-byte, don't bother streaming - // every line — render a compact dynamic banner instead. - if (allEqual) { - return ( - - {title} - {subtitle && {subtitle}} - - - ✓ File on disk already matches the proposed content — - {' '} - {total} - {' '} - identical line - {total === 1 ? '' : 's'} - , no diff to show. - - - - ) - } - - return ( - - {title} - {subtitle && {subtitle}} - {'─'.repeat(60)} - - {'Summary: '} - {`+${addCount} added`} - {' '} - {`-${delCount} removed`} - - {lines.map((line, index) => { - const lineNumber = String(index + 1).padStart(4, ' ') - if (line.kind === 'add') { - return ( - - {`${lineNumber} + `} - {line.text} - - ) - } - if (line.kind === 'del') { - return ( - - {`${lineNumber} - `} - {line.text} - - ) - } - return ( - - {`${lineNumber} `} - {line.text} - - ) - })} - {'─'.repeat(60)} - {`End of proposed diff (${total} line${total === 1 ? '' : 's'} total). Scroll your terminal up to review.`} - - ) -} - /** * Render the secrets table inline. Keep this dynamic so the onboarding header * and prompt stay in one live Ink frame. From f30e040362a9eabac5af58d5c94c30944bc4fc91 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Fri, 22 May 2026 13:53:23 +0200 Subject: [PATCH 7/9] fix(cli): avoid typo warning in workflow generator comment --- cli/src/build/onboarding/workflow-generator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/build/onboarding/workflow-generator.ts b/cli/src/build/onboarding/workflow-generator.ts index 7ff00e4208..1cb114cfc9 100644 --- a/cli/src/build/onboarding/workflow-generator.ts +++ b/cli/src/build/onboarding/workflow-generator.ts @@ -101,7 +101,7 @@ export function generateWorkflow(opts: WorkflowGeneratorOpts): GeneratedWorkflow // Capgo cloud build request. The `${{ ... }}` syntax below is GitHub Actions // expression syntax, not a JS template literal — it must be emitted verbatim // into the YAML. eslint's `no-template-curly-in-string` doesn't understand - // that and would otherwise mis-flag every single line here. + // that and would otherwise flag every single line here incorrectly. lines.push('') lines.push(' - name: Capgo native build') lines.push(' run: |') From 5817600f91158e5b254a5b85a50d0520bdc99213 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Fri, 22 May 2026 14:06:10 +0200 Subject: [PATCH 8/9] fix(cli): emit custom workflow commands safely --- cli/src/build/onboarding/workflow-generator.ts | 8 +++++++- cli/test/test-workflow-generator.mjs | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/cli/src/build/onboarding/workflow-generator.ts b/cli/src/build/onboarding/workflow-generator.ts index 1cb114cfc9..f3672387a0 100644 --- a/cli/src/build/onboarding/workflow-generator.ts +++ b/cli/src/build/onboarding/workflow-generator.ts @@ -95,7 +95,7 @@ export function generateWorkflow(opts: WorkflowGeneratorOpts): GeneratedWorkflow if (opts.buildScript.type !== 'skip') { lines.push('') lines.push(' - name: Build web assets') - lines.push(` run: ${buildCommand(opts.packageManager, opts.buildScript)}`) + appendRunBlock(lines, buildCommand(opts.packageManager, opts.buildScript)) } // Capgo cloud build request. The `${{ ... }}` syntax below is GitHub Actions @@ -201,6 +201,12 @@ function installCommand(pm: PackageManager): string { } } +function appendRunBlock(lines: string[], command: string): void { + lines.push(' run: |') + for (const line of command.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')) + lines.push(` ${line}`) +} + function buildCommand(pm: PackageManager, choice: BuildScriptChoice): string { if (choice.type === 'custom') return choice.command diff --git a/cli/test/test-workflow-generator.mjs b/cli/test/test-workflow-generator.mjs index 7c41950268..c9be3cc63f 100644 --- a/cli/test/test-workflow-generator.mjs +++ b/cli/test/test-workflow-generator.mjs @@ -110,7 +110,7 @@ await test('yarn template uses yarn (not yarn run) for npm-script', () => { assertIncludes(content, `cache: 'yarn'`) assertIncludes(content, 'yarn install --frozen-lockfile') // yarn classic invokes scripts without `run` - assertIncludes(content, '\n run: yarn build\n') + assertIncludes(content, '\n run: |\n yarn build\n') assertIncludes(content, 'npx @capgo/cli@latest build request') assertIncludes(content, 'URL=$(npx @capgo/cli@latest build last-output') }) @@ -123,10 +123,22 @@ await test('custom build command is rendered verbatim', () => { buildScript: { type: 'custom', command: 'make web' }, secretKeys: [], }) - assertIncludes(content, 'run: make web') + assertIncludes(content, '\n run: |\n make web\n') assertExcludes(content, 'npm run', 'should not prepend npm run for custom command') }) +await test('custom build command with YAML-significant characters uses a block scalar', () => { + const { content } = generateWorkflow({ + appId: 'com.example.app', + defaultPlatform: 'ios', + packageManager: 'npm', + buildScript: { type: 'custom', command: 'echo "a: b" # shell comment' }, + secretKeys: [], + }) + assertIncludes(content, '\n run: |\n echo "a: b" # shell comment\n') + assertExcludes(content, 'run: echo "a: b" # shell comment') +}) + await test('skip build omits the build step but keeps install', () => { // Plain HTML/JS Capacitor apps exist (rare but real). They still need deps // installed (postinstall hooks, etc.) but have no separate build step. From a35ba39a981fbed4fa4cf4faee989804b3852451 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Fri, 22 May 2026 14:53:52 +0200 Subject: [PATCH 9/9] fix(cli): compact secrets push confirmation --- cli/src/build/onboarding/android/ui/app.tsx | 5 ++--- cli/src/build/onboarding/ui/app.tsx | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/cli/src/build/onboarding/android/ui/app.tsx b/cli/src/build/onboarding/android/ui/app.tsx index e93d69e5f1..7bec9094b0 100644 --- a/cli/src/build/onboarding/android/ui/app.tsx +++ b/cli/src/build/onboarding/android/ui/app.tsx @@ -3114,14 +3114,13 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir return ( ⚠ Confirm before pushing secrets - Repository: {' '} {ciSecretRepoLabel} + {' '} + (resolved via `gh repo view`) - (resolved via `gh repo view` from this directory) - {`Will push ${ciSecretEntries.length} env var${ciSecretEntries.length === 1 ? '' : 's'}`} {replaceCount > 0 ? ` — ${newCount} new, ${replaceCount} REPLACING existing:` : ' — all new:'} diff --git a/cli/src/build/onboarding/ui/app.tsx b/cli/src/build/onboarding/ui/app.tsx index 778a329c06..28244356ea 100644 --- a/cli/src/build/onboarding/ui/app.tsx +++ b/cli/src/build/onboarding/ui/app.tsx @@ -2926,14 +2926,13 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, t return ( ⚠ Confirm before pushing secrets - Repository: {' '} {ciSecretRepoLabel} + {' '} + (resolved via `gh repo view`) - (resolved via `gh repo view` from this directory) - {`Will push ${ciSecretEntries.length} env var${ciSecretEntries.length === 1 ? '' : 's'}`} {replaceCount > 0 ? ` — ${newCount} new, ${replaceCount} REPLACING existing:` : ' — all new:'}