From 6391f2f0170a89e7c7038462372ad15e6b45bc45 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 28 Apr 2026 12:05:55 +0200 Subject: [PATCH 01/15] fix(init): refresh iOS device selection --- README.md | 1 + package.json | 3 +- skills/usage/SKILL.md | 2 +- src/init/command.ts | 221 ++++++++++++++++++++++++++- test/test-onboarding-run-targets.mjs | 56 +++++++ webdocs/init.mdx | 2 +- 6 files changed, 275 insertions(+), 10 deletions(-) create mode 100644 test/test-onboarding-run-targets.mjs diff --git a/README.md b/README.md index 5e01d989..b470e2f2 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ npx @capgo/cli@latest init šŸš€ Initialize a new app in Capgo Cloud with step-by-step guidance. This includes adding code for updates, building, uploading your app, and verifying update functionality. Capgo bundles are web assets and can be fetched by anyone who knows the URL. Use encryption for banking, regulated, or other high-security apps. +During the iOS run-on-device step, choose a physical iPhone/iPad or simulator. If you choose a physical device, the CLI lets you connect, unlock, and check again before it launches the app. **Example:** diff --git a/package.json b/package.json index a56fdaa9..ed7bb6c6 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "test:checksum": "bun test/test-checksum-algorithm.mjs", "test:ci-prompts": "bun test/test-ci-prompts.mjs", "test:onboarding-recovery": "bun test/test-onboarding-recovery.mjs", + "test:onboarding-run-targets": "bun test/test-onboarding-run-targets.mjs", "test:prompt-preferences": "bun test/test-prompt-preferences.mjs", "test:esm-sdk": "node test/test-sdk-esm.mjs", "test:mcp": "node test/test-mcp.mjs", @@ -80,7 +81,7 @@ "test:version-detection:setup": "./test/fixtures/setup-test-projects.sh", "test:platform-paths": "bun test/test-platform-paths.mjs", "test:payload-split": "bun test/test-payload-split.mjs", - "test": "bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:ci-prompts && bun run test:onboarding-recovery && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split" + "test": "bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:ci-prompts && bun run test:onboarding-recovery && bun run test:onboarding-run-targets && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split" }, "devDependencies": { "@antfu/eslint-config": "^7.0.0", diff --git a/skills/usage/SKILL.md b/skills/usage/SKILL.md index 6d506251..cc6bbae3 100644 --- a/skills/usage/SKILL.md +++ b/skills/usage/SKILL.md @@ -24,7 +24,7 @@ TanStack Intent skills should stay focused and under the validator line limit, s ### Project setup and diagnostics -- `init [apikey] [appId]`: guided first-time setup for Capgo in a Capacitor app. The interactive flow now runs as a real Ink-based fullscreen onboarding so it uses the same UI stack as `build init` (alias: `build onboarding`), with a persistent dashboard, phase roadmap, progress cards, shared log area, and resume support. When dependency auto-detection fails on macOS, the flow opens a native file picker for `package.json` before falling back to manual path entry. If the user reuses a pending app that was already created in the web onboarding flow, the CLI syncs that selected dashboard app ID back into `capacitor.config.*` before the remaining steps continue. Outside that reused pending-app path, the CLI keeps using the local Capacitor app ID. It can also offer a final `npx skills add https://github.com/Cap-go/capgo-skills -g -y` install step before the GitHub support prompt; if accepted, the support menu includes `Cap-go/capgo-skills` alongside the updater-only and all-Capgo choices. If native platforms are missing, the onboarding can offer to run `cap add` for you. If iOS sync validation fails during onboarding, the CLI can offer to run a one-line native reset command, wait for you to type `ready` after a manual fix, surface `doctor`, and save a support bundle before you leave the flow. +- `init [apikey] [appId]`: guided first-time setup for Capgo in a Capacitor app. The interactive flow now runs as a real Ink-based fullscreen onboarding so it uses the same UI stack as `build init` (alias: `build onboarding`), with a persistent dashboard, phase roadmap, progress cards, shared log area, and resume support. When dependency auto-detection fails on macOS, the flow opens a native file picker for `package.json` before falling back to manual path entry. If the user reuses a pending app that was already created in the web onboarding flow, the CLI syncs that selected dashboard app ID back into `capacitor.config.*` before the remaining steps continue. Outside that reused pending-app path, the CLI keeps using the local Capacitor app ID. It can also offer a final `npx skills add https://github.com/Cap-go/capgo-skills -g -y` install step before the GitHub support prompt; if accepted, the support menu includes `Cap-go/capgo-skills` alongside the updater-only and all-Capgo choices. If native platforms are missing, the onboarding can offer to run `cap add` for you. During the iOS run-on-device step, onboarding asks whether to use a physical iPhone/iPad or a simulator; for physical devices, it asks the user to connect and unlock the device, then offers a check-again loop before launching with the detected target. If iOS sync validation fails during onboarding, the CLI can offer to run a one-line native reset command, wait for you to type `ready` after a manual fix, surface `doctor`, and save a support bundle before you leave the flow. - `login [apikey]`: store an API key locally. - `doctor`: inspect installation health and gather troubleshooting details. - `probe`: test whether the update endpoint would deliver an update. diff --git a/src/init/command.ts b/src/init/command.ts index 242b9d57..a362819e 100644 --- a/src/init/command.ts +++ b/src/init/command.ts @@ -1,3 +1,4 @@ +import type { Buffer } from 'node:buffer' import type { ExecSyncOptions } from 'node:child_process' import type { Options, PendingOnboardingApp } from '../api/app' import type { Organization } from '../utils' @@ -2312,6 +2313,19 @@ function promoteEncryptionSummaryToEnabled(): void { type PackageManagerInfo = ReturnType type PlatformChoice = 'ios' | 'android' type BuildProjectStepOutcome = 'completed' | 'skipped' +type RunDeviceStepOutcome = { args: string[], command: string } | { args: undefined, command: string } + +export interface CapacitorRunTarget { + name: string + api: string | undefined + id: string +} + +const iosRunTargetActions = { + refresh: '__refresh__', + simulator: '__simulator__', + skip: '__skip__', +} as const async function ensureNativePlatformForBuild(platform: PlatformChoice, config: CapacitorConfigSnapshot | undefined, runner: string): Promise { const addPlatformCommand = formatRunnerCommand(runner, ['cap', 'add', platform]) @@ -2430,6 +2444,167 @@ async function buildProjectStep(orgId: string, apikey: string, appId: string, pl await markStep(orgId, apikey, 'build-project', appId) } +function runPackageRunnerSync(runner: string, args: string[], options: Parameters[2]) { + const parsedRunner = splitRunnerCommand(runner) + return spawnSync(parsedRunner.command, [...parsedRunner.args, ...args], options) +} + +function getSpawnOutputText(output: string | Buffer | null | undefined): string { + if (!output) + return '' + return typeof output === 'string' ? output.trim() : output.toString('utf8').trim() +} + +export function parseCapacitorRunTargetList(output: string): CapacitorRunTarget[] { + const parsed = JSON.parse(output.trim()) + if (!Array.isArray(parsed)) + return [] + + return parsed + .filter((target): target is Record => typeof target === 'object' && target !== null) + .map((target) => { + const id = typeof target.id === 'string' ? target.id.trim() : '' + const rawName = typeof target.name === 'string' ? target.name.trim() : '' + const api = typeof target.api === 'string' ? target.api.trim() : undefined + return { + id, + name: rawName || id, + api, + } + }) + .filter((target): target is CapacitorRunTarget => target.id.length > 0 && target.name.length > 0) +} + +export function getPhysicalIosRunTargets(targets: CapacitorRunTarget[]): CapacitorRunTarget[] { + return targets.filter(target => !/\(simulator\)$/i.test(target.name)) +} + +function getCapacitorRunTargetList(runner: string, platformName: PlatformChoice): { targets: CapacitorRunTarget[], error?: Error } { + try { + const result = runPackageRunnerSync(runner, ['cap', 'run', platformName, '--list', '--json'], { + stdio: 'pipe', + encoding: 'utf8', + }) + + if (result.error) + return { targets: [], error: result.error } + + if (result.status !== 0) { + const stderr = getSpawnOutputText(result.stderr) + const stdout = getSpawnOutputText(result.stdout) + return { targets: [], error: new Error(stderr || stdout || `cap run ${platformName} --list exited with code ${result.status ?? 'unknown'}`) } + } + + return { targets: parseCapacitorRunTargetList(getSpawnOutputText(result.stdout)) } + } + catch (error) { + return { targets: [], error: error instanceof Error ? error : new Error(String(error)) } + } +} + +function getRunDeviceCommand(pm: PackageManagerInfo, platformName: PlatformChoice, target?: CapacitorRunTarget): RunDeviceStepOutcome { + const args = ['cap', 'run', platformName] + if (target) + args.push('--target', target.id) + return { args, command: formatRunnerCommand(pm.runner, args) } +} + +function getSkippedRunDeviceCommand(pm: PackageManagerInfo, platformName: PlatformChoice): RunDeviceStepOutcome { + const args = ['cap', 'run', platformName] + return { args: undefined, command: formatRunnerCommand(pm.runner, args) } +} + +async function selectPhysicalIosRunTarget(orgId: string, apikey: string, pm: PackageManagerInfo): Promise { + pLog.info('Connect your iPhone or iPad, unlock it, and tap Trust if prompted.') + + while (true) { + const result = getCapacitorRunTargetList(pm.runner, 'ios') + if (result.error) + pLog.warn(`Could not check connected iOS devices: ${formatError(result.error)}`) + + const physicalTargets = getPhysicalIosRunTargets(result.targets) + + if (physicalTargets.length === 1) { + const target = physicalTargets[0] + pLog.info(`Found physical iOS device: ${target.name}`) + return getRunDeviceCommand(pm, 'ios', target) + } + + if (physicalTargets.length > 1) { + const selectedTarget = await pSelect({ + message: 'Which physical iOS device should onboarding use?', + options: [ + ...physicalTargets.map(target => ({ + value: target.id, + label: target.name, + hint: target.id, + })), + { value: iosRunTargetActions.refresh, label: 'Check again for connected devices' }, + { value: iosRunTargetActions.simulator, label: 'Use iOS Simulator instead' }, + { value: iosRunTargetActions.skip, label: 'Skip running now' }, + ], + }) + + if (pIsCancel(selectedTarget)) + await exitCanceledInitOnboarding(orgId, apikey) + + if (selectedTarget === iosRunTargetActions.refresh) + continue + if (selectedTarget === iosRunTargetActions.simulator) + return getRunDeviceCommand(pm, 'ios') + if (selectedTarget === iosRunTargetActions.skip) + return getSkippedRunDeviceCommand(pm, 'ios') + + const target = physicalTargets.find(({ id }) => id === selectedTarget) + if (target) + return getRunDeviceCommand(pm, 'ios', target) + + pLog.warn('That iOS device is no longer available. Checking again.') + continue + } + + pLog.warn('No physical iOS device detected yet.') + const nextAction = await pSelect({ + message: 'Connect and unlock your iPhone, then check again.', + options: [ + { value: iosRunTargetActions.refresh, label: 'Check again for connected devices' }, + { value: iosRunTargetActions.simulator, label: 'Use iOS Simulator instead' }, + { value: iosRunTargetActions.skip, label: 'Skip running now' }, + ], + }) + + if (pIsCancel(nextAction)) + await exitCanceledInitOnboarding(orgId, apikey) + + if (nextAction === iosRunTargetActions.refresh) + continue + if (nextAction === iosRunTargetActions.simulator) + return getRunDeviceCommand(pm, 'ios') + return getSkippedRunDeviceCommand(pm, 'ios') + } +} + +async function resolveRunDeviceCommand(orgId: string, apikey: string, pm: PackageManagerInfo, platformName: PlatformChoice): Promise { + if (platformName !== 'ios') + return getRunDeviceCommand(pm, platformName) + + const targetKind = await pSelect({ + message: 'Where do you want to run the iOS app?', + options: [ + { value: 'physical', label: 'Physical iPhone or iPad' }, + { value: 'simulator', label: 'iOS Simulator' }, + ], + }) + + if (pIsCancel(targetKind)) + await exitCanceledInitOnboarding(orgId, apikey) + + if (targetKind === 'simulator') + return getRunDeviceCommand(pm, 'ios') + + return selectPhysicalIosRunTarget(orgId, apikey, pm) +} + function getSelectablePlatformOptions(config?: CapacitorConfigSnapshot): Array<{ value: PlatformChoice, label: string }> { const availablePlatforms = getNativePlatformAvailability(config) const options: Array<{ value: PlatformChoice, label: string }> = [] @@ -2499,14 +2674,31 @@ async function runDeviceStep(orgId: string, apikey: string, appId: string, platf const doRun = await pConfirm({ message: `Run ${appId} on ${platform.toUpperCase()} device now to test the initial version?` }) await cancelCommand(doRun, orgId, apikey) if (doRun) { + const runCommand = await resolveRunDeviceCommand(orgId, apikey, pm, platform) + if (!runCommand.args) { + pLog.info(`Skipped device launch. You can run it manually with: ${runCommand.command}`) + await markStep(orgId, apikey, 'run-device', appId) + return + } + const s = pSpinner() - s.start(`Running: ${pm.runner} cap run ${platform}`) - const runResult = spawnSync(pm.runner, ['cap', 'run', platform], { stdio: 'inherit' }) - const runFailed = runResult.error || runResult.status !== 0 + s.start(`Running: ${runCommand.command}`) + + let runResult: ReturnType | undefined + let runError: Error | undefined + try { + runResult = runPackageRunnerSync(pm.runner, runCommand.args, { stdio: 'inherit' }) + } + catch (error) { + runError = error instanceof Error ? error : new Error(String(error)) + } + const runFailed = runError || runResult?.error || runResult?.status !== 0 if (runFailed) { const platformName = platform === 'ios' ? 'iOS' : 'Android' s.stop(`App failed to start āŒ`) + if (runError || runResult?.error) + pLog.error(formatError(runError ?? runResult?.error)) pLog.error(`The app failed to start on your ${platformName} device.`) const openIDE = await pConfirm({ @@ -2516,12 +2708,27 @@ async function runDeviceStep(orgId: string, apikey: string, appId: string, platf if (!pIsCancel(openIDE) && openIDE) { const s2 = pSpinner() s2.start(`Opening ${platform === 'ios' ? 'Xcode' : 'Android Studio'}...`) - spawnSync(pm.runner, ['cap', 'open', platform], { stdio: 'inherit' }) - s2.stop(`IDE opened āœ…`) - pLog.info(`Please run the app manually from ${platform === 'ios' ? 'Xcode' : 'Android Studio'}`) + try { + const openResult = runPackageRunnerSync(pm.runner, ['cap', 'open', platform], { stdio: 'inherit' }) + if (openResult.error || openResult.status !== 0) { + s2.stop(`Could not open ${platform === 'ios' ? 'Xcode' : 'Android Studio'} āŒ`) + if (openResult.error) + pLog.error(formatError(openResult.error)) + pLog.info(`You can run the app manually with: ${runCommand.command}`) + } + else { + s2.stop(`IDE opened āœ…`) + pLog.info(`Please run the app manually from ${platform === 'ios' ? 'Xcode' : 'Android Studio'}`) + } + } + catch (error) { + s2.stop(`Could not open ${platform === 'ios' ? 'Xcode' : 'Android Studio'} āŒ`) + pLog.error(formatError(error)) + pLog.info(`You can run the app manually with: ${runCommand.command}`) + } } else { - pLog.info(`You can run the app manually with: ${pm.runner} cap run ${platform}`) + pLog.info(`You can run the app manually with: ${runCommand.command}`) } } else { diff --git a/test/test-onboarding-run-targets.mjs b/test/test-onboarding-run-targets.mjs new file mode 100644 index 00000000..afac73cc --- /dev/null +++ b/test/test-onboarding-run-targets.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict' +import { + getPhysicalIosRunTargets, + parseCapacitorRunTargetList, +} from '../src/init/command.ts' + +let failures = 0 + +function test(name, fn) { + try { + fn() + console.log(`āœ“ ${name}`) + } + catch (error) { + failures += 1 + console.error(`āŒ ${name}`) + console.error(error) + } +} + +test('parses Capacitor run target list output', () => { + const targets = parseCapacitorRunTargetList(JSON.stringify([ + { name: 'Martin iPhone', api: 'iOS 26.0', id: '00008110-001A' }, + { name: 'iPhone 17 (simulator)', api: 'iOS 26.0', id: 'A-B-C-D' }, + { name: '', api: 'iOS 26.0', id: 'FALLBACK-ID' }, + { name: 'Missing ID', api: 'iOS 26.0' }, + ])) + + assert.deepEqual(targets, [ + { name: 'Martin iPhone', api: 'iOS 26.0', id: '00008110-001A' }, + { name: 'iPhone 17 (simulator)', api: 'iOS 26.0', id: 'A-B-C-D' }, + { name: 'FALLBACK-ID', api: 'iOS 26.0', id: 'FALLBACK-ID' }, + ]) +}) + +test('filters physical iOS devices from simulator targets', () => { + const physicalTargets = getPhysicalIosRunTargets([ + { name: 'Martin iPhone', api: 'iOS 26.0', id: 'device-1' }, + { name: 'iPad Pro (simulator)', api: 'iOS 26.0', id: 'sim-1' }, + { name: 'QA iPad', api: 'iOS 25.5', id: 'device-2' }, + ]) + + assert.deepEqual(physicalTargets, [ + { name: 'Martin iPhone', api: 'iOS 26.0', id: 'device-1' }, + { name: 'QA iPad', api: 'iOS 25.5', id: 'device-2' }, + ]) +}) + +if (failures > 0) { + console.error(`\nāŒ ${failures} onboarding run target test(s) failed`) + process.exit(1) +} + +console.log('\nāœ… Onboarding run target handling works') diff --git a/webdocs/init.mdx b/webdocs/init.mdx index e75ff26e..2d30acf2 100644 --- a/webdocs/init.mdx +++ b/webdocs/init.mdx @@ -16,6 +16,7 @@ npx @capgo/cli@latest init This includes adding code for updates, building, uploading your app, and verifying update functionality. Capgo bundles are web assets and can be fetched by anyone who knows the URL. Use encryption for banking, regulated, or other high-security apps. +During the iOS run-on-device step, choose a physical iPhone/iPad or simulator. If you choose a physical device, the CLI lets you connect, unlock, and check again before it launches the app. **Example:** @@ -31,4 +32,3 @@ npx @capgo/cli@latest init YOUR_API_KEY com.example.app | **-i,** | string | App icon path for display in Capgo Cloud | | **--supa-host** | string | Custom Supabase host URL (for self-hosting or Capgo development) | | **--supa-anon** | string | Custom Supabase anon key (for self-hosting) | - From 9a1523caa1deca19bf703634861004ad789ebee2 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 28 Apr 2026 12:33:56 +0200 Subject: [PATCH 02/15] refactor(init): split iOS target selection branches --- src/init/command.ts | 122 ++++++++++++++++++++++++-------------------- 1 file changed, 67 insertions(+), 55 deletions(-) diff --git a/src/init/command.ts b/src/init/command.ts index a362819e..0dc2155b 100644 --- a/src/init/command.ts +++ b/src/init/command.ts @@ -2326,6 +2326,7 @@ const iosRunTargetActions = { simulator: '__simulator__', skip: '__skip__', } as const +type IosRunTargetResolution = RunDeviceStepOutcome | typeof iosRunTargetActions.refresh async function ensureNativePlatformForBuild(platform: PlatformChoice, config: CapacitorConfigSnapshot | undefined, runner: string): Promise { const addPlatformCommand = formatRunnerCommand(runner, ['cap', 'add', platform]) @@ -2514,73 +2515,84 @@ function getSkippedRunDeviceCommand(pm: PackageManagerInfo, platformName: Platfo return { args: undefined, command: formatRunnerCommand(pm.runner, args) } } -async function selectPhysicalIosRunTarget(orgId: string, apikey: string, pm: PackageManagerInfo): Promise { - pLog.info('Connect your iPhone or iPad, unlock it, and tap Trust if prompted.') +function handleSinglePhysicalIosRunTarget(pm: PackageManagerInfo, target: CapacitorRunTarget): RunDeviceStepOutcome { + pLog.info(`Found physical iOS device: ${target.name}`) + return getRunDeviceCommand(pm, 'ios', target) +} - while (true) { - const result = getCapacitorRunTargetList(pm.runner, 'ios') - if (result.error) - pLog.warn(`Could not check connected iOS devices: ${formatError(result.error)}`) +async function handleMultiplePhysicalIosRunTargets(orgId: string, apikey: string, pm: PackageManagerInfo, physicalTargets: CapacitorRunTarget[]): Promise { + const selectedTarget = await pSelect({ + message: 'Which physical iOS device should onboarding use?', + options: [ + ...physicalTargets.map(target => ({ + value: target.id, + label: target.name, + hint: target.id, + })), + { value: iosRunTargetActions.refresh, label: 'Check again for connected devices' }, + { value: iosRunTargetActions.simulator, label: 'Use iOS Simulator instead' }, + { value: iosRunTargetActions.skip, label: 'Skip running now' }, + ], + }) - const physicalTargets = getPhysicalIosRunTargets(result.targets) + if (pIsCancel(selectedTarget)) + await exitCanceledInitOnboarding(orgId, apikey) - if (physicalTargets.length === 1) { - const target = physicalTargets[0] - pLog.info(`Found physical iOS device: ${target.name}`) - return getRunDeviceCommand(pm, 'ios', target) - } + if (selectedTarget === iosRunTargetActions.refresh) + return iosRunTargetActions.refresh + if (selectedTarget === iosRunTargetActions.simulator) + return getRunDeviceCommand(pm, 'ios') + if (selectedTarget === iosRunTargetActions.skip) + return getSkippedRunDeviceCommand(pm, 'ios') - if (physicalTargets.length > 1) { - const selectedTarget = await pSelect({ - message: 'Which physical iOS device should onboarding use?', - options: [ - ...physicalTargets.map(target => ({ - value: target.id, - label: target.name, - hint: target.id, - })), - { value: iosRunTargetActions.refresh, label: 'Check again for connected devices' }, - { value: iosRunTargetActions.simulator, label: 'Use iOS Simulator instead' }, - { value: iosRunTargetActions.skip, label: 'Skip running now' }, - ], - }) + const target = physicalTargets.find(({ id }) => id === selectedTarget) + if (target) + return getRunDeviceCommand(pm, 'ios', target) - if (pIsCancel(selectedTarget)) - await exitCanceledInitOnboarding(orgId, apikey) + pLog.warn('That iOS device is no longer available. Checking again.') + return iosRunTargetActions.refresh +} - if (selectedTarget === iosRunTargetActions.refresh) - continue - if (selectedTarget === iosRunTargetActions.simulator) - return getRunDeviceCommand(pm, 'ios') - if (selectedTarget === iosRunTargetActions.skip) - return getSkippedRunDeviceCommand(pm, 'ios') +async function handleMissingPhysicalIosRunTargets(orgId: string, apikey: string, pm: PackageManagerInfo): Promise { + pLog.warn('No physical iOS device detected yet.') + const nextAction = await pSelect({ + message: 'Connect and unlock your iPhone, then check again.', + options: [ + { value: iosRunTargetActions.refresh, label: 'Check again for connected devices' }, + { value: iosRunTargetActions.simulator, label: 'Use iOS Simulator instead' }, + { value: iosRunTargetActions.skip, label: 'Skip running now' }, + ], + }) - const target = physicalTargets.find(({ id }) => id === selectedTarget) - if (target) - return getRunDeviceCommand(pm, 'ios', target) + if (pIsCancel(nextAction)) + await exitCanceledInitOnboarding(orgId, apikey) - pLog.warn('That iOS device is no longer available. Checking again.') - continue - } + if (nextAction === iosRunTargetActions.refresh) + return iosRunTargetActions.refresh + if (nextAction === iosRunTargetActions.simulator) + return getRunDeviceCommand(pm, 'ios') + return getSkippedRunDeviceCommand(pm, 'ios') +} - pLog.warn('No physical iOS device detected yet.') - const nextAction = await pSelect({ - message: 'Connect and unlock your iPhone, then check again.', - options: [ - { value: iosRunTargetActions.refresh, label: 'Check again for connected devices' }, - { value: iosRunTargetActions.simulator, label: 'Use iOS Simulator instead' }, - { value: iosRunTargetActions.skip, label: 'Skip running now' }, - ], - }) +async function selectPhysicalIosRunTarget(orgId: string, apikey: string, pm: PackageManagerInfo): Promise { + pLog.info('Connect your iPhone or iPad, unlock it, and tap Trust if prompted.') + + while (true) { + const result = getCapacitorRunTargetList(pm.runner, 'ios') + if (result.error) + pLog.warn(`Could not check connected iOS devices: ${formatError(result.error)}`) - if (pIsCancel(nextAction)) - await exitCanceledInitOnboarding(orgId, apikey) + const physicalTargets = getPhysicalIosRunTargets(result.targets) - if (nextAction === iosRunTargetActions.refresh) + if (physicalTargets.length === 1) + return handleSinglePhysicalIosRunTarget(pm, physicalTargets[0]) + + const selectionResult = physicalTargets.length > 1 + ? await handleMultiplePhysicalIosRunTargets(orgId, apikey, pm, physicalTargets) + : await handleMissingPhysicalIosRunTargets(orgId, apikey, pm) + if (selectionResult === iosRunTargetActions.refresh) continue - if (nextAction === iosRunTargetActions.simulator) - return getRunDeviceCommand(pm, 'ios') - return getSkippedRunDeviceCommand(pm, 'ios') + return selectionResult } } From de870377d9fc9b1838dc8fcc36273b014076807f Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 28 Apr 2026 12:40:14 +0200 Subject: [PATCH 03/15] fix(init): harden iOS run target parsing --- src/init/command.ts | 19 ++++++++++++++++--- test/test-onboarding-run-targets.mjs | 7 +++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/init/command.ts b/src/init/command.ts index 0dc2155b..8c0ef0da 100644 --- a/src/init/command.ts +++ b/src/init/command.ts @@ -2326,6 +2326,8 @@ const iosRunTargetActions = { simulator: '__simulator__', skip: '__skip__', } as const +const IOS_SIMULATOR_TARGET_SUFFIX_RE = /\(simulator\)$/i +const INVALID_CAPACITOR_RUN_TARGET_IDS = new Set(['?']) type IosRunTargetResolution = RunDeviceStepOutcome | typeof iosRunTargetActions.refresh async function ensureNativePlatformForBuild(platform: PlatformChoice, config: CapacitorConfigSnapshot | undefined, runner: string): Promise { @@ -2457,7 +2459,18 @@ function getSpawnOutputText(output: string | Buffer | null | undefined): string } export function parseCapacitorRunTargetList(output: string): CapacitorRunTarget[] { - const parsed = JSON.parse(output.trim()) + const trimmed = output.trim() + if (!trimmed) + return [] + + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } + catch { + return [] + } + if (!Array.isArray(parsed)) return [] @@ -2473,11 +2486,11 @@ export function parseCapacitorRunTargetList(output: string): CapacitorRunTarget[ api, } }) - .filter((target): target is CapacitorRunTarget => target.id.length > 0 && target.name.length > 0) + .filter((target): target is CapacitorRunTarget => target.id.length > 0 && !INVALID_CAPACITOR_RUN_TARGET_IDS.has(target.id) && target.name.length > 0) } export function getPhysicalIosRunTargets(targets: CapacitorRunTarget[]): CapacitorRunTarget[] { - return targets.filter(target => !/\(simulator\)$/i.test(target.name)) + return targets.filter(target => !IOS_SIMULATOR_TARGET_SUFFIX_RE.test(target.name)) } function getCapacitorRunTargetList(runner: string, platformName: PlatformChoice): { targets: CapacitorRunTarget[], error?: Error } { diff --git a/test/test-onboarding-run-targets.mjs b/test/test-onboarding-run-targets.mjs index afac73cc..60d9836a 100644 --- a/test/test-onboarding-run-targets.mjs +++ b/test/test-onboarding-run-targets.mjs @@ -26,6 +26,7 @@ test('parses Capacitor run target list output', () => { { name: 'iPhone 17 (simulator)', api: 'iOS 26.0', id: 'A-B-C-D' }, { name: '', api: 'iOS 26.0', id: 'FALLBACK-ID' }, { name: 'Missing ID', api: 'iOS 26.0' }, + { name: 'Unresolved iPhone', api: 'iOS 26.0', id: '?' }, ])) assert.deepEqual(targets, [ @@ -35,6 +36,12 @@ test('parses Capacitor run target list output', () => { ]) }) +test('returns an empty target list for malformed Capacitor output', () => { + assert.deepEqual(parseCapacitorRunTargetList(''), []) + assert.deepEqual(parseCapacitorRunTargetList('not json'), []) + assert.deepEqual(parseCapacitorRunTargetList(JSON.stringify({ name: 'Not a list' })), []) +}) + test('filters physical iOS devices from simulator targets', () => { const physicalTargets = getPhysicalIosRunTargets([ { name: 'Martin iPhone', api: 'iOS 26.0', id: 'device-1' }, From 952a009a5402a39d05dd0406463ef3695e795ee2 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 28 Apr 2026 12:53:27 +0200 Subject: [PATCH 04/15] fix(init): select explicit iOS simulator target --- src/init/command.ts | 94 ++++++++++++++++++++++++++-- test/test-onboarding-run-targets.mjs | 14 +++++ 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/src/init/command.ts b/src/init/command.ts index 8c0ef0da..7e933c9b 100644 --- a/src/init/command.ts +++ b/src/init/command.ts @@ -2328,7 +2328,8 @@ const iosRunTargetActions = { } as const const IOS_SIMULATOR_TARGET_SUFFIX_RE = /\(simulator\)$/i const INVALID_CAPACITOR_RUN_TARGET_IDS = new Set(['?']) -type IosRunTargetResolution = RunDeviceStepOutcome | typeof iosRunTargetActions.refresh +type IosRunTargetResolution = RunDeviceStepOutcome | typeof iosRunTargetActions.refresh | typeof iosRunTargetActions.simulator +type IosSimulatorRunTargetResolution = RunDeviceStepOutcome | typeof iosRunTargetActions.refresh async function ensureNativePlatformForBuild(platform: PlatformChoice, config: CapacitorConfigSnapshot | undefined, runner: string): Promise { const addPlatformCommand = formatRunnerCommand(runner, ['cap', 'add', platform]) @@ -2493,6 +2494,10 @@ export function getPhysicalIosRunTargets(targets: CapacitorRunTarget[]): Capacit return targets.filter(target => !IOS_SIMULATOR_TARGET_SUFFIX_RE.test(target.name)) } +export function getSimulatorIosRunTargets(targets: CapacitorRunTarget[]): CapacitorRunTarget[] { + return targets.filter(target => IOS_SIMULATOR_TARGET_SUFFIX_RE.test(target.name)) +} + function getCapacitorRunTargetList(runner: string, platformName: PlatformChoice): { targets: CapacitorRunTarget[], error?: Error } { try { const result = runPackageRunnerSync(runner, ['cap', 'run', platformName, '--list', '--json'], { @@ -2533,6 +2538,11 @@ function handleSinglePhysicalIosRunTarget(pm: PackageManagerInfo, target: Capaci return getRunDeviceCommand(pm, 'ios', target) } +function handleSingleSimulatorIosRunTarget(pm: PackageManagerInfo, target: CapacitorRunTarget): RunDeviceStepOutcome { + pLog.info(`Found iOS Simulator: ${target.name}`) + return getRunDeviceCommand(pm, 'ios', target) +} + async function handleMultiplePhysicalIosRunTargets(orgId: string, apikey: string, pm: PackageManagerInfo, physicalTargets: CapacitorRunTarget[]): Promise { const selectedTarget = await pSelect({ message: 'Which physical iOS device should onboarding use?', @@ -2554,7 +2564,7 @@ async function handleMultiplePhysicalIosRunTargets(orgId: string, apikey: string if (selectedTarget === iosRunTargetActions.refresh) return iosRunTargetActions.refresh if (selectedTarget === iosRunTargetActions.simulator) - return getRunDeviceCommand(pm, 'ios') + return iosRunTargetActions.simulator if (selectedTarget === iosRunTargetActions.skip) return getSkippedRunDeviceCommand(pm, 'ios') @@ -2583,10 +2593,84 @@ async function handleMissingPhysicalIosRunTargets(orgId: string, apikey: string, if (nextAction === iosRunTargetActions.refresh) return iosRunTargetActions.refresh if (nextAction === iosRunTargetActions.simulator) - return getRunDeviceCommand(pm, 'ios') + return iosRunTargetActions.simulator return getSkippedRunDeviceCommand(pm, 'ios') } +async function handleMultipleSimulatorIosRunTargets(orgId: string, apikey: string, pm: PackageManagerInfo, simulatorTargets: CapacitorRunTarget[]): Promise { + const selectedTarget = await pSelect({ + message: 'Which iOS Simulator should onboarding use?', + options: [ + ...simulatorTargets.map(target => ({ + value: target.id, + label: target.name, + hint: target.id, + })), + { value: iosRunTargetActions.refresh, label: 'Check again for simulators' }, + { value: iosRunTargetActions.skip, label: 'Skip running now' }, + ], + }) + + if (pIsCancel(selectedTarget)) + await exitCanceledInitOnboarding(orgId, apikey) + + if (selectedTarget === iosRunTargetActions.refresh) + return iosRunTargetActions.refresh + if (selectedTarget === iosRunTargetActions.skip) + return getSkippedRunDeviceCommand(pm, 'ios') + + const target = simulatorTargets.find(({ id }) => id === selectedTarget) + if (target) + return getRunDeviceCommand(pm, 'ios', target) + + pLog.warn('That iOS Simulator is no longer available. Checking again.') + return iosRunTargetActions.refresh +} + +async function handleMissingSimulatorIosRunTargets(orgId: string, apikey: string, pm: PackageManagerInfo): Promise { + pLog.warn('No iOS Simulator target detected yet.') + const nextAction = await pSelect({ + message: 'Open Xcode or install a simulator, then check again.', + options: [ + { value: iosRunTargetActions.refresh, label: 'Check again for simulators' }, + { value: iosRunTargetActions.skip, label: 'Skip running now' }, + ], + }) + + if (pIsCancel(nextAction)) + await exitCanceledInitOnboarding(orgId, apikey) + + if (nextAction === iosRunTargetActions.refresh) + return iosRunTargetActions.refresh + return getSkippedRunDeviceCommand(pm, 'ios') +} + +async function selectSimulatorIosRunTarget(orgId: string, apikey: string, pm: PackageManagerInfo, initialTargets?: CapacitorRunTarget[]): Promise { + let knownTargets = initialTargets + + while (true) { + const result = knownTargets + ? { targets: knownTargets } + : getCapacitorRunTargetList(pm.runner, 'ios') + knownTargets = undefined + + if ('error' in result && result.error) + pLog.warn(`Could not check iOS Simulator targets: ${formatError(result.error)}`) + + const simulatorTargets = getSimulatorIosRunTargets(result.targets) + + if (simulatorTargets.length === 1) + return handleSingleSimulatorIosRunTarget(pm, simulatorTargets[0]) + + const selectionResult = simulatorTargets.length > 1 + ? await handleMultipleSimulatorIosRunTargets(orgId, apikey, pm, simulatorTargets) + : await handleMissingSimulatorIosRunTargets(orgId, apikey, pm) + if (selectionResult === iosRunTargetActions.refresh) + continue + return selectionResult + } +} + async function selectPhysicalIosRunTarget(orgId: string, apikey: string, pm: PackageManagerInfo): Promise { pLog.info('Connect your iPhone or iPad, unlock it, and tap Trust if prompted.') @@ -2605,6 +2689,8 @@ async function selectPhysicalIosRunTarget(orgId: string, apikey: string, pm: Pac : await handleMissingPhysicalIosRunTargets(orgId, apikey, pm) if (selectionResult === iosRunTargetActions.refresh) continue + if (selectionResult === iosRunTargetActions.simulator) + return selectSimulatorIosRunTarget(orgId, apikey, pm, result.targets) return selectionResult } } @@ -2625,7 +2711,7 @@ async function resolveRunDeviceCommand(orgId: string, apikey: string, pm: Packag await exitCanceledInitOnboarding(orgId, apikey) if (targetKind === 'simulator') - return getRunDeviceCommand(pm, 'ios') + return selectSimulatorIosRunTarget(orgId, apikey, pm) return selectPhysicalIosRunTarget(orgId, apikey, pm) } diff --git a/test/test-onboarding-run-targets.mjs b/test/test-onboarding-run-targets.mjs index 60d9836a..22d46d17 100644 --- a/test/test-onboarding-run-targets.mjs +++ b/test/test-onboarding-run-targets.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict' import { getPhysicalIosRunTargets, + getSimulatorIosRunTargets, parseCapacitorRunTargetList, } from '../src/init/command.ts' @@ -55,6 +56,19 @@ test('filters physical iOS devices from simulator targets', () => { ]) }) +test('filters iOS Simulator targets from physical devices', () => { + const simulatorTargets = getSimulatorIosRunTargets([ + { name: 'Martin iPhone', api: 'iOS 26.0', id: 'device-1' }, + { name: 'iPad Pro (simulator)', api: 'iOS 26.0', id: 'sim-1' }, + { name: 'iPhone 17 (simulator)', api: 'iOS 26.0', id: 'sim-2' }, + ]) + + assert.deepEqual(simulatorTargets, [ + { name: 'iPad Pro (simulator)', api: 'iOS 26.0', id: 'sim-1' }, + { name: 'iPhone 17 (simulator)', api: 'iOS 26.0', id: 'sim-2' }, + ]) +}) + if (failures > 0) { console.error(`\nāŒ ${failures} onboarding run target test(s) failed`) process.exit(1) From 7439adcbb05c9208e218dcf2915589f706394c64 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 28 Apr 2026 13:15:48 +0200 Subject: [PATCH 05/15] feat(init): add run-device test command --- README.md | 24 +++++++++ skills/usage/SKILL.md | 2 + src/docs.ts | 2 + src/index.ts | 14 ++++- src/init/command.ts | 114 +++++++++++++++++++++++++++++++-------- src/init/index.ts | 2 +- webdocs/account.mdx | 2 +- webdocs/app.mdx | 2 +- webdocs/build.mdx | 2 +- webdocs/bundle.mdx | 2 +- webdocs/channel.mdx | 2 +- webdocs/doctor.mdx | 2 +- webdocs/init.mdx | 1 + webdocs/key.mdx | 2 +- webdocs/login.mdx | 2 +- webdocs/organisation.mdx | 2 +- webdocs/organization.mdx | 2 +- webdocs/probe.mdx | 2 +- webdocs/run-device.mdx | 29 ++++++++++ webdocs/star-all.mdx | 2 +- webdocs/star.mdx | 2 +- 21 files changed, 178 insertions(+), 36 deletions(-) create mode 100644 webdocs/run-device.mdx diff --git a/README.md b/README.md index b470e2f2..2eb69d07 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Follow the documentation here: https://capacitorjs.com/docs/getting-started/ ## šŸ“‹ Table of Contents - šŸš€ [Init](#init) +- šŸ“± [Run-device](#run-device) - šŸ”¹ [Star](#star) - šŸ”¹ [Star-all](#star-all) - šŸ‘Øā€āš•ļø [Doctor](#doctor) @@ -104,6 +105,29 @@ npx @capgo/cli@latest init YOUR_API_KEY com.example.app | **--supa-anon** | string | Custom Supabase anon key (for self-hosting) | +## šŸ“± **Run-device** + +```bash +npx @capgo/cli@latest run-device +``` + +šŸ“± Test the same Capacitor device target picker used by init onboarding. +For iOS, this asks whether to use a physical iPhone/iPad or simulator, supports checking again for targets, and runs with the resolved target when available. +Use --no-launch to print the resolved command without starting the app. + +**Example:** + +```bash +npx @capgo/cli@latest run-device ios --no-launch +``` + +## Options + +| Param | Type | Description | +| -------------- | ------------- | -------------------- | +| **--no-launch** | boolean | Resolve and print the run command without starting the app | + + ## šŸ”¹ **Star** ```bash diff --git a/skills/usage/SKILL.md b/skills/usage/SKILL.md index cc6bbae3..4ef21cb7 100644 --- a/skills/usage/SKILL.md +++ b/skills/usage/SKILL.md @@ -25,6 +25,7 @@ TanStack Intent skills should stay focused and under the validator line limit, s ### Project setup and diagnostics - `init [apikey] [appId]`: guided first-time setup for Capgo in a Capacitor app. The interactive flow now runs as a real Ink-based fullscreen onboarding so it uses the same UI stack as `build init` (alias: `build onboarding`), with a persistent dashboard, phase roadmap, progress cards, shared log area, and resume support. When dependency auto-detection fails on macOS, the flow opens a native file picker for `package.json` before falling back to manual path entry. If the user reuses a pending app that was already created in the web onboarding flow, the CLI syncs that selected dashboard app ID back into `capacitor.config.*` before the remaining steps continue. Outside that reused pending-app path, the CLI keeps using the local Capacitor app ID. It can also offer a final `npx skills add https://github.com/Cap-go/capgo-skills -g -y` install step before the GitHub support prompt; if accepted, the support menu includes `Cap-go/capgo-skills` alongside the updater-only and all-Capgo choices. If native platforms are missing, the onboarding can offer to run `cap add` for you. During the iOS run-on-device step, onboarding asks whether to use a physical iPhone/iPad or a simulator; for physical devices, it asks the user to connect and unlock the device, then offers a check-again loop before launching with the detected target. If iOS sync validation fails during onboarding, the CLI can offer to run a one-line native reset command, wait for you to type `ready` after a manual fix, surface `doctor`, and save a support bundle before you leave the flow. +- `run-device [platform]`: test the same run-on-device picker used by init onboarding without running the full onboarding. Use `npx @capgo/cli@latest run-device ios --no-launch` to exercise iOS physical/simulator target selection and print the resolved `cap run` command without launching the app. - `login [apikey]`: store an API key locally. - `doctor`: inspect installation health and gather troubleshooting details. - `probe`: test whether the update endpoint would deliver an update. @@ -81,6 +82,7 @@ Load `skills/organization-management/SKILL.md` when working with: ```bash npx @capgo/cli@latest init YOUR_API_KEY com.example.app +npx @capgo/cli@latest run-device ios --no-launch npx @capgo/cli@latest login YOUR_API_KEY npx @capgo/cli@latest doctor npx @capgo/cli@latest probe --platform ios diff --git a/src/docs.ts b/src/docs.ts index e3e91f08..27098466 100644 --- a/src/docs.ts +++ b/src/docs.ts @@ -50,6 +50,8 @@ function getCommandEmoji(cmdName: string): string { emoji = 'šŸ”“' else if (cmdName.includes('debug')) emoji = 'šŸž' + else if (cmdName.includes('run-device')) + emoji = 'šŸ“±' else if (cmdName.includes('doctor')) emoji = 'šŸ‘Øā€āš•ļø' else if (cmdName.includes('login')) diff --git a/src/index.ts b/src/index.ts index c65ab485..818c5a5e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,7 @@ import { setChannel } from './channel/set' import { generateDocs } from './docs' import { defaultStarRepo } from './github' import { starAllRepositoriesCommand, starRepositoryCommand } from './github-command' -import { initApp } from './init' +import { initApp, testRunDeviceCommand } from './init' import { createKey, deleteOldKey, saveKeyCommand } from './key' import { login } from './login' import { startMcpServer } from './mcp/server' @@ -65,6 +65,7 @@ program This includes adding code for updates, building, uploading your app, and verifying update functionality. Capgo bundles are web assets and can be fetched by anyone who knows the URL. Use encryption for banking, regulated, or other high-security apps. +During the iOS run-on-device step, choose a physical iPhone/iPad or simulator. If you choose a physical device, the CLI lets you connect, unlock, and check again before it launches the app. Example: npx @capgo/cli@latest init YOUR_API_KEY com.example.app`) .action(initApp) @@ -73,6 +74,17 @@ Example: npx @capgo/cli@latest init YOUR_API_KEY com.example.app`) .option('--supa-host ', optionDescriptions.supaHost) .option('--supa-anon ', optionDescriptions.supaAnon) +program + .command('run-device [platform]') + .description(`šŸ“± Test the same Capacitor device target picker used by init onboarding. + +For iOS, this asks whether to use a physical iPhone/iPad or simulator, supports checking again for targets, and runs with the resolved target when available. +Use --no-launch to print the resolved command without starting the app. + +Example: npx @capgo/cli@latest run-device ios --no-launch`) + .action(testRunDeviceCommand) + .option('--no-launch', `Resolve and print the run command without starting the app`) + program .command('star [repository]') .description(`⭐ Star a Capgo GitHub repository to support the project. diff --git a/src/init/command.ts b/src/init/command.ts index 7e933c9b..b3d33b82 100644 --- a/src/init/command.ts +++ b/src/init/command.ts @@ -28,12 +28,18 @@ import { showReplicationProgress } from '../replicationProgress' import { formatRunnerCommand, splitRunnerCommand } from '../runner-command' import { createSupabaseClient, findBuildCommandForProjectType, findMainFile, findMainFileForProjectType, findProjectType, findRoot, findSavedKey, formatError, getAllPackagesDependencies, getAppId, getBundleVersion, getConfig, getInstalledVersion, getLocalConfig, getNativeProjectResetAdvice, getPackageScripts, getPMAndCommand, PACKNAME, projectIsMonorepo, updateConfigbyKey, updateConfigUpdater, validateIosUpdaterSync, verifyUser } from '../utils' import { cancel as pCancel, confirm as pConfirm, intro as pIntro, isCancel as pIsCancel, log as pLog, outro as pOutro, select as pSelect, spinner as pSpinner, text as pText } from './prompts' -import { appendInitStreamingLine, clearInitStreamingOutput, setInitCodeDiff, setInitEncryptionSummary, setInitVersionWarning, startInitStreamingOutput, stopInitInkSession, updateInitStreamingStatus } from './runtime' +import { appendInitStreamingLine, clearInitStreamingOutput, setInitCodeDiff, setInitEncryptionSummary, setInitScreen, setInitVersionWarning, startInitStreamingOutput, stopInitInkSession, updateInitStreamingStatus } from './runtime' import { formatInitResumeMessage, initOnboardingSteps, renderInitOnboardingComplete, renderInitOnboardingFrame, renderInitOnboardingWelcome } from './ui' interface SuperOptions extends Options { local: boolean } + +interface RunDeviceTestOptions { + launch?: boolean +} + +type RunDeviceCancelHandler = () => Promise const importInject = 'import { CapacitorUpdater } from \'@capgo/capacitor-updater\'' const codeInject = 'CapacitorUpdater.notifyAppReady()' // create regex to find line who start by 'import ' and end by ' from ' @@ -137,6 +143,11 @@ async function exitCanceledInitOnboarding(orgId: string, apikey: string, message exit(1) } +async function exitCanceledRunDeviceTest(): Promise { + pOutro('Run-device test canceled.') + exit(1) +} + // Render an init-time file path with its project directory prefix so users // can tell which nested project was modified when they run `capgo init` from // a parent folder (e.g. `CLI/src/main.tsx` instead of `src/main.tsx`). @@ -2543,7 +2554,7 @@ function handleSingleSimulatorIosRunTarget(pm: PackageManagerInfo, target: Capac return getRunDeviceCommand(pm, 'ios', target) } -async function handleMultiplePhysicalIosRunTargets(orgId: string, apikey: string, pm: PackageManagerInfo, physicalTargets: CapacitorRunTarget[]): Promise { +async function handleMultiplePhysicalIosRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, physicalTargets: CapacitorRunTarget[]): Promise { const selectedTarget = await pSelect({ message: 'Which physical iOS device should onboarding use?', options: [ @@ -2559,7 +2570,7 @@ async function handleMultiplePhysicalIosRunTargets(orgId: string, apikey: string }) if (pIsCancel(selectedTarget)) - await exitCanceledInitOnboarding(orgId, apikey) + await cancelHandler() if (selectedTarget === iosRunTargetActions.refresh) return iosRunTargetActions.refresh @@ -2576,7 +2587,7 @@ async function handleMultiplePhysicalIosRunTargets(orgId: string, apikey: string return iosRunTargetActions.refresh } -async function handleMissingPhysicalIosRunTargets(orgId: string, apikey: string, pm: PackageManagerInfo): Promise { +async function handleMissingPhysicalIosRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo): Promise { pLog.warn('No physical iOS device detected yet.') const nextAction = await pSelect({ message: 'Connect and unlock your iPhone, then check again.', @@ -2588,7 +2599,7 @@ async function handleMissingPhysicalIosRunTargets(orgId: string, apikey: string, }) if (pIsCancel(nextAction)) - await exitCanceledInitOnboarding(orgId, apikey) + await cancelHandler() if (nextAction === iosRunTargetActions.refresh) return iosRunTargetActions.refresh @@ -2597,7 +2608,7 @@ async function handleMissingPhysicalIosRunTargets(orgId: string, apikey: string, return getSkippedRunDeviceCommand(pm, 'ios') } -async function handleMultipleSimulatorIosRunTargets(orgId: string, apikey: string, pm: PackageManagerInfo, simulatorTargets: CapacitorRunTarget[]): Promise { +async function handleMultipleSimulatorIosRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, simulatorTargets: CapacitorRunTarget[]): Promise { const selectedTarget = await pSelect({ message: 'Which iOS Simulator should onboarding use?', options: [ @@ -2612,7 +2623,7 @@ async function handleMultipleSimulatorIosRunTargets(orgId: string, apikey: strin }) if (pIsCancel(selectedTarget)) - await exitCanceledInitOnboarding(orgId, apikey) + await cancelHandler() if (selectedTarget === iosRunTargetActions.refresh) return iosRunTargetActions.refresh @@ -2627,7 +2638,7 @@ async function handleMultipleSimulatorIosRunTargets(orgId: string, apikey: strin return iosRunTargetActions.refresh } -async function handleMissingSimulatorIosRunTargets(orgId: string, apikey: string, pm: PackageManagerInfo): Promise { +async function handleMissingSimulatorIosRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo): Promise { pLog.warn('No iOS Simulator target detected yet.') const nextAction = await pSelect({ message: 'Open Xcode or install a simulator, then check again.', @@ -2638,14 +2649,14 @@ async function handleMissingSimulatorIosRunTargets(orgId: string, apikey: string }) if (pIsCancel(nextAction)) - await exitCanceledInitOnboarding(orgId, apikey) + await cancelHandler() if (nextAction === iosRunTargetActions.refresh) return iosRunTargetActions.refresh return getSkippedRunDeviceCommand(pm, 'ios') } -async function selectSimulatorIosRunTarget(orgId: string, apikey: string, pm: PackageManagerInfo, initialTargets?: CapacitorRunTarget[]): Promise { +async function selectSimulatorIosRunTarget(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, initialTargets?: CapacitorRunTarget[]): Promise { let knownTargets = initialTargets while (true) { @@ -2663,15 +2674,15 @@ async function selectSimulatorIosRunTarget(orgId: string, apikey: string, pm: Pa return handleSingleSimulatorIosRunTarget(pm, simulatorTargets[0]) const selectionResult = simulatorTargets.length > 1 - ? await handleMultipleSimulatorIosRunTargets(orgId, apikey, pm, simulatorTargets) - : await handleMissingSimulatorIosRunTargets(orgId, apikey, pm) + ? await handleMultipleSimulatorIosRunTargets(cancelHandler, pm, simulatorTargets) + : await handleMissingSimulatorIosRunTargets(cancelHandler, pm) if (selectionResult === iosRunTargetActions.refresh) continue return selectionResult } } -async function selectPhysicalIosRunTarget(orgId: string, apikey: string, pm: PackageManagerInfo): Promise { +async function selectPhysicalIosRunTarget(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo): Promise { pLog.info('Connect your iPhone or iPad, unlock it, and tap Trust if prompted.') while (true) { @@ -2685,17 +2696,17 @@ async function selectPhysicalIosRunTarget(orgId: string, apikey: string, pm: Pac return handleSinglePhysicalIosRunTarget(pm, physicalTargets[0]) const selectionResult = physicalTargets.length > 1 - ? await handleMultiplePhysicalIosRunTargets(orgId, apikey, pm, physicalTargets) - : await handleMissingPhysicalIosRunTargets(orgId, apikey, pm) + ? await handleMultiplePhysicalIosRunTargets(cancelHandler, pm, physicalTargets) + : await handleMissingPhysicalIosRunTargets(cancelHandler, pm) if (selectionResult === iosRunTargetActions.refresh) continue if (selectionResult === iosRunTargetActions.simulator) - return selectSimulatorIosRunTarget(orgId, apikey, pm, result.targets) + return selectSimulatorIosRunTarget(cancelHandler, pm, result.targets) return selectionResult } } -async function resolveRunDeviceCommand(orgId: string, apikey: string, pm: PackageManagerInfo, platformName: PlatformChoice): Promise { +async function resolveRunDeviceCommand(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, platformName: PlatformChoice): Promise { if (platformName !== 'ios') return getRunDeviceCommand(pm, platformName) @@ -2708,12 +2719,12 @@ async function resolveRunDeviceCommand(orgId: string, apikey: string, pm: Packag }) if (pIsCancel(targetKind)) - await exitCanceledInitOnboarding(orgId, apikey) + await cancelHandler() if (targetKind === 'simulator') - return selectSimulatorIosRunTarget(orgId, apikey, pm) + return selectSimulatorIosRunTarget(cancelHandler, pm) - return selectPhysicalIosRunTarget(orgId, apikey, pm) + return selectPhysicalIosRunTarget(cancelHandler, pm) } function getSelectablePlatformOptions(config?: CapacitorConfigSnapshot): Array<{ value: PlatformChoice, label: string }> { @@ -2767,6 +2778,67 @@ async function handleMissingPlatformSelection(orgId: string, apikey: string, ava pLog.warn(`Still could not add ${platformToAdd}.`) } +function normalizeRunDevicePlatform(platformName: string | undefined): PlatformChoice { + const normalized = (platformName || 'ios').toLowerCase() + if (normalized === 'ios' || normalized === 'android') + return normalized + throw new Error('Platform must be "ios" or "android".') +} + +export async function testRunDeviceCommand(platformName?: string, options: RunDeviceTestOptions = {}) { + try { + const pm = getPMAndCommand() + const platformNameChoice = normalizeRunDevicePlatform(platformName) + + pIntro('Run device test') + setInitScreen({ + title: 'Run Device Test', + introLines: [ + 'This uses the same device target picker as init onboarding.', + platformNameChoice === 'ios' + ? 'For iOS, choose a physical device or simulator, then refresh target discovery if needed.' + : 'For Android, this runs Capacitor directly.', + ], + phaseLabel: 'Device target', + statusLine: `Platform: ${platformNameChoice.toUpperCase()}`, + tone: 'blue', + }) + + const runCommand = await resolveRunDeviceCommand(exitCanceledRunDeviceTest, pm, platformNameChoice) + if (!runCommand.args) { + pOutro(`Skipped device launch. Manual command: ${runCommand.command}`) + return + } + + if (options.launch === false) { + pOutro(`Resolved run command: ${runCommand.command}`) + return + } + + const s = pSpinner() + s.start(`Running: ${runCommand.command}`) + + const runResult = runPackageRunnerSync(pm.runner, runCommand.args, { stdio: 'inherit' }) + const runFailed = runResult.error || runResult.status !== 0 + + if (runFailed) { + s.stop('App failed to start āŒ') + if (runResult.error) + pLog.error(formatError(runResult.error)) + pLog.info(`You can run the command manually with: ${runCommand.command}`) + pCancel('Run-device test failed.') + exit(1) + } + + s.stop('App started āœ…') + pOutro(`Run-device test finished. Manual command: ${runCommand.command}`) + } + catch (error) { + pCancel(`Run-device test failed: ${formatError(error)}`) + exit(1) + } +} + async function selectPlatformStep(orgId: string, apikey: string, config?: CapacitorConfigSnapshot): Promise<'ios' | 'android'> { pLog.info(`šŸ“± Platform selection for onboarding`) pLog.info(` This is just for testing during onboarding - your app will work on all platforms`) @@ -2785,7 +2857,7 @@ async function runDeviceStep(orgId: string, apikey: string, appId: string, platf const doRun = await pConfirm({ message: `Run ${appId} on ${platform.toUpperCase()} device now to test the initial version?` }) await cancelCommand(doRun, orgId, apikey) if (doRun) { - const runCommand = await resolveRunDeviceCommand(orgId, apikey, pm, platform) + const runCommand = await resolveRunDeviceCommand(() => exitCanceledInitOnboarding(orgId, apikey), pm, platform) if (!runCommand.args) { pLog.info(`Skipped device launch. You can run it manually with: ${runCommand.command}`) await markStep(orgId, apikey, 'run-device', appId) diff --git a/src/init/index.ts b/src/init/index.ts index 48bc2b23..6ba7ebbf 100644 --- a/src/init/index.ts +++ b/src/init/index.ts @@ -1 +1 @@ -export { initApp } from './command' +export { initApp, testRunDeviceCommand } from './command' diff --git a/webdocs/account.mdx b/webdocs/account.mdx index 1dd280a4..a42cb126 100644 --- a/webdocs/account.mdx +++ b/webdocs/account.mdx @@ -3,7 +3,7 @@ title: šŸ‘¤ account description: "šŸ‘¤ Manage your Capgo account details and retrieve information for support or collaboration." sidebar_label: account sidebar: - order: 10 + order: 11 --- šŸ‘¤ Manage your Capgo account details and retrieve information for support or collaboration. diff --git a/webdocs/app.mdx b/webdocs/app.mdx index d4564974..509a234f 100644 --- a/webdocs/app.mdx +++ b/webdocs/app.mdx @@ -3,7 +3,7 @@ title: šŸ“± app description: "šŸ“± Manage your Capgo app settings and configurations in Capgo Cloud." sidebar_label: app sidebar: - order: 7 + order: 8 --- šŸ“± Manage your Capgo app settings and configurations in Capgo Cloud. diff --git a/webdocs/build.mdx b/webdocs/build.mdx index 4a95a951..1303b643 100644 --- a/webdocs/build.mdx +++ b/webdocs/build.mdx @@ -3,7 +3,7 @@ title: šŸ”¹ build description: "šŸ—ļø Manage native iOS/Android builds through Capgo Cloud." sidebar_label: build sidebar: - order: 13 + order: 14 --- šŸ—ļø Manage native iOS/Android builds through Capgo Cloud. diff --git a/webdocs/bundle.mdx b/webdocs/bundle.mdx index e6b04ed2..396434cd 100644 --- a/webdocs/bundle.mdx +++ b/webdocs/bundle.mdx @@ -3,7 +3,7 @@ title: šŸ“¦ bundle description: "šŸ“¦ Manage app bundles for deployment in Capgo Cloud, including upload, compatibility checks, and encryption." sidebar_label: bundle sidebar: - order: 6 + order: 7 --- šŸ“¦ Manage app bundles for deployment in Capgo Cloud, including upload, compatibility checks, and encryption. diff --git a/webdocs/channel.mdx b/webdocs/channel.mdx index 2c99bcd0..b462b231 100644 --- a/webdocs/channel.mdx +++ b/webdocs/channel.mdx @@ -3,7 +3,7 @@ title: šŸ“¢ channel description: "šŸ“¢ Manage distribution channels for app updates in Capgo Cloud, controlling how updates are delivered to devices." sidebar_label: channel sidebar: - order: 8 + order: 9 --- šŸ“¢ Manage distribution channels for app updates in Capgo Cloud, controlling how updates are delivered to devices. diff --git a/webdocs/doctor.mdx b/webdocs/doctor.mdx index 906a45b2..d7d60179 100644 --- a/webdocs/doctor.mdx +++ b/webdocs/doctor.mdx @@ -3,7 +3,7 @@ title: šŸ‘Øā€āš•ļø doctor description: "šŸ‘Øā€āš•ļø Check if your Capgo app installation is up-to-date and gather information useful for bug reports." sidebar_label: doctor sidebar: - order: 4 + order: 5 --- šŸ‘Øā€āš•ļø Check if your Capgo app installation is up-to-date and gather information useful for bug reports. diff --git a/webdocs/init.mdx b/webdocs/init.mdx index 2d30acf2..efff76c0 100644 --- a/webdocs/init.mdx +++ b/webdocs/init.mdx @@ -32,3 +32,4 @@ npx @capgo/cli@latest init YOUR_API_KEY com.example.app | **-i,** | string | App icon path for display in Capgo Cloud | | **--supa-host** | string | Custom Supabase host URL (for self-hosting or Capgo development) | | **--supa-anon** | string | Custom Supabase anon key (for self-hosting) | + diff --git a/webdocs/key.mdx b/webdocs/key.mdx index 3e73753d..cc4af79e 100644 --- a/webdocs/key.mdx +++ b/webdocs/key.mdx @@ -3,7 +3,7 @@ title: šŸ” key description: "šŸ” Manage encryption keys for secure bundle distribution in Capgo Cloud, supporting end-to-end encryption with RSA and AES combination." sidebar_label: key sidebar: - order: 9 + order: 10 --- šŸ” Manage encryption keys for secure bundle distribution in Capgo Cloud, supporting end-to-end encryption with RSA and AES combination. diff --git a/webdocs/login.mdx b/webdocs/login.mdx index 81e16df8..3f8acd54 100644 --- a/webdocs/login.mdx +++ b/webdocs/login.mdx @@ -3,7 +3,7 @@ title: šŸ”‘ login description: "šŸ”‘ Save your Capgo API key to your machine or local folder for easier access to Capgo Cloud services." sidebar_label: login sidebar: - order: 5 + order: 6 --- šŸ”‘ Save your Capgo API key to your machine or local folder for easier access to Capgo Cloud services. diff --git a/webdocs/organisation.mdx b/webdocs/organisation.mdx index 163a5cd9..967bb177 100644 --- a/webdocs/organisation.mdx +++ b/webdocs/organisation.mdx @@ -3,7 +3,7 @@ title: šŸ”¹ organisation description: "[DEPRECATED] Use \"organization\" instead. This command will be removed in a future version." sidebar_label: organisation sidebar: - order: 12 + order: 13 --- [DEPRECATED] Use "organization" instead. This command will be removed in a future version. diff --git a/webdocs/organization.mdx b/webdocs/organization.mdx index 4876e2d1..0c820eae 100644 --- a/webdocs/organization.mdx +++ b/webdocs/organization.mdx @@ -3,7 +3,7 @@ title: šŸ”¹ organization description: "šŸ¢ Manage your organizations in Capgo Cloud for team collaboration and app management." sidebar_label: organization sidebar: - order: 11 + order: 12 --- šŸ¢ Manage your organizations in Capgo Cloud for team collaboration and app management. diff --git a/webdocs/probe.mdx b/webdocs/probe.mdx index b044d104..50f7f67a 100644 --- a/webdocs/probe.mdx +++ b/webdocs/probe.mdx @@ -3,7 +3,7 @@ title: šŸ”¹ probe description: "šŸ”Ž Probe the Capgo updates endpoint to check if an update is available for your app." sidebar_label: probe sidebar: - order: 14 + order: 15 --- šŸ”Ž Probe the Capgo updates endpoint to check if an update is available for your app. diff --git a/webdocs/run-device.mdx b/webdocs/run-device.mdx new file mode 100644 index 00000000..aa88f054 --- /dev/null +++ b/webdocs/run-device.mdx @@ -0,0 +1,29 @@ +--- +title: šŸ“± run-device +description: "šŸ“± Test the same Capacitor device target picker used by init onboarding." +sidebar_label: run-device +sidebar: + order: 2 +--- + +šŸ“± Test the same Capacitor device target picker used by init onboarding. + +```bash +npx @capgo/cli@latest run-device +``` + +For iOS, this asks whether to use a physical iPhone/iPad or simulator, supports checking again for targets, and runs with the resolved target when available. +Use --no-launch to print the resolved command without starting the app. + +**Example:** + +```bash +npx @capgo/cli@latest run-device ios --no-launch +``` + +## Options + +| Param | Type | Description | +| -------------- | ------------- | -------------------- | +| **--no-launch** | boolean | Resolve and print the run command without starting the app | + diff --git a/webdocs/star-all.mdx b/webdocs/star-all.mdx index 5168ecfc..d06baccf 100644 --- a/webdocs/star-all.mdx +++ b/webdocs/star-all.mdx @@ -3,7 +3,7 @@ title: šŸ”¹ star-all description: "⭐ Star all Capgo GitHub repositories with a small random delay between each request." sidebar_label: star-all sidebar: - order: 3 + order: 4 --- ⭐ Star all Capgo GitHub repositories with a small random delay between each request. diff --git a/webdocs/star.mdx b/webdocs/star.mdx index 0d3978da..743f54d6 100644 --- a/webdocs/star.mdx +++ b/webdocs/star.mdx @@ -3,7 +3,7 @@ title: šŸ”¹ star description: "⭐ Star a Capgo GitHub repository to support the project." sidebar_label: star sidebar: - order: 2 + order: 3 --- ⭐ Star a Capgo GitHub repository to support the project. From 59576ec05fb8c13c44821e8e6d212c67b28e3bc4 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 28 Apr 2026 13:25:25 +0200 Subject: [PATCH 06/15] refactor(init): split run-device command handler --- README.md | 12 +++---- src/docs.ts | 6 ++-- src/index.ts | 3 +- src/init/command.ts | 77 ++++------------------------------------- src/init/index.ts | 2 +- src/run-device.ts | 84 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 104 insertions(+), 80 deletions(-) create mode 100644 src/run-device.ts diff --git a/README.md b/README.md index 2eb69d07..f4798f0f 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ During the iOS run-on-device step, choose a physical iPhone/iPad or simulator. I npx @capgo/cli@latest init YOUR_API_KEY com.example.app ``` -## Options +### Options | Param | Type | Description | | -------------- | ------------- | -------------------- | @@ -121,7 +121,7 @@ Use --no-launch to print the resolved command without starting the app. npx @capgo/cli@latest run-device ios --no-launch ``` -## Options +### Options | Param | Type | Description | | -------------- | ------------- | -------------------- | @@ -147,7 +147,7 @@ npx @capgo/cli@latest star-all ⭐ Star all Capgo GitHub repositories with a small random delay between each request. If you do not pass repositories, this defaults to all Cap-go repositories whose name starts with `capacitor-`. -## Options +### Options | Param | Type | Description | | -------------- | ------------- | -------------------- | @@ -171,7 +171,7 @@ This command helps diagnose issues with your setup. npx @capgo/cli@latest doctor ``` -## Options +### Options | Param | Type | Description | | -------------- | ------------- | -------------------- | @@ -195,7 +195,7 @@ Use --apikey=******** in any command to override it. npx @capgo/cli@latest login YOUR_API_KEY ``` -## Options +### Options | Param | Type | Description | | -------------- | ------------- | -------------------- | @@ -1248,7 +1248,7 @@ and reports whether an update would be delivered, or explains why not. npx @capgo/cli@latest probe --platform ios ``` -## Options +### Options | Param | Type | Description | | -------------- | ------------- | -------------------- | diff --git a/src/docs.ts b/src/docs.ts index 27098466..ac275aca 100644 --- a/src/docs.ts +++ b/src/docs.ts @@ -197,8 +197,10 @@ export function generateDocs(filePath: string = './README.md', folderPath?: stri // Options table - for all commands (even command groups may have global options) if (cmd.options.length > 0) { if (!isSubcommand) { - // Only add the Options title for the main command - section += `## Options\n\n` + const optionsAnchor = skipMainHeading ? 'options' : `${cmdName}-options` + const optionsHeading = skipMainHeading ? '##' : '###' + // In README each command is already a top-level section, so options sit underneath it. + section += `${optionsHeading} Options\n\n` } else { section += `**Options:**\n\n` diff --git a/src/index.ts b/src/index.ts index 818c5a5e..26885057 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,12 +29,13 @@ import { setChannel } from './channel/set' import { generateDocs } from './docs' import { defaultStarRepo } from './github' import { starAllRepositoriesCommand, starRepositoryCommand } from './github-command' -import { initApp, testRunDeviceCommand } from './init' +import { initApp } from './init' import { createKey, deleteOldKey, saveKeyCommand } from './key' import { login } from './login' import { startMcpServer } from './mcp/server' import { addOrganization, deleteOrganization, listMembers, listOrganizations, setOrganization } from './organization' import { probe } from './probe' +import { testRunDeviceCommand } from './run-device' import { getUserId } from './user/account' import { formatError } from './utils' diff --git a/src/init/command.ts b/src/init/command.ts index b3d33b82..e034101e 100644 --- a/src/init/command.ts +++ b/src/init/command.ts @@ -28,18 +28,14 @@ import { showReplicationProgress } from '../replicationProgress' import { formatRunnerCommand, splitRunnerCommand } from '../runner-command' import { createSupabaseClient, findBuildCommandForProjectType, findMainFile, findMainFileForProjectType, findProjectType, findRoot, findSavedKey, formatError, getAllPackagesDependencies, getAppId, getBundleVersion, getConfig, getInstalledVersion, getLocalConfig, getNativeProjectResetAdvice, getPackageScripts, getPMAndCommand, PACKNAME, projectIsMonorepo, updateConfigbyKey, updateConfigUpdater, validateIosUpdaterSync, verifyUser } from '../utils' import { cancel as pCancel, confirm as pConfirm, intro as pIntro, isCancel as pIsCancel, log as pLog, outro as pOutro, select as pSelect, spinner as pSpinner, text as pText } from './prompts' -import { appendInitStreamingLine, clearInitStreamingOutput, setInitCodeDiff, setInitEncryptionSummary, setInitScreen, setInitVersionWarning, startInitStreamingOutput, stopInitInkSession, updateInitStreamingStatus } from './runtime' +import { appendInitStreamingLine, clearInitStreamingOutput, setInitCodeDiff, setInitEncryptionSummary, setInitVersionWarning, startInitStreamingOutput, stopInitInkSession, updateInitStreamingStatus } from './runtime' import { formatInitResumeMessage, initOnboardingSteps, renderInitOnboardingComplete, renderInitOnboardingFrame, renderInitOnboardingWelcome } from './ui' interface SuperOptions extends Options { local: boolean } -interface RunDeviceTestOptions { - launch?: boolean -} - -type RunDeviceCancelHandler = () => Promise +export type RunDeviceCancelHandler = () => Promise const importInject = 'import { CapacitorUpdater } from \'@capgo/capacitor-updater\'' const codeInject = 'CapacitorUpdater.notifyAppReady()' // create regex to find line who start by 'import ' and end by ' from ' @@ -143,11 +139,6 @@ async function exitCanceledInitOnboarding(orgId: string, apikey: string, message exit(1) } -async function exitCanceledRunDeviceTest(): Promise { - pOutro('Run-device test canceled.') - exit(1) -} - // Render an init-time file path with its project directory prefix so users // can tell which nested project was modified when they run `capgo init` from // a parent folder (e.g. `CLI/src/main.tsx` instead of `src/main.tsx`). @@ -2322,9 +2313,9 @@ function promoteEncryptionSummaryToEnabled(): void { } type PackageManagerInfo = ReturnType -type PlatformChoice = 'ios' | 'android' +export type PlatformChoice = 'ios' | 'android' type BuildProjectStepOutcome = 'completed' | 'skipped' -type RunDeviceStepOutcome = { args: string[], command: string } | { args: undefined, command: string } +export type RunDeviceStepOutcome = { args: string[], command: string } | { args: undefined, command: string } export interface CapacitorRunTarget { name: string @@ -2459,7 +2450,7 @@ async function buildProjectStep(orgId: string, apikey: string, appId: string, pl await markStep(orgId, apikey, 'build-project', appId) } -function runPackageRunnerSync(runner: string, args: string[], options: Parameters[2]) { +export function runPackageRunnerSync(runner: string, args: string[], options: Parameters[2]) { const parsedRunner = splitRunnerCommand(runner) return spawnSync(parsedRunner.command, [...parsedRunner.args, ...args], options) } @@ -2706,7 +2697,7 @@ async function selectPhysicalIosRunTarget(cancelHandler: RunDeviceCancelHandler, } } -async function resolveRunDeviceCommand(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, platformName: PlatformChoice): Promise { +export async function resolveRunDeviceCommand(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, platformName: PlatformChoice): Promise { if (platformName !== 'ios') return getRunDeviceCommand(pm, platformName) @@ -2778,67 +2769,13 @@ async function handleMissingPlatformSelection(orgId: string, apikey: string, ava pLog.warn(`Still could not add ${platformToAdd}.`) } -function normalizeRunDevicePlatform(platformName: string | undefined): PlatformChoice { +export function normalizeRunDevicePlatform(platformName: string | undefined): PlatformChoice { const normalized = (platformName || 'ios').toLowerCase() if (normalized === 'ios' || normalized === 'android') return normalized throw new Error('Platform must be "ios" or "android".') } -export async function testRunDeviceCommand(platformName?: string, options: RunDeviceTestOptions = {}) { - try { - const pm = getPMAndCommand() - const platformNameChoice = normalizeRunDevicePlatform(platformName) - - pIntro('Run device test') - setInitScreen({ - title: 'Run Device Test', - introLines: [ - 'This uses the same device target picker as init onboarding.', - platformNameChoice === 'ios' - ? 'For iOS, choose a physical device or simulator, then refresh target discovery if needed.' - : 'For Android, this runs Capacitor directly.', - ], - phaseLabel: 'Device target', - statusLine: `Platform: ${platformNameChoice.toUpperCase()}`, - tone: 'blue', - }) - - const runCommand = await resolveRunDeviceCommand(exitCanceledRunDeviceTest, pm, platformNameChoice) - if (!runCommand.args) { - pOutro(`Skipped device launch. Manual command: ${runCommand.command}`) - return - } - - if (options.launch === false) { - pOutro(`Resolved run command: ${runCommand.command}`) - return - } - - const s = pSpinner() - s.start(`Running: ${runCommand.command}`) - - const runResult = runPackageRunnerSync(pm.runner, runCommand.args, { stdio: 'inherit' }) - const runFailed = runResult.error || runResult.status !== 0 - - if (runFailed) { - s.stop('App failed to start āŒ') - if (runResult.error) - pLog.error(formatError(runResult.error)) - pLog.info(`You can run the command manually with: ${runCommand.command}`) - pCancel('Run-device test failed.') - exit(1) - } - - s.stop('App started āœ…') - pOutro(`Run-device test finished. Manual command: ${runCommand.command}`) - } - catch (error) { - pCancel(`Run-device test failed: ${formatError(error)}`) - exit(1) - } -} - async function selectPlatformStep(orgId: string, apikey: string, config?: CapacitorConfigSnapshot): Promise<'ios' | 'android'> { pLog.info(`šŸ“± Platform selection for onboarding`) pLog.info(` This is just for testing during onboarding - your app will work on all platforms`) diff --git a/src/init/index.ts b/src/init/index.ts index 6ba7ebbf..48bc2b23 100644 --- a/src/init/index.ts +++ b/src/init/index.ts @@ -1 +1 @@ -export { initApp, testRunDeviceCommand } from './command' +export { initApp } from './command' diff --git a/src/run-device.ts b/src/run-device.ts new file mode 100644 index 00000000..eb753c88 --- /dev/null +++ b/src/run-device.ts @@ -0,0 +1,84 @@ +import { exit, stdin, stdout } from 'node:process' +import { normalizeRunDevicePlatform, resolveRunDeviceCommand, runPackageRunnerSync } from './init/command' +import { cancel as pCancel, intro as pIntro, log as pLog, outro as pOutro, spinner as pSpinner } from './init/prompts' +import { setInitScreen } from './init/runtime' +import { formatRunnerCommand } from './runner-command' +import { formatError, getPMAndCommand } from './utils' + +interface RunDeviceTestOptions { + launch?: boolean +} + +async function exitCanceledRunDeviceTest(): Promise { + pOutro('Run-device test canceled.') + exit(1) +} + +function canSelectRunDeviceTargetInteractively(): boolean { + return !!stdin.isTTY && !!stdout.isTTY +} + +function handleNonInteractiveIosRunDevice(pm: ReturnType): never { + pLog.info('Non-interactive mode: iOS target selection needs an interactive terminal.') + pLog.info(`List targets with: ${formatRunnerCommand(pm.runner, ['cap', 'run', 'ios', '--list'])}`) + pLog.info(`Run a concrete target with: ${formatRunnerCommand(pm.runner, ['cap', 'run', 'ios', '--target', ''])}`) + pCancel('Run-device test failed.') + exit(1) +} + +export async function testRunDeviceCommand(platformName?: string, options: RunDeviceTestOptions = {}) { + try { + const pm = getPMAndCommand() + const platformNameChoice = normalizeRunDevicePlatform(platformName) + + pIntro('Run device test') + setInitScreen({ + title: 'Run Device Test', + introLines: [ + 'This uses the same device target picker as init onboarding.', + platformNameChoice === 'ios' + ? 'For iOS, choose a physical device or simulator, then refresh target discovery if needed.' + : 'For Android, this runs Capacitor directly.', + ], + phaseLabel: 'Device target', + statusLine: `Platform: ${platformNameChoice.toUpperCase()}`, + tone: 'blue', + }) + + if (platformNameChoice === 'ios' && !canSelectRunDeviceTargetInteractively()) + handleNonInteractiveIosRunDevice(pm) + + const runCommand = await resolveRunDeviceCommand(exitCanceledRunDeviceTest, pm, platformNameChoice) + if (!runCommand.args) { + pOutro(`Skipped device launch. Manual command: ${runCommand.command}`) + return + } + + if (options.launch === false) { + pOutro(`Resolved run command: ${runCommand.command}`) + return + } + + const s = pSpinner() + s.start(`Running: ${runCommand.command}`) + + const runResult = runPackageRunnerSync(pm.runner, runCommand.args, { stdio: 'inherit' }) + const runFailed = runResult.error || runResult.status !== 0 + + if (runFailed) { + s.stop('App failed to start āŒ') + if (runResult.error) + pLog.error(formatError(runResult.error)) + pLog.info(`You can run the command manually with: ${runCommand.command}`) + pCancel('Run-device test failed.') + exit(1) + } + + s.stop('App started āœ…') + pOutro(`Run-device test finished. Manual command: ${runCommand.command}`) + } + catch (error) { + pCancel(`Run-device test failed: ${formatError(error)}`) + exit(1) + } +} From 2f604b1cf6e9b83417bfcf5bc708febc03c664df Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 28 Apr 2026 13:32:54 +0200 Subject: [PATCH 07/15] fix(run-device): support non-interactive android resolution --- src/run-device.ts | 103 +++++++++++++++++++++++++++++++++------------- 1 file changed, 75 insertions(+), 28 deletions(-) diff --git a/src/run-device.ts b/src/run-device.ts index eb753c88..52f82bfe 100644 --- a/src/run-device.ts +++ b/src/run-device.ts @@ -1,4 +1,5 @@ import { exit, stdin, stdout } from 'node:process' +import { log as clackLog } from '@clack/prompts' import { normalizeRunDevicePlatform, resolveRunDeviceCommand, runPackageRunnerSync } from './init/command' import { cancel as pCancel, intro as pIntro, log as pLog, outro as pOutro, spinner as pSpinner } from './init/prompts' import { setInitScreen } from './init/runtime' @@ -19,18 +20,83 @@ function canSelectRunDeviceTargetInteractively(): boolean { } function handleNonInteractiveIosRunDevice(pm: ReturnType): never { - pLog.info('Non-interactive mode: iOS target selection needs an interactive terminal.') - pLog.info(`List targets with: ${formatRunnerCommand(pm.runner, ['cap', 'run', 'ios', '--list'])}`) - pLog.info(`Run a concrete target with: ${formatRunnerCommand(pm.runner, ['cap', 'run', 'ios', '--target', ''])}`) - pCancel('Run-device test failed.') + clackLog.info('Non-interactive mode: iOS target selection needs an interactive terminal.') + clackLog.info(`List targets with: ${formatRunnerCommand(pm.runner, ['cap', 'run', 'ios', '--list'])}`) + clackLog.info(`Run a concrete target with: ${formatRunnerCommand(pm.runner, ['cap', 'run', 'ios', '--target', ''])}`) + clackLog.error('Run-device test failed.') exit(1) } +function failRunDeviceTest(message: string, interactive: boolean): never { + if (interactive) + pCancel(message) + else + clackLog.error(message) + exit(1) +} + +function finishRunDeviceTest(message: string, interactive: boolean): void { + if (interactive) + pOutro(message) + else + clackLog.info(message) +} + +function runResolvedDeviceCommand(pm: ReturnType, runCommand: { args: string[], command: string }, interactive: boolean): void { + if (interactive) { + const s = pSpinner() + s.start(`Running: ${runCommand.command}`) + + const runResult = runPackageRunnerSync(pm.runner, runCommand.args, { stdio: 'inherit' }) + const runFailed = runResult.error || runResult.status !== 0 + + if (runFailed) { + s.stop('App failed to start āŒ') + if (runResult.error) + pLog.error(formatError(runResult.error)) + pLog.info(`You can run the command manually with: ${runCommand.command}`) + failRunDeviceTest('Run-device test failed.', interactive) + } + + s.stop('App started āœ…') + return + } + + clackLog.info(`Running: ${runCommand.command}`) + const runResult = runPackageRunnerSync(pm.runner, runCommand.args, { stdio: 'inherit' }) + const runFailed = runResult.error || runResult.status !== 0 + + if (runFailed) { + if (runResult.error) + clackLog.error(formatError(runResult.error)) + clackLog.info(`You can run the command manually with: ${runCommand.command}`) + failRunDeviceTest('Run-device test failed.', interactive) + } + + clackLog.info('App started') +} + export async function testRunDeviceCommand(platformName?: string, options: RunDeviceTestOptions = {}) { + const interactive = canSelectRunDeviceTargetInteractively() try { const pm = getPMAndCommand() const platformNameChoice = normalizeRunDevicePlatform(platformName) + if (platformNameChoice === 'ios' && !interactive) + handleNonInteractiveIosRunDevice(pm) + + if (!interactive) { + const runCommand = await resolveRunDeviceCommand(exitCanceledRunDeviceTest, pm, platformNameChoice) + if (!runCommand.args || options.launch === false) { + finishRunDeviceTest(`Resolved run command: ${runCommand.command}`, interactive) + return + } + + runResolvedDeviceCommand(pm, runCommand, interactive) + finishRunDeviceTest(`Run-device test finished. Manual command: ${runCommand.command}`, interactive) + return + } + pIntro('Run device test') setInitScreen({ title: 'Run Device Test', @@ -45,40 +111,21 @@ export async function testRunDeviceCommand(platformName?: string, options: RunDe tone: 'blue', }) - if (platformNameChoice === 'ios' && !canSelectRunDeviceTargetInteractively()) - handleNonInteractiveIosRunDevice(pm) - const runCommand = await resolveRunDeviceCommand(exitCanceledRunDeviceTest, pm, platformNameChoice) if (!runCommand.args) { - pOutro(`Skipped device launch. Manual command: ${runCommand.command}`) + finishRunDeviceTest(`Skipped device launch. Manual command: ${runCommand.command}`, interactive) return } if (options.launch === false) { - pOutro(`Resolved run command: ${runCommand.command}`) + finishRunDeviceTest(`Resolved run command: ${runCommand.command}`, interactive) return } - const s = pSpinner() - s.start(`Running: ${runCommand.command}`) - - const runResult = runPackageRunnerSync(pm.runner, runCommand.args, { stdio: 'inherit' }) - const runFailed = runResult.error || runResult.status !== 0 - - if (runFailed) { - s.stop('App failed to start āŒ') - if (runResult.error) - pLog.error(formatError(runResult.error)) - pLog.info(`You can run the command manually with: ${runCommand.command}`) - pCancel('Run-device test failed.') - exit(1) - } - - s.stop('App started āœ…') - pOutro(`Run-device test finished. Manual command: ${runCommand.command}`) + runResolvedDeviceCommand(pm, runCommand, interactive) + finishRunDeviceTest(`Run-device test finished. Manual command: ${runCommand.command}`, interactive) } catch (error) { - pCancel(`Run-device test failed: ${formatError(error)}`) - exit(1) + failRunDeviceTest(`Run-device test failed: ${formatError(error)}`, interactive) } } From cb163217b19b11edbd0557ff8020cc533da6c296 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 28 Apr 2026 16:38:46 +0200 Subject: [PATCH 08/15] fix(run): use grouped device command --- README.md | 15 ++++++++++----- skills/usage/SKILL.md | 4 ++-- src/docs.ts | 2 +- src/index.ts | 12 ++++++++---- src/init/runtime.tsx | 3 ++- src/init/ui/app.tsx | 4 ++-- src/init/ui/components.tsx | 4 ++-- src/{run-device.ts => run/device.ts} | 26 +++++++++++++------------- webdocs/{run-device.mdx => run.mdx} | 17 ++++++++++------- 9 files changed, 50 insertions(+), 37 deletions(-) rename src/{run-device.ts => run/device.ts} (84%) rename webdocs/{run-device.mdx => run.mdx} (66%) diff --git a/README.md b/README.md index f4798f0f..269cefd6 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ Follow the documentation here: https://capacitorjs.com/docs/getting-started/ ## šŸ“‹ Table of Contents - šŸš€ [Init](#init) -- šŸ“± [Run-device](#run-device) +- šŸ“± [Run](#run) + - [Device](#run-device) - šŸ”¹ [Star](#star) - šŸ”¹ [Star-all](#star-all) - šŸ‘Øā€āš•ļø [Doctor](#doctor) @@ -105,10 +106,14 @@ npx @capgo/cli@latest init YOUR_API_KEY com.example.app | **--supa-anon** | string | Custom Supabase anon key (for self-hosting) | -## šŸ“± **Run-device** +## šŸ“± **Run** + +šŸ“± Run and test Capacitor app targets from the CLI. + +### šŸ”¹ **Device** ```bash -npx @capgo/cli@latest run-device +npx @capgo/cli@latest run device ``` šŸ“± Test the same Capacitor device target picker used by init onboarding. @@ -118,10 +123,10 @@ Use --no-launch to print the resolved command without starting the app. **Example:** ```bash -npx @capgo/cli@latest run-device ios --no-launch +npx @capgo/cli@latest run device ios --no-launch ``` -### Options +**Options:** | Param | Type | Description | | -------------- | ------------- | -------------------- | diff --git a/skills/usage/SKILL.md b/skills/usage/SKILL.md index 4ef21cb7..917d0d69 100644 --- a/skills/usage/SKILL.md +++ b/skills/usage/SKILL.md @@ -25,7 +25,7 @@ TanStack Intent skills should stay focused and under the validator line limit, s ### Project setup and diagnostics - `init [apikey] [appId]`: guided first-time setup for Capgo in a Capacitor app. The interactive flow now runs as a real Ink-based fullscreen onboarding so it uses the same UI stack as `build init` (alias: `build onboarding`), with a persistent dashboard, phase roadmap, progress cards, shared log area, and resume support. When dependency auto-detection fails on macOS, the flow opens a native file picker for `package.json` before falling back to manual path entry. If the user reuses a pending app that was already created in the web onboarding flow, the CLI syncs that selected dashboard app ID back into `capacitor.config.*` before the remaining steps continue. Outside that reused pending-app path, the CLI keeps using the local Capacitor app ID. It can also offer a final `npx skills add https://github.com/Cap-go/capgo-skills -g -y` install step before the GitHub support prompt; if accepted, the support menu includes `Cap-go/capgo-skills` alongside the updater-only and all-Capgo choices. If native platforms are missing, the onboarding can offer to run `cap add` for you. During the iOS run-on-device step, onboarding asks whether to use a physical iPhone/iPad or a simulator; for physical devices, it asks the user to connect and unlock the device, then offers a check-again loop before launching with the detected target. If iOS sync validation fails during onboarding, the CLI can offer to run a one-line native reset command, wait for you to type `ready` after a manual fix, surface `doctor`, and save a support bundle before you leave the flow. -- `run-device [platform]`: test the same run-on-device picker used by init onboarding without running the full onboarding. Use `npx @capgo/cli@latest run-device ios --no-launch` to exercise iOS physical/simulator target selection and print the resolved `cap run` command without launching the app. +- `run device [platform]`: test the same run-on-device picker used by init onboarding without running the full onboarding. Use `npx @capgo/cli@latest run device ios --no-launch` to exercise iOS physical/simulator target selection and print the resolved `cap run` command without launching the app. - `login [apikey]`: store an API key locally. - `doctor`: inspect installation health and gather troubleshooting details. - `probe`: test whether the update endpoint would deliver an update. @@ -82,7 +82,7 @@ Load `skills/organization-management/SKILL.md` when working with: ```bash npx @capgo/cli@latest init YOUR_API_KEY com.example.app -npx @capgo/cli@latest run-device ios --no-launch +npx @capgo/cli@latest run device ios --no-launch npx @capgo/cli@latest login YOUR_API_KEY npx @capgo/cli@latest doctor npx @capgo/cli@latest probe --platform ios diff --git a/src/docs.ts b/src/docs.ts index ac275aca..8fe9eadf 100644 --- a/src/docs.ts +++ b/src/docs.ts @@ -50,7 +50,7 @@ function getCommandEmoji(cmdName: string): string { emoji = 'šŸ”“' else if (cmdName.includes('debug')) emoji = 'šŸž' - else if (cmdName.includes('run-device')) + else if (cmdName === 'run') emoji = 'šŸ“±' else if (cmdName.includes('doctor')) emoji = 'šŸ‘Øā€āš•ļø' diff --git a/src/index.ts b/src/index.ts index 26885057..52826b68 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,7 +35,7 @@ import { login } from './login' import { startMcpServer } from './mcp/server' import { addOrganization, deleteOrganization, listMembers, listOrganizations, setOrganization } from './organization' import { probe } from './probe' -import { testRunDeviceCommand } from './run-device' +import { testRunDeviceCommand } from './run/device' import { getUserId } from './user/account' import { formatError } from './utils' @@ -75,14 +75,18 @@ Example: npx @capgo/cli@latest init YOUR_API_KEY com.example.app`) .option('--supa-host ', optionDescriptions.supaHost) .option('--supa-anon ', optionDescriptions.supaAnon) -program - .command('run-device [platform]') +const run = program + .command('run') + .description(`šŸ“± Run and test Capacitor app targets from the CLI.`) + +run + .command('device [platform]') .description(`šŸ“± Test the same Capacitor device target picker used by init onboarding. For iOS, this asks whether to use a physical iPhone/iPad or simulator, supports checking again for targets, and runs with the resolved target when available. Use --no-launch to print the resolved command without starting the app. -Example: npx @capgo/cli@latest run-device ios --no-launch`) +Example: npx @capgo/cli@latest run device ios --no-launch`) .action(testRunDeviceCommand) .option('--no-launch', `Resolve and print the run command without starting the app`) diff --git a/src/init/runtime.tsx b/src/init/runtime.tsx index c7ec54e0..55c34e7f 100644 --- a/src/init/runtime.tsx +++ b/src/init/runtime.tsx @@ -10,6 +10,7 @@ export type InitLogTone = 'cyan' | 'yellow' | 'green' | 'red' export type InitScreenTone = 'cyan' | 'blue' | 'green' | 'yellow' export interface InitScreen { + headerTitle?: string title?: string introLines?: string[] phaseLabel?: string @@ -170,8 +171,8 @@ export function stopInitInkSession(finalMessage?: { text: string, tone: 'green' } export function setInitScreen(screen: InitScreen) { - ensureInitInkSession() updateState(current => ({ ...current, screen })) + ensureInitInkSession() } export function pushInitLog(message: string, tone: InitLogTone) { diff --git a/src/init/ui/app.tsx b/src/init/ui/app.tsx index 8ba3b8c1..8823fba4 100644 --- a/src/init/ui/app.tsx +++ b/src/init/ui/app.tsx @@ -208,7 +208,7 @@ export default function InitInkApp({ getSnapshot, subscribe, updatePromptError } if (snapshot.streamingOutput) { return ( - + - + {snapshot.versionWarning && ( diff --git a/src/init/ui/components.tsx b/src/init/ui/components.tsx index 8198d402..849fbe9f 100644 --- a/src/init/ui/components.tsx +++ b/src/init/ui/components.tsx @@ -18,7 +18,7 @@ function colorForTone(tone: InitScreenTone | InitLogTone): 'cyan' | 'blue' | 'gr return 'cyan' } -export function InitHeader() { +export function InitHeader({ title = 'šŸš€ Capgo OTA Onboarding' }: Readonly<{ title?: string }>) { return ( - šŸš€ Capgo OTA Onboarding + {title} ) diff --git a/src/run-device.ts b/src/run/device.ts similarity index 84% rename from src/run-device.ts rename to src/run/device.ts index 52f82bfe..ff5ca7ea 100644 --- a/src/run-device.ts +++ b/src/run/device.ts @@ -1,17 +1,17 @@ import { exit, stdin, stdout } from 'node:process' import { log as clackLog } from '@clack/prompts' -import { normalizeRunDevicePlatform, resolveRunDeviceCommand, runPackageRunnerSync } from './init/command' -import { cancel as pCancel, intro as pIntro, log as pLog, outro as pOutro, spinner as pSpinner } from './init/prompts' -import { setInitScreen } from './init/runtime' -import { formatRunnerCommand } from './runner-command' -import { formatError, getPMAndCommand } from './utils' +import { normalizeRunDevicePlatform, resolveRunDeviceCommand, runPackageRunnerSync } from '../init/command' +import { cancel as pCancel, log as pLog, outro as pOutro, spinner as pSpinner } from '../init/prompts' +import { setInitScreen } from '../init/runtime' +import { formatRunnerCommand } from '../runner-command' +import { formatError, getPMAndCommand } from '../utils' interface RunDeviceTestOptions { launch?: boolean } async function exitCanceledRunDeviceTest(): Promise { - pOutro('Run-device test canceled.') + pOutro('Run device test canceled.') exit(1) } @@ -23,7 +23,7 @@ function handleNonInteractiveIosRunDevice(pm: ReturnType clackLog.info('Non-interactive mode: iOS target selection needs an interactive terminal.') clackLog.info(`List targets with: ${formatRunnerCommand(pm.runner, ['cap', 'run', 'ios', '--list'])}`) clackLog.info(`Run a concrete target with: ${formatRunnerCommand(pm.runner, ['cap', 'run', 'ios', '--target', ''])}`) - clackLog.error('Run-device test failed.') + clackLog.error('Run device test failed.') exit(1) } @@ -55,7 +55,7 @@ function runResolvedDeviceCommand(pm: ReturnType, runCom if (runResult.error) pLog.error(formatError(runResult.error)) pLog.info(`You can run the command manually with: ${runCommand.command}`) - failRunDeviceTest('Run-device test failed.', interactive) + failRunDeviceTest('Run device test failed.', interactive) } s.stop('App started āœ…') @@ -70,7 +70,7 @@ function runResolvedDeviceCommand(pm: ReturnType, runCom if (runResult.error) clackLog.error(formatError(runResult.error)) clackLog.info(`You can run the command manually with: ${runCommand.command}`) - failRunDeviceTest('Run-device test failed.', interactive) + failRunDeviceTest('Run device test failed.', interactive) } clackLog.info('App started') @@ -93,12 +93,12 @@ export async function testRunDeviceCommand(platformName?: string, options: RunDe } runResolvedDeviceCommand(pm, runCommand, interactive) - finishRunDeviceTest(`Run-device test finished. Manual command: ${runCommand.command}`, interactive) + finishRunDeviceTest(`Run device test finished. Manual command: ${runCommand.command}`, interactive) return } - pIntro('Run device test') setInitScreen({ + headerTitle: 'šŸ“± Capgo Run Device', title: 'Run Device Test', introLines: [ 'This uses the same device target picker as init onboarding.', @@ -123,9 +123,9 @@ export async function testRunDeviceCommand(platformName?: string, options: RunDe } runResolvedDeviceCommand(pm, runCommand, interactive) - finishRunDeviceTest(`Run-device test finished. Manual command: ${runCommand.command}`, interactive) + finishRunDeviceTest(`Run device test finished. Manual command: ${runCommand.command}`, interactive) } catch (error) { - failRunDeviceTest(`Run-device test failed: ${formatError(error)}`, interactive) + failRunDeviceTest(`Run device test failed: ${formatError(error)}`, interactive) } } diff --git a/webdocs/run-device.mdx b/webdocs/run.mdx similarity index 66% rename from webdocs/run-device.mdx rename to webdocs/run.mdx index aa88f054..a8e85c64 100644 --- a/webdocs/run-device.mdx +++ b/webdocs/run.mdx @@ -1,27 +1,30 @@ --- -title: šŸ“± run-device -description: "šŸ“± Test the same Capacitor device target picker used by init onboarding." -sidebar_label: run-device +title: šŸ“± run +description: "šŸ“± Run and test Capacitor app targets from the CLI." +sidebar_label: run sidebar: order: 2 --- -šŸ“± Test the same Capacitor device target picker used by init onboarding. +šŸ“± Run and test Capacitor app targets from the CLI. + +### šŸ”¹ **Device** ```bash -npx @capgo/cli@latest run-device +npx @capgo/cli@latest run device ``` +šŸ“± Test the same Capacitor device target picker used by init onboarding. For iOS, this asks whether to use a physical iPhone/iPad or simulator, supports checking again for targets, and runs with the resolved target when available. Use --no-launch to print the resolved command without starting the app. **Example:** ```bash -npx @capgo/cli@latest run-device ios --no-launch +npx @capgo/cli@latest run device ios --no-launch ``` -## Options +**Options:** | Param | Type | Description | | -------------- | ------------- | -------------------- | From 1881f7f0ea0852a6fe50ac4c837f0907c7cbc138 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 28 Apr 2026 16:49:14 +0200 Subject: [PATCH 09/15] fix(run): keep ios no-launch non-interactive --- package.json | 3 ++- src/run/device.ts | 16 +++++++++----- test/test-run-device-command.mjs | 38 ++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 test/test-run-device-command.mjs diff --git a/package.json b/package.json index ed7bb6c6..584c6470 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "test:ci-prompts": "bun test/test-ci-prompts.mjs", "test:onboarding-recovery": "bun test/test-onboarding-recovery.mjs", "test:onboarding-run-targets": "bun test/test-onboarding-run-targets.mjs", + "test:run-device-command": "bun test/test-run-device-command.mjs", "test:prompt-preferences": "bun test/test-prompt-preferences.mjs", "test:esm-sdk": "node test/test-sdk-esm.mjs", "test:mcp": "node test/test-mcp.mjs", @@ -81,7 +82,7 @@ "test:version-detection:setup": "./test/fixtures/setup-test-projects.sh", "test:platform-paths": "bun test/test-platform-paths.mjs", "test:payload-split": "bun test/test-payload-split.mjs", - "test": "bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:ci-prompts && bun run test:onboarding-recovery && bun run test:onboarding-run-targets && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split" + "test": "bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:ci-prompts && bun run test:onboarding-recovery && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split" }, "devDependencies": { "@antfu/eslint-config": "^7.0.0", diff --git a/src/run/device.ts b/src/run/device.ts index ff5ca7ea..72fd23ea 100644 --- a/src/run/device.ts +++ b/src/run/device.ts @@ -1,3 +1,4 @@ +import type { PlatformChoice } from '../init/command' import { exit, stdin, stdout } from 'node:process' import { log as clackLog } from '@clack/prompts' import { normalizeRunDevicePlatform, resolveRunDeviceCommand, runPackageRunnerSync } from '../init/command' @@ -42,6 +43,11 @@ function finishRunDeviceTest(message: string, interactive: boolean): void { clackLog.info(message) } +function getNonInteractiveRunDeviceCommand(pm: ReturnType, platformName: PlatformChoice): { args: string[], command: string } { + const args = ['cap', 'run', platformName] + return { args, command: formatRunnerCommand(pm.runner, args) } +} + function runResolvedDeviceCommand(pm: ReturnType, runCommand: { args: string[], command: string }, interactive: boolean): void { if (interactive) { const s = pSpinner() @@ -82,16 +88,16 @@ export async function testRunDeviceCommand(platformName?: string, options: RunDe const pm = getPMAndCommand() const platformNameChoice = normalizeRunDevicePlatform(platformName) - if (platformNameChoice === 'ios' && !interactive) - handleNonInteractiveIosRunDevice(pm) - if (!interactive) { - const runCommand = await resolveRunDeviceCommand(exitCanceledRunDeviceTest, pm, platformNameChoice) - if (!runCommand.args || options.launch === false) { + const runCommand = getNonInteractiveRunDeviceCommand(pm, platformNameChoice) + if (options.launch === false) { finishRunDeviceTest(`Resolved run command: ${runCommand.command}`, interactive) return } + if (platformNameChoice === 'ios') + handleNonInteractiveIosRunDevice(pm) + runResolvedDeviceCommand(pm, runCommand, interactive) finishRunDeviceTest(`Run device test finished. Manual command: ${runCommand.command}`, interactive) return diff --git a/test/test-run-device-command.mjs b/test/test-run-device-command.mjs new file mode 100644 index 00000000..457c61ab --- /dev/null +++ b/test/test-run-device-command.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' + +let failures = 0 + +function test(name, fn) { + try { + fn() + console.log(`āœ“ ${name}`) + } + catch (error) { + failures += 1 + console.error(`āŒ ${name}`) + console.error(error) + } +} + +test('resolves iOS run device command without launching in non-interactive mode', () => { + const result = spawnSync('node', ['dist/index.js', 'run', 'device', 'ios', '--no-launch'], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }) + const output = `${result.stdout}\n${result.stderr}` + + assert.equal(result.status, 0, output) + assert.match(output, /Resolved run command:/) + assert.match(output, /cap run ios/) + assert.doesNotMatch(output, /Run device test failed/) +}) + +if (failures > 0) { + console.error(`\nāŒ ${failures} run device command test(s) failed`) + process.exit(1) +} + +console.log('\nāœ… Run device command handling works') From 4703df8790a2887980b1e4fa0b276e07b807d926 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 28 Apr 2026 17:05:28 +0200 Subject: [PATCH 10/15] fix(run): prompt platform before device selection --- README.md | 8 ++- skills/usage/SKILL.md | 2 +- src/index.ts | 8 ++- src/init/command.ts | 120 ++++++++++++++++++++++--------- src/run/device.ts | 66 ++++++++++++----- test/test-run-device-command.mjs | 14 +++- webdocs/run.mdx | 10 +-- 7 files changed, 165 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 269cefd6..4b7a2a88 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ npx @capgo/cli@latest init YOUR_API_KEY com.example.app ## šŸ“± **Run** -šŸ“± Run and test Capacitor app targets from the CLI. +šŸ“± Run Capacitor apps on devices from the CLI. ### šŸ”¹ **Device** @@ -116,8 +116,10 @@ npx @capgo/cli@latest init YOUR_API_KEY com.example.app npx @capgo/cli@latest run device ``` -šŸ“± Test the same Capacitor device target picker used by init onboarding. -For iOS, this asks whether to use a physical iPhone/iPad or simulator, supports checking again for targets, and runs with the resolved target when available. +šŸ“± Run your Capacitor app on a connected device or simulator. +If you omit the platform in an interactive terminal, Capgo asks whether to run iOS or Android first. +The command lists available devices and simulators, lets you reload the list, and runs with your selection. +For iOS, this asks whether to use a physical iPhone/iPad or simulator before showing devices. Use --no-launch to print the resolved command without starting the app. **Example:** diff --git a/skills/usage/SKILL.md b/skills/usage/SKILL.md index 917d0d69..ef269bba 100644 --- a/skills/usage/SKILL.md +++ b/skills/usage/SKILL.md @@ -25,7 +25,7 @@ TanStack Intent skills should stay focused and under the validator line limit, s ### Project setup and diagnostics - `init [apikey] [appId]`: guided first-time setup for Capgo in a Capacitor app. The interactive flow now runs as a real Ink-based fullscreen onboarding so it uses the same UI stack as `build init` (alias: `build onboarding`), with a persistent dashboard, phase roadmap, progress cards, shared log area, and resume support. When dependency auto-detection fails on macOS, the flow opens a native file picker for `package.json` before falling back to manual path entry. If the user reuses a pending app that was already created in the web onboarding flow, the CLI syncs that selected dashboard app ID back into `capacitor.config.*` before the remaining steps continue. Outside that reused pending-app path, the CLI keeps using the local Capacitor app ID. It can also offer a final `npx skills add https://github.com/Cap-go/capgo-skills -g -y` install step before the GitHub support prompt; if accepted, the support menu includes `Cap-go/capgo-skills` alongside the updater-only and all-Capgo choices. If native platforms are missing, the onboarding can offer to run `cap add` for you. During the iOS run-on-device step, onboarding asks whether to use a physical iPhone/iPad or a simulator; for physical devices, it asks the user to connect and unlock the device, then offers a check-again loop before launching with the detected target. If iOS sync validation fails during onboarding, the CLI can offer to run a one-line native reset command, wait for you to type `ready` after a manual fix, surface `doctor`, and save a support bundle before you leave the flow. -- `run device [platform]`: test the same run-on-device picker used by init onboarding without running the full onboarding. Use `npx @capgo/cli@latest run device ios --no-launch` to exercise iOS physical/simulator target selection and print the resolved `cap run` command without launching the app. +- `run device [platform]`: run a Capacitor app on a connected device or simulator. In an interactive terminal, omitting `[platform]` asks whether to run iOS or Android first. The command lists available devices and simulators, includes a reload option, and resolves the `cap run` command. Use `npx @capgo/cli@latest run device ios --no-launch` to exercise iOS physical/simulator target selection and print the resolved command without launching the app. - `login [apikey]`: store an API key locally. - `doctor`: inspect installation health and gather troubleshooting details. - `probe`: test whether the update endpoint would deliver an update. diff --git a/src/index.ts b/src/index.ts index 52826b68..4e43134c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,13 +77,15 @@ Example: npx @capgo/cli@latest init YOUR_API_KEY com.example.app`) const run = program .command('run') - .description(`šŸ“± Run and test Capacitor app targets from the CLI.`) + .description(`šŸ“± Run Capacitor apps on devices from the CLI.`) run .command('device [platform]') - .description(`šŸ“± Test the same Capacitor device target picker used by init onboarding. + .description(`šŸ“± Run your Capacitor app on a connected device or simulator. -For iOS, this asks whether to use a physical iPhone/iPad or simulator, supports checking again for targets, and runs with the resolved target when available. +If you omit the platform in an interactive terminal, Capgo asks whether to run iOS or Android first. +The command lists available devices and simulators, lets you reload the list, and runs with your selection. +For iOS, this asks whether to use a physical iPhone/iPad or simulator before showing devices. Use --no-launch to print the resolved command without starting the app. Example: npx @capgo/cli@latest run device ios --no-launch`) diff --git a/src/init/command.ts b/src/init/command.ts index e034101e..cc234c88 100644 --- a/src/init/command.ts +++ b/src/init/command.ts @@ -2332,6 +2332,7 @@ const IOS_SIMULATOR_TARGET_SUFFIX_RE = /\(simulator\)$/i const INVALID_CAPACITOR_RUN_TARGET_IDS = new Set(['?']) type IosRunTargetResolution = RunDeviceStepOutcome | typeof iosRunTargetActions.refresh | typeof iosRunTargetActions.simulator type IosSimulatorRunTargetResolution = RunDeviceStepOutcome | typeof iosRunTargetActions.refresh +type CapacitorRunTargetResolution = RunDeviceStepOutcome | typeof iosRunTargetActions.refresh async function ensureNativePlatformForBuild(platform: PlatformChoice, config: CapacitorConfigSnapshot | undefined, runner: string): Promise { const addPlatformCommand = formatRunnerCommand(runner, ['cap', 'add', platform]) @@ -2535,26 +2536,16 @@ function getSkippedRunDeviceCommand(pm: PackageManagerInfo, platformName: Platfo return { args: undefined, command: formatRunnerCommand(pm.runner, args) } } -function handleSinglePhysicalIosRunTarget(pm: PackageManagerInfo, target: CapacitorRunTarget): RunDeviceStepOutcome { - pLog.info(`Found physical iOS device: ${target.name}`) - return getRunDeviceCommand(pm, 'ios', target) -} - -function handleSingleSimulatorIosRunTarget(pm: PackageManagerInfo, target: CapacitorRunTarget): RunDeviceStepOutcome { - pLog.info(`Found iOS Simulator: ${target.name}`) - return getRunDeviceCommand(pm, 'ios', target) -} - -async function handleMultiplePhysicalIosRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, physicalTargets: CapacitorRunTarget[]): Promise { +async function handlePhysicalIosRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, physicalTargets: CapacitorRunTarget[]): Promise { const selectedTarget = await pSelect({ - message: 'Which physical iOS device should onboarding use?', + message: 'Which physical iOS device do you want to run on?', options: [ ...physicalTargets.map(target => ({ value: target.id, label: target.name, hint: target.id, })), - { value: iosRunTargetActions.refresh, label: 'Check again for connected devices' }, + { value: iosRunTargetActions.refresh, label: 'Reload list' }, { value: iosRunTargetActions.simulator, label: 'Use iOS Simulator instead' }, { value: iosRunTargetActions.skip, label: 'Skip running now' }, ], @@ -2583,7 +2574,7 @@ async function handleMissingPhysicalIosRunTargets(cancelHandler: RunDeviceCancel const nextAction = await pSelect({ message: 'Connect and unlock your iPhone, then check again.', options: [ - { value: iosRunTargetActions.refresh, label: 'Check again for connected devices' }, + { value: iosRunTargetActions.refresh, label: 'Reload list' }, { value: iosRunTargetActions.simulator, label: 'Use iOS Simulator instead' }, { value: iosRunTargetActions.skip, label: 'Skip running now' }, ], @@ -2599,16 +2590,16 @@ async function handleMissingPhysicalIosRunTargets(cancelHandler: RunDeviceCancel return getSkippedRunDeviceCommand(pm, 'ios') } -async function handleMultipleSimulatorIosRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, simulatorTargets: CapacitorRunTarget[]): Promise { +async function handleSimulatorIosRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, simulatorTargets: CapacitorRunTarget[]): Promise { const selectedTarget = await pSelect({ - message: 'Which iOS Simulator should onboarding use?', + message: 'Which iOS Simulator do you want to run on?', options: [ ...simulatorTargets.map(target => ({ value: target.id, label: target.name, hint: target.id, })), - { value: iosRunTargetActions.refresh, label: 'Check again for simulators' }, + { value: iosRunTargetActions.refresh, label: 'Reload list' }, { value: iosRunTargetActions.skip, label: 'Skip running now' }, ], }) @@ -2630,11 +2621,11 @@ async function handleMultipleSimulatorIosRunTargets(cancelHandler: RunDeviceCanc } async function handleMissingSimulatorIosRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo): Promise { - pLog.warn('No iOS Simulator target detected yet.') + pLog.warn('No iOS Simulator detected yet.') const nextAction = await pSelect({ message: 'Open Xcode or install a simulator, then check again.', options: [ - { value: iosRunTargetActions.refresh, label: 'Check again for simulators' }, + { value: iosRunTargetActions.refresh, label: 'Reload list' }, { value: iosRunTargetActions.skip, label: 'Skip running now' }, ], }) @@ -2657,15 +2648,12 @@ async function selectSimulatorIosRunTarget(cancelHandler: RunDeviceCancelHandler knownTargets = undefined if ('error' in result && result.error) - pLog.warn(`Could not check iOS Simulator targets: ${formatError(result.error)}`) + pLog.warn(`Could not check iOS Simulators: ${formatError(result.error)}`) const simulatorTargets = getSimulatorIosRunTargets(result.targets) - if (simulatorTargets.length === 1) - return handleSingleSimulatorIosRunTarget(pm, simulatorTargets[0]) - - const selectionResult = simulatorTargets.length > 1 - ? await handleMultipleSimulatorIosRunTargets(cancelHandler, pm, simulatorTargets) + const selectionResult = simulatorTargets.length > 0 + ? await handleSimulatorIosRunTargets(cancelHandler, pm, simulatorTargets) : await handleMissingSimulatorIosRunTargets(cancelHandler, pm) if (selectionResult === iosRunTargetActions.refresh) continue @@ -2683,11 +2671,8 @@ async function selectPhysicalIosRunTarget(cancelHandler: RunDeviceCancelHandler, const physicalTargets = getPhysicalIosRunTargets(result.targets) - if (physicalTargets.length === 1) - return handleSinglePhysicalIosRunTarget(pm, physicalTargets[0]) - - const selectionResult = physicalTargets.length > 1 - ? await handleMultiplePhysicalIosRunTargets(cancelHandler, pm, physicalTargets) + const selectionResult = physicalTargets.length > 0 + ? await handlePhysicalIosRunTargets(cancelHandler, pm, physicalTargets) : await handleMissingPhysicalIosRunTargets(cancelHandler, pm) if (selectionResult === iosRunTargetActions.refresh) continue @@ -2697,9 +2682,78 @@ async function selectPhysicalIosRunTarget(cancelHandler: RunDeviceCancelHandler, } } +function getRunTargetLabel(platformName: PlatformChoice): string { + return platformName === 'ios' ? 'iOS device or simulator' : 'Android device or emulator' +} + +async function handleCapacitorRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, platformName: PlatformChoice, targets: CapacitorRunTarget[]): Promise { + const targetLabel = getRunTargetLabel(platformName) + const selectedTarget = await pSelect({ + message: `Which ${targetLabel} do you want to run on?`, + options: [ + ...targets.map(target => ({ + value: target.id, + label: target.name, + hint: target.id, + })), + { value: iosRunTargetActions.refresh, label: 'Reload list' }, + { value: iosRunTargetActions.skip, label: 'Skip running now' }, + ], + }) + + if (pIsCancel(selectedTarget)) + await cancelHandler() + + if (selectedTarget === iosRunTargetActions.refresh) + return iosRunTargetActions.refresh + if (selectedTarget === iosRunTargetActions.skip) + return getSkippedRunDeviceCommand(pm, platformName) + + const target = targets.find(({ id }) => id === selectedTarget) + if (target) + return getRunDeviceCommand(pm, platformName, target) + + pLog.warn(`That ${targetLabel} is no longer available. Checking again.`) + return iosRunTargetActions.refresh +} + +async function handleMissingCapacitorRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, platformName: PlatformChoice): Promise { + const targetLabel = getRunTargetLabel(platformName) + pLog.warn(`No ${targetLabel} detected yet.`) + const nextAction = await pSelect({ + message: 'Connect or start one, then reload the list.', + options: [ + { value: iosRunTargetActions.refresh, label: 'Reload list' }, + { value: iosRunTargetActions.skip, label: 'Skip running now' }, + ], + }) + + if (pIsCancel(nextAction)) + await cancelHandler() + + if (nextAction === iosRunTargetActions.refresh) + return iosRunTargetActions.refresh + return getSkippedRunDeviceCommand(pm, platformName) +} + +async function selectCapacitorRunTarget(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, platformName: PlatformChoice): Promise { + while (true) { + const result = getCapacitorRunTargetList(pm.runner, platformName) + if (result.error) + pLog.warn(`Could not check available devices: ${formatError(result.error)}`) + + const selectionResult = result.targets.length > 0 + ? await handleCapacitorRunTargets(cancelHandler, pm, platformName, result.targets) + : await handleMissingCapacitorRunTargets(cancelHandler, pm, platformName) + if (selectionResult === iosRunTargetActions.refresh) + continue + return selectionResult + } +} + export async function resolveRunDeviceCommand(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, platformName: PlatformChoice): Promise { if (platformName !== 'ios') - return getRunDeviceCommand(pm, platformName) + return selectCapacitorRunTarget(cancelHandler, pm, platformName) const targetKind = await pSelect({ message: 'Where do you want to run the iOS app?', @@ -2769,8 +2823,8 @@ async function handleMissingPlatformSelection(orgId: string, apikey: string, ava pLog.warn(`Still could not add ${platformToAdd}.`) } -export function normalizeRunDevicePlatform(platformName: string | undefined): PlatformChoice { - const normalized = (platformName || 'ios').toLowerCase() +export function normalizeRunDevicePlatform(platformName: string): PlatformChoice { + const normalized = platformName.toLowerCase() if (normalized === 'ios' || normalized === 'android') return normalized throw new Error('Platform must be "ios" or "android".') diff --git a/src/run/device.ts b/src/run/device.ts index 72fd23ea..beaad64b 100644 --- a/src/run/device.ts +++ b/src/run/device.ts @@ -2,7 +2,7 @@ import type { PlatformChoice } from '../init/command' import { exit, stdin, stdout } from 'node:process' import { log as clackLog } from '@clack/prompts' import { normalizeRunDevicePlatform, resolveRunDeviceCommand, runPackageRunnerSync } from '../init/command' -import { cancel as pCancel, log as pLog, outro as pOutro, spinner as pSpinner } from '../init/prompts' +import { cancel as pCancel, isCancel as pIsCancel, log as pLog, outro as pOutro, select as pSelect, spinner as pSpinner } from '../init/prompts' import { setInitScreen } from '../init/runtime' import { formatRunnerCommand } from '../runner-command' import { formatError, getPMAndCommand } from '../utils' @@ -21,9 +21,9 @@ function canSelectRunDeviceTargetInteractively(): boolean { } function handleNonInteractiveIosRunDevice(pm: ReturnType): never { - clackLog.info('Non-interactive mode: iOS target selection needs an interactive terminal.') - clackLog.info(`List targets with: ${formatRunnerCommand(pm.runner, ['cap', 'run', 'ios', '--list'])}`) - clackLog.info(`Run a concrete target with: ${formatRunnerCommand(pm.runner, ['cap', 'run', 'ios', '--target', ''])}`) + clackLog.info('Non-interactive mode: iOS device selection needs an interactive terminal.') + clackLog.info(`List devices with: ${formatRunnerCommand(pm.runner, ['cap', 'run', 'ios', '--list'])}`) + clackLog.info(`Run a specific device with: ${formatRunnerCommand(pm.runner, ['cap', 'run', 'ios', '--target', ''])}`) clackLog.error('Run device test failed.') exit(1) } @@ -48,6 +48,45 @@ function getNonInteractiveRunDeviceCommand(pm: ReturnType { + if (platformName) + return normalizeRunDevicePlatform(platformName) + + if (!interactive) + throw new Error('Platform is required in non-interactive mode. Pass "ios" or "android".') + + const selectedPlatform = await pSelect({ + message: 'Which platform do you want to run?', + options: [ + { value: 'ios', label: 'iOS' }, + { value: 'android', label: 'Android' }, + ], + }) + + if (pIsCancel(selectedPlatform)) + await exitCanceledRunDeviceTest() + + return selectedPlatform as PlatformChoice +} + +function setRunDeviceScreen(platformName?: PlatformChoice): void { + setInitScreen({ + headerTitle: 'šŸ“± Capgo Run Device', + title: 'Run On Device', + introLines: [ + platformName + ? 'Choose where to run your app.' + : 'Choose a platform, then pick a device or simulator.', + platformName === 'ios' + ? 'For iOS, use a physical iPhone/iPad or an iOS Simulator.' + : 'Reload the list if your device is not visible yet.', + ], + phaseLabel: platformName ? 'Device' : 'Platform', + statusLine: platformName ? `Platform: ${platformName.toUpperCase()}` : 'Choose iOS or Android', + tone: 'blue', + }) +} + function runResolvedDeviceCommand(pm: ReturnType, runCommand: { args: string[], command: string }, interactive: boolean): void { if (interactive) { const s = pSpinner() @@ -86,7 +125,10 @@ export async function testRunDeviceCommand(platformName?: string, options: RunDe const interactive = canSelectRunDeviceTargetInteractively() try { const pm = getPMAndCommand() - const platformNameChoice = normalizeRunDevicePlatform(platformName) + if (interactive) + setRunDeviceScreen(platformName ? normalizeRunDevicePlatform(platformName) : undefined) + + const platformNameChoice = await selectRunDevicePlatform(platformName, interactive) if (!interactive) { const runCommand = getNonInteractiveRunDeviceCommand(pm, platformNameChoice) @@ -103,19 +145,7 @@ export async function testRunDeviceCommand(platformName?: string, options: RunDe return } - setInitScreen({ - headerTitle: 'šŸ“± Capgo Run Device', - title: 'Run Device Test', - introLines: [ - 'This uses the same device target picker as init onboarding.', - platformNameChoice === 'ios' - ? 'For iOS, choose a physical device or simulator, then refresh target discovery if needed.' - : 'For Android, this runs Capacitor directly.', - ], - phaseLabel: 'Device target', - statusLine: `Platform: ${platformNameChoice.toUpperCase()}`, - tone: 'blue', - }) + setRunDeviceScreen(platformNameChoice) const runCommand = await resolveRunDeviceCommand(exitCanceledRunDeviceTest, pm, platformNameChoice) if (!runCommand.args) { diff --git a/test/test-run-device-command.mjs b/test/test-run-device-command.mjs index 457c61ab..30bccaaf 100644 --- a/test/test-run-device-command.mjs +++ b/test/test-run-device-command.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' +import { execPath } from 'node:process' let failures = 0 @@ -18,7 +19,7 @@ function test(name, fn) { } test('resolves iOS run device command without launching in non-interactive mode', () => { - const result = spawnSync('node', ['dist/index.js', 'run', 'device', 'ios', '--no-launch'], { + const result = spawnSync(execPath, ['dist/index.js', 'run', 'device', 'ios', '--no-launch'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], }) @@ -30,6 +31,17 @@ test('resolves iOS run device command without launching in non-interactive mode' assert.doesNotMatch(output, /Run device test failed/) }) +test('requires a platform when run non-interactively without launching', () => { + const result = spawnSync(execPath, ['dist/index.js', 'run', 'device', '--no-launch'], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }) + const output = `${result.stdout}\n${result.stderr}` + + assert.notEqual(result.status, 0, output) + assert.match(output, /Platform is required in non-interactive mode/) +}) + if (failures > 0) { console.error(`\nāŒ ${failures} run device command test(s) failed`) process.exit(1) diff --git a/webdocs/run.mdx b/webdocs/run.mdx index a8e85c64..bf661852 100644 --- a/webdocs/run.mdx +++ b/webdocs/run.mdx @@ -1,12 +1,12 @@ --- title: šŸ“± run -description: "šŸ“± Run and test Capacitor app targets from the CLI." +description: "šŸ“± Run Capacitor apps on devices from the CLI." sidebar_label: run sidebar: order: 2 --- -šŸ“± Run and test Capacitor app targets from the CLI. +šŸ“± Run Capacitor apps on devices from the CLI. ### šŸ”¹ **Device** @@ -14,8 +14,10 @@ sidebar: npx @capgo/cli@latest run device ``` -šŸ“± Test the same Capacitor device target picker used by init onboarding. -For iOS, this asks whether to use a physical iPhone/iPad or simulator, supports checking again for targets, and runs with the resolved target when available. +šŸ“± Run your Capacitor app on a connected device or simulator. +If you omit the platform in an interactive terminal, Capgo asks whether to run iOS or Android first. +The command lists available devices and simulators, lets you reload the list, and runs with your selection. +For iOS, this asks whether to use a physical iPhone/iPad or simulator before showing devices. Use --no-launch to print the resolved command without starting the app. **Example:** From ce853d44255ffd4f5c6be485d6791fc2d4d1f97a Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 28 Apr 2026 18:03:20 +0200 Subject: [PATCH 11/15] fix(run): prompt missing device platform without header --- README.md | 2 +- skills/usage/SKILL.md | 2 +- src/index.ts | 2 +- src/init/ui/app.tsx | 4 ++-- src/run/device.ts | 40 +++++++------------------------- test/test-run-device-command.mjs | 5 ++-- webdocs/run.mdx | 3 +-- 7 files changed, 18 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 4b7a2a88..f3036413 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ npx @capgo/cli@latest run device ``` šŸ“± Run your Capacitor app on a connected device or simulator. -If you omit the platform in an interactive terminal, Capgo asks whether to run iOS or Android first. +If you omit the platform in an interactive terminal, the command asks whether to start on iOS or Android. The command lists available devices and simulators, lets you reload the list, and runs with your selection. For iOS, this asks whether to use a physical iPhone/iPad or simulator before showing devices. Use --no-launch to print the resolved command without starting the app. diff --git a/skills/usage/SKILL.md b/skills/usage/SKILL.md index ef269bba..ff058c22 100644 --- a/skills/usage/SKILL.md +++ b/skills/usage/SKILL.md @@ -25,7 +25,7 @@ TanStack Intent skills should stay focused and under the validator line limit, s ### Project setup and diagnostics - `init [apikey] [appId]`: guided first-time setup for Capgo in a Capacitor app. The interactive flow now runs as a real Ink-based fullscreen onboarding so it uses the same UI stack as `build init` (alias: `build onboarding`), with a persistent dashboard, phase roadmap, progress cards, shared log area, and resume support. When dependency auto-detection fails on macOS, the flow opens a native file picker for `package.json` before falling back to manual path entry. If the user reuses a pending app that was already created in the web onboarding flow, the CLI syncs that selected dashboard app ID back into `capacitor.config.*` before the remaining steps continue. Outside that reused pending-app path, the CLI keeps using the local Capacitor app ID. It can also offer a final `npx skills add https://github.com/Cap-go/capgo-skills -g -y` install step before the GitHub support prompt; if accepted, the support menu includes `Cap-go/capgo-skills` alongside the updater-only and all-Capgo choices. If native platforms are missing, the onboarding can offer to run `cap add` for you. During the iOS run-on-device step, onboarding asks whether to use a physical iPhone/iPad or a simulator; for physical devices, it asks the user to connect and unlock the device, then offers a check-again loop before launching with the detected target. If iOS sync validation fails during onboarding, the CLI can offer to run a one-line native reset command, wait for you to type `ready` after a manual fix, surface `doctor`, and save a support bundle before you leave the flow. -- `run device [platform]`: run a Capacitor app on a connected device or simulator. In an interactive terminal, omitting `[platform]` asks whether to run iOS or Android first. The command lists available devices and simulators, includes a reload option, and resolves the `cap run` command. Use `npx @capgo/cli@latest run device ios --no-launch` to exercise iOS physical/simulator target selection and print the resolved command without launching the app. +- `run device [platform]`: run a Capacitor app on a connected device or simulator. In an interactive terminal, omitting `[platform]` asks whether to start on iOS or Android. The command lists available devices and simulators, includes a reload option, and resolves the `cap run` command. Use `npx @capgo/cli@latest run device ios --no-launch` to exercise iOS physical/simulator target selection and print the resolved command without launching the app. - `login [apikey]`: store an API key locally. - `doctor`: inspect installation health and gather troubleshooting details. - `probe`: test whether the update endpoint would deliver an update. diff --git a/src/index.ts b/src/index.ts index 4e43134c..89d1d1bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,7 +83,7 @@ run .command('device [platform]') .description(`šŸ“± Run your Capacitor app on a connected device or simulator. -If you omit the platform in an interactive terminal, Capgo asks whether to run iOS or Android first. +If you omit the platform in an interactive terminal, the command asks whether to start on iOS or Android. The command lists available devices and simulators, lets you reload the list, and runs with your selection. For iOS, this asks whether to use a physical iPhone/iPad or simulator before showing devices. Use --no-launch to print the resolved command without starting the app. diff --git a/src/init/ui/app.tsx b/src/init/ui/app.tsx index 8823fba4..e0933336 100644 --- a/src/init/ui/app.tsx +++ b/src/init/ui/app.tsx @@ -208,7 +208,7 @@ export default function InitInkApp({ getSnapshot, subscribe, updatePromptError } if (snapshot.streamingOutput) { return ( - + {screen ? : null} - + {screen ? : null} {snapshot.versionWarning && ( diff --git a/src/run/device.ts b/src/run/device.ts index beaad64b..af249e22 100644 --- a/src/run/device.ts +++ b/src/run/device.ts @@ -1,9 +1,8 @@ import type { PlatformChoice } from '../init/command' import { exit, stdin, stdout } from 'node:process' -import { log as clackLog } from '@clack/prompts' +import { cancel as clackCancel, isCancel as clackIsCancel, log as clackLog, select as clackSelect } from '@clack/prompts' import { normalizeRunDevicePlatform, resolveRunDeviceCommand, runPackageRunnerSync } from '../init/command' -import { cancel as pCancel, isCancel as pIsCancel, log as pLog, outro as pOutro, select as pSelect, spinner as pSpinner } from '../init/prompts' -import { setInitScreen } from '../init/runtime' +import { cancel as pCancel, log as pLog, outro as pOutro, spinner as pSpinner } from '../init/prompts' import { formatRunnerCommand } from '../runner-command' import { formatError, getPMAndCommand } from '../utils' @@ -53,40 +52,24 @@ async function selectRunDevicePlatform(platformName: string | undefined, interac return normalizeRunDevicePlatform(platformName) if (!interactive) - throw new Error('Platform is required in non-interactive mode. Pass "ios" or "android".') + throw new Error('No platform provided. Run in an interactive terminal to choose iOS or Android, or pass "ios" or "android".') - const selectedPlatform = await pSelect({ - message: 'Which platform do you want to run?', + const selectedPlatform = await clackSelect({ + message: 'You did not provide a platform. Which platform do you want to start on?', options: [ { value: 'ios', label: 'iOS' }, { value: 'android', label: 'Android' }, ], }) - if (pIsCancel(selectedPlatform)) - await exitCanceledRunDeviceTest() + if (clackIsCancel(selectedPlatform)) { + clackCancel('Run device test canceled.') + exit(1) + } return selectedPlatform as PlatformChoice } -function setRunDeviceScreen(platformName?: PlatformChoice): void { - setInitScreen({ - headerTitle: 'šŸ“± Capgo Run Device', - title: 'Run On Device', - introLines: [ - platformName - ? 'Choose where to run your app.' - : 'Choose a platform, then pick a device or simulator.', - platformName === 'ios' - ? 'For iOS, use a physical iPhone/iPad or an iOS Simulator.' - : 'Reload the list if your device is not visible yet.', - ], - phaseLabel: platformName ? 'Device' : 'Platform', - statusLine: platformName ? `Platform: ${platformName.toUpperCase()}` : 'Choose iOS or Android', - tone: 'blue', - }) -} - function runResolvedDeviceCommand(pm: ReturnType, runCommand: { args: string[], command: string }, interactive: boolean): void { if (interactive) { const s = pSpinner() @@ -125,9 +108,6 @@ export async function testRunDeviceCommand(platformName?: string, options: RunDe const interactive = canSelectRunDeviceTargetInteractively() try { const pm = getPMAndCommand() - if (interactive) - setRunDeviceScreen(platformName ? normalizeRunDevicePlatform(platformName) : undefined) - const platformNameChoice = await selectRunDevicePlatform(platformName, interactive) if (!interactive) { @@ -145,8 +125,6 @@ export async function testRunDeviceCommand(platformName?: string, options: RunDe return } - setRunDeviceScreen(platformNameChoice) - const runCommand = await resolveRunDeviceCommand(exitCanceledRunDeviceTest, pm, platformNameChoice) if (!runCommand.args) { finishRunDeviceTest(`Skipped device launch. Manual command: ${runCommand.command}`, interactive) diff --git a/test/test-run-device-command.mjs b/test/test-run-device-command.mjs index 30bccaaf..14741eee 100644 --- a/test/test-run-device-command.mjs +++ b/test/test-run-device-command.mjs @@ -31,7 +31,7 @@ test('resolves iOS run device command without launching in non-interactive mode' assert.doesNotMatch(output, /Run device test failed/) }) -test('requires a platform when run non-interactively without launching', () => { +test('requires an interactive terminal when no platform is provided', () => { const result = spawnSync(execPath, ['dist/index.js', 'run', 'device', '--no-launch'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], @@ -39,7 +39,8 @@ test('requires a platform when run non-interactively without launching', () => { const output = `${result.stdout}\n${result.stderr}` assert.notEqual(result.status, 0, output) - assert.match(output, /Platform is required in non-interactive mode/) + assert.match(output, /No platform provided/) + assert.match(output, /choose iOS or Android/) }) if (failures > 0) { diff --git a/webdocs/run.mdx b/webdocs/run.mdx index bf661852..827443f1 100644 --- a/webdocs/run.mdx +++ b/webdocs/run.mdx @@ -15,7 +15,7 @@ npx @capgo/cli@latest run device ``` šŸ“± Run your Capacitor app on a connected device or simulator. -If you omit the platform in an interactive terminal, Capgo asks whether to run iOS or Android first. +If you omit the platform in an interactive terminal, the command asks whether to start on iOS or Android. The command lists available devices and simulators, lets you reload the list, and runs with your selection. For iOS, this asks whether to use a physical iPhone/iPad or simulator before showing devices. Use --no-launch to print the resolved command without starting the app. @@ -31,4 +31,3 @@ npx @capgo/cli@latest run device ios --no-launch | Param | Type | Description | | -------------- | ------------- | -------------------- | | **--no-launch** | boolean | Resolve and print the run command without starting the app | - From e3aec53a3a853677bd022368e03aec3df2b67a92 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 28 Apr 2026 18:42:48 +0200 Subject: [PATCH 12/15] fix(run): keep device picker visible --- src/init/command.ts | 18 +++++++++++++++--- src/run/device.ts | 19 +++++++++++++++++++ test/test-onboarding-run-targets.mjs | 12 ++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/init/command.ts b/src/init/command.ts index cc234c88..e026d59f 100644 --- a/src/init/command.ts +++ b/src/init/command.ts @@ -2524,6 +2524,18 @@ function getCapacitorRunTargetList(runner: string, platformName: PlatformChoice) } } +async function getCapacitorRunTargetListWithStatus(pm: PackageManagerInfo, platformName: PlatformChoice, message: string): Promise<{ targets: CapacitorRunTarget[], error?: Error }> { + const s = pSpinner() + s.start(message) + await new Promise(resolve => setTimeout(resolve, 0)) + try { + return getCapacitorRunTargetList(pm.runner, platformName) + } + finally { + s.stop() + } +} + function getRunDeviceCommand(pm: PackageManagerInfo, platformName: PlatformChoice, target?: CapacitorRunTarget): RunDeviceStepOutcome { const args = ['cap', 'run', platformName] if (target) @@ -2644,7 +2656,7 @@ async function selectSimulatorIosRunTarget(cancelHandler: RunDeviceCancelHandler while (true) { const result = knownTargets ? { targets: knownTargets } - : getCapacitorRunTargetList(pm.runner, 'ios') + : await getCapacitorRunTargetListWithStatus(pm, 'ios', 'Checking iOS Simulators...') knownTargets = undefined if ('error' in result && result.error) @@ -2665,7 +2677,7 @@ async function selectPhysicalIosRunTarget(cancelHandler: RunDeviceCancelHandler, pLog.info('Connect your iPhone or iPad, unlock it, and tap Trust if prompted.') while (true) { - const result = getCapacitorRunTargetList(pm.runner, 'ios') + const result = await getCapacitorRunTargetListWithStatus(pm, 'ios', 'Checking connected iOS devices...') if (result.error) pLog.warn(`Could not check connected iOS devices: ${formatError(result.error)}`) @@ -2738,7 +2750,7 @@ async function handleMissingCapacitorRunTargets(cancelHandler: RunDeviceCancelHa async function selectCapacitorRunTarget(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, platformName: PlatformChoice): Promise { while (true) { - const result = getCapacitorRunTargetList(pm.runner, platformName) + const result = await getCapacitorRunTargetListWithStatus(pm, platformName, 'Checking available Android devices and emulators...') if (result.error) pLog.warn(`Could not check available devices: ${formatError(result.error)}`) diff --git a/src/run/device.ts b/src/run/device.ts index af249e22..d3f8ea0c 100644 --- a/src/run/device.ts +++ b/src/run/device.ts @@ -3,6 +3,7 @@ import { exit, stdin, stdout } from 'node:process' import { cancel as clackCancel, isCancel as clackIsCancel, log as clackLog, select as clackSelect } from '@clack/prompts' import { normalizeRunDevicePlatform, resolveRunDeviceCommand, runPackageRunnerSync } from '../init/command' import { cancel as pCancel, log as pLog, outro as pOutro, spinner as pSpinner } from '../init/prompts' +import { setInitScreen } from '../init/runtime' import { formatRunnerCommand } from '../runner-command' import { formatError, getPMAndCommand } from '../utils' @@ -70,6 +71,23 @@ async function selectRunDevicePlatform(platformName: string | undefined, interac return selectedPlatform as PlatformChoice } +function setRunDeviceScreen(platformName: PlatformChoice): void { + const platformLabel = platformName === 'ios' ? 'iOS' : 'Android' + setInitScreen({ + headerTitle: 'šŸ“± Capgo Run Device', + title: 'Run Device', + introLines: [ + `${platformLabel} selected.`, + platformName === 'ios' + ? 'Pick a physical device or simulator.' + : 'Pick a device or emulator.', + ], + phaseLabel: 'Device', + statusLine: 'Reload the list if your target is not visible yet.', + tone: 'blue', + }) +} + function runResolvedDeviceCommand(pm: ReturnType, runCommand: { args: string[], command: string }, interactive: boolean): void { if (interactive) { const s = pSpinner() @@ -125,6 +143,7 @@ export async function testRunDeviceCommand(platformName?: string, options: RunDe return } + setRunDeviceScreen(platformNameChoice) const runCommand = await resolveRunDeviceCommand(exitCanceledRunDeviceTest, pm, platformNameChoice) if (!runCommand.args) { finishRunDeviceTest(`Skipped device launch. Manual command: ${runCommand.command}`, interactive) diff --git a/test/test-onboarding-run-targets.mjs b/test/test-onboarding-run-targets.mjs index 22d46d17..6812c60e 100644 --- a/test/test-onboarding-run-targets.mjs +++ b/test/test-onboarding-run-targets.mjs @@ -37,6 +37,18 @@ test('parses Capacitor run target list output', () => { ]) }) +test('parses Android device and emulator targets', () => { + const targets = parseCapacitorRunTargetList(JSON.stringify([ + { name: 'Google sdk_gphone16k_arm64', api: 'API 37', id: 'emulator-5554' }, + { name: 'Pixel 9a (emulator)', api: 'API 37.0', id: 'Pixel_9a' }, + ])) + + assert.deepEqual(targets, [ + { name: 'Google sdk_gphone16k_arm64', api: 'API 37', id: 'emulator-5554' }, + { name: 'Pixel 9a (emulator)', api: 'API 37.0', id: 'Pixel_9a' }, + ]) +}) + test('returns an empty target list for malformed Capacitor output', () => { assert.deepEqual(parseCapacitorRunTargetList(''), []) assert.deepEqual(parseCapacitorRunTargetList('not json'), []) From 2721990aed859c1073fe8fb78f8926cddbf644b1 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 28 Apr 2026 18:48:37 +0200 Subject: [PATCH 13/15] fix(run): keep ink prompts alive --- src/init/runtime.tsx | 6 +++ src/run/device.ts | 90 ++++++++++++++++++++++++-------------------- 2 files changed, 56 insertions(+), 40 deletions(-) diff --git a/src/init/runtime.tsx b/src/init/runtime.tsx index 55c34e7f..568796b0 100644 --- a/src/init/runtime.tsx +++ b/src/init/runtime.tsx @@ -115,6 +115,7 @@ let state: InitRuntimeState = { const listeners = new Set<() => void>() let inkApp: ReturnType | undefined let started = false +let keepAliveTimer: ReturnType | undefined function emit() { listeners.forEach(listener => listener()) @@ -157,9 +158,14 @@ export function ensureInitInkSession() { subscribe, updatePromptError, })) + keepAliveTimer ??= setInterval(() => {}, 1000) } export function stopInitInkSession(finalMessage?: { text: string, tone: 'green' | 'yellow' }) { + if (keepAliveTimer) { + clearInterval(keepAliveTimer) + keepAliveTimer = undefined + } if (inkApp) { inkApp.unmount() inkApp = undefined diff --git a/src/run/device.ts b/src/run/device.ts index d3f8ea0c..d4cea49e 100644 --- a/src/run/device.ts +++ b/src/run/device.ts @@ -11,6 +11,31 @@ interface RunDeviceTestOptions { launch?: boolean } +interface RunDeviceOutput { + fail: (message: string) => never + finish: (message: string) => void +} + +const interactiveRunDeviceOutput: RunDeviceOutput = { + fail(message: string): never { + pCancel(message) + exit(1) + }, + finish(message: string): void { + pOutro(message) + }, +} + +const nonInteractiveRunDeviceOutput: RunDeviceOutput = { + fail(message: string): never { + clackLog.error(message) + exit(1) + }, + finish(message: string): void { + clackLog.info(message) + }, +} + async function exitCanceledRunDeviceTest(): Promise { pOutro('Run device test canceled.') exit(1) @@ -28,21 +53,6 @@ function handleNonInteractiveIosRunDevice(pm: ReturnType exit(1) } -function failRunDeviceTest(message: string, interactive: boolean): never { - if (interactive) - pCancel(message) - else - clackLog.error(message) - exit(1) -} - -function finishRunDeviceTest(message: string, interactive: boolean): void { - if (interactive) - pOutro(message) - else - clackLog.info(message) -} - function getNonInteractiveRunDeviceCommand(pm: ReturnType, platformName: PlatformChoice): { args: string[], command: string } { const args = ['cap', 'run', platformName] return { args, command: formatRunnerCommand(pm.runner, args) } @@ -88,26 +98,25 @@ function setRunDeviceScreen(platformName: PlatformChoice): void { }) } -function runResolvedDeviceCommand(pm: ReturnType, runCommand: { args: string[], command: string }, interactive: boolean): void { - if (interactive) { - const s = pSpinner() - s.start(`Running: ${runCommand.command}`) - - const runResult = runPackageRunnerSync(pm.runner, runCommand.args, { stdio: 'inherit' }) - const runFailed = runResult.error || runResult.status !== 0 +function runResolvedDeviceCommandInteractive(pm: ReturnType, runCommand: { args: string[], command: string }): void { + const s = pSpinner() + s.start(`Running: ${runCommand.command}`) - if (runFailed) { - s.stop('App failed to start āŒ') - if (runResult.error) - pLog.error(formatError(runResult.error)) - pLog.info(`You can run the command manually with: ${runCommand.command}`) - failRunDeviceTest('Run device test failed.', interactive) - } + const runResult = runPackageRunnerSync(pm.runner, runCommand.args, { stdio: 'inherit' }) + const runFailed = runResult.error || runResult.status !== 0 - s.stop('App started āœ…') - return + if (runFailed) { + s.stop('App failed to start āŒ') + if (runResult.error) + pLog.error(formatError(runResult.error)) + pLog.info(`You can run the command manually with: ${runCommand.command}`) + interactiveRunDeviceOutput.fail('Run device test failed.') } + s.stop('App started āœ…') +} + +function runResolvedDeviceCommandNonInteractive(pm: ReturnType, runCommand: { args: string[], command: string }): void { clackLog.info(`Running: ${runCommand.command}`) const runResult = runPackageRunnerSync(pm.runner, runCommand.args, { stdio: 'inherit' }) const runFailed = runResult.error || runResult.status !== 0 @@ -116,7 +125,7 @@ function runResolvedDeviceCommand(pm: ReturnType, runCom if (runResult.error) clackLog.error(formatError(runResult.error)) clackLog.info(`You can run the command manually with: ${runCommand.command}`) - failRunDeviceTest('Run device test failed.', interactive) + nonInteractiveRunDeviceOutput.fail('Run device test failed.') } clackLog.info('App started') @@ -124,6 +133,7 @@ function runResolvedDeviceCommand(pm: ReturnType, runCom export async function testRunDeviceCommand(platformName?: string, options: RunDeviceTestOptions = {}) { const interactive = canSelectRunDeviceTargetInteractively() + const output = interactive ? interactiveRunDeviceOutput : nonInteractiveRunDeviceOutput try { const pm = getPMAndCommand() const platformNameChoice = await selectRunDevicePlatform(platformName, interactive) @@ -131,34 +141,34 @@ export async function testRunDeviceCommand(platformName?: string, options: RunDe if (!interactive) { const runCommand = getNonInteractiveRunDeviceCommand(pm, platformNameChoice) if (options.launch === false) { - finishRunDeviceTest(`Resolved run command: ${runCommand.command}`, interactive) + output.finish(`Resolved run command: ${runCommand.command}`) return } if (platformNameChoice === 'ios') handleNonInteractiveIosRunDevice(pm) - runResolvedDeviceCommand(pm, runCommand, interactive) - finishRunDeviceTest(`Run device test finished. Manual command: ${runCommand.command}`, interactive) + runResolvedDeviceCommandNonInteractive(pm, runCommand) + output.finish(`Run device test finished. Manual command: ${runCommand.command}`) return } setRunDeviceScreen(platformNameChoice) const runCommand = await resolveRunDeviceCommand(exitCanceledRunDeviceTest, pm, platformNameChoice) if (!runCommand.args) { - finishRunDeviceTest(`Skipped device launch. Manual command: ${runCommand.command}`, interactive) + output.finish(`Skipped device launch. Manual command: ${runCommand.command}`) return } if (options.launch === false) { - finishRunDeviceTest(`Resolved run command: ${runCommand.command}`, interactive) + output.finish(`Resolved run command: ${runCommand.command}`) return } - runResolvedDeviceCommand(pm, runCommand, interactive) - finishRunDeviceTest(`Run device test finished. Manual command: ${runCommand.command}`, interactive) + runResolvedDeviceCommandInteractive(pm, runCommand) + output.finish(`Run device test finished. Manual command: ${runCommand.command}`) } catch (error) { - failRunDeviceTest(`Run device test failed: ${formatError(error)}`, interactive) + output.fail(`Run device test failed: ${formatError(error)}`) } } From c9c3aca2e0c8d2a0bc2ca6d1e45567b1267dab11 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 28 Apr 2026 18:53:11 +0200 Subject: [PATCH 14/15] fix(init): use secure random app id suffix --- src/init/command.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/init/command.ts b/src/init/command.ts index e026d59f..d7ea9768 100644 --- a/src/init/command.ts +++ b/src/init/command.ts @@ -4,6 +4,7 @@ import type { Options, PendingOnboardingApp } from '../api/app' import type { Organization } from '../utils' import type { InitCodeDiff, InitEncryptionPhase, InitEncryptionSummary } from './runtime' import { execSync, spawn, spawnSync } from 'node:child_process' +import { randomBytes } from 'node:crypto' import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import path, { dirname, join } from 'node:path' import { chdir, cwd, env, exit, platform, stdin, stdout } from 'node:process' @@ -1468,7 +1469,7 @@ async function addAppStep(organization: Organization, apikey: string, appId: str // Generate alternative suggestions with validation const rawSuggestions = [ - `${appId}-${Math.random().toString(36).substring(2, 6)}`, + `${appId}-${randomBytes(2).toString('hex')}`, `${appId}.dev`, `${appId}.app`, `${appId}-${Date.now().toString().slice(-4)}`, From 7c4f807bbcd009ee140b84e5c465efe2d550e9fa Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 28 Apr 2026 19:11:10 +0200 Subject: [PATCH 15/15] fix(run): handle unavailable device lists --- src/init/command.ts | 188 ++++++++++++++++++++++----- src/init/ui/app.tsx | 11 +- test/test-onboarding-run-targets.mjs | 10 ++ 3 files changed, 169 insertions(+), 40 deletions(-) diff --git a/src/init/command.ts b/src/init/command.ts index d7ea9768..2f4562b7 100644 --- a/src/init/command.ts +++ b/src/init/command.ts @@ -2324,6 +2324,20 @@ export interface CapacitorRunTarget { id: string } +interface CapacitorRunTargetListResult { + targets: CapacitorRunTarget[] + error?: Error +} + +interface PackageRunnerListResult { + stdout: string + stderr: string + status: number | null + signal: NodeJS.Signals | null + error?: Error + timedOut?: boolean +} + const iosRunTargetActions = { refresh: '__refresh__', simulator: '__simulator__', @@ -2331,6 +2345,7 @@ const iosRunTargetActions = { } as const const IOS_SIMULATOR_TARGET_SUFFIX_RE = /\(simulator\)$/i const INVALID_CAPACITOR_RUN_TARGET_IDS = new Set(['?']) +const DEFAULT_CAPACITOR_RUN_TARGET_LIST_TIMEOUT_MS = 30_000 type IosRunTargetResolution = RunDeviceStepOutcome | typeof iosRunTargetActions.refresh | typeof iosRunTargetActions.simulator type IosSimulatorRunTargetResolution = RunDeviceStepOutcome | typeof iosRunTargetActions.refresh type CapacitorRunTargetResolution = RunDeviceStepOutcome | typeof iosRunTargetActions.refresh @@ -2464,22 +2479,38 @@ function getSpawnOutputText(output: string | Buffer | null | undefined): string } export function parseCapacitorRunTargetList(output: string): CapacitorRunTarget[] { + return parseCapacitorRunTargetListResult(output).targets +} + +function extractCapacitorRunTargetJson(output: string): string { const trimmed = output.trim() + if (!trimmed || trimmed.startsWith('[')) + return trimmed + + const jsonStart = trimmed.indexOf('[') + const jsonEnd = trimmed.lastIndexOf(']') + return jsonStart >= 0 && jsonEnd > jsonStart + ? trimmed.slice(jsonStart, jsonEnd + 1) + : trimmed +} + +function parseCapacitorRunTargetListResult(output: string): CapacitorRunTargetListResult { + const trimmed = extractCapacitorRunTargetJson(output) if (!trimmed) - return [] + return { targets: [], error: new Error('Capacitor returned no target list output.') } let parsed: unknown try { parsed = JSON.parse(trimmed) } catch { - return [] + return { targets: [], error: new Error('Capacitor returned an invalid target list.') } } if (!Array.isArray(parsed)) - return [] + return { targets: [], error: new Error('Capacitor returned target list data in an unexpected format.') } - return parsed + const targets = parsed .filter((target): target is Record => typeof target === 'object' && target !== null) .map((target) => { const id = typeof target.id === 'string' ? target.id.trim() : '' @@ -2492,6 +2523,8 @@ export function parseCapacitorRunTargetList(output: string): CapacitorRunTarget[ } }) .filter((target): target is CapacitorRunTarget => target.id.length > 0 && !INVALID_CAPACITOR_RUN_TARGET_IDS.has(target.id) && target.name.length > 0) + + return { targets } } export function getPhysicalIosRunTargets(targets: CapacitorRunTarget[]): CapacitorRunTarget[] { @@ -2502,38 +2535,131 @@ export function getSimulatorIosRunTargets(targets: CapacitorRunTarget[]): Capaci return targets.filter(target => IOS_SIMULATOR_TARGET_SUFFIX_RE.test(target.name)) } -function getCapacitorRunTargetList(runner: string, platformName: PlatformChoice): { targets: CapacitorRunTarget[], error?: Error } { +function getCapacitorRunTargetListTimeoutMs(): number { + const rawTimeout = env.CAPGO_RUN_DEVICE_LIST_TIMEOUT_MS + if (!rawTimeout) + return DEFAULT_CAPACITOR_RUN_TARGET_LIST_TIMEOUT_MS + + const timeout = Number.parseInt(rawTimeout, 10) + return Number.isFinite(timeout) && timeout > 0 + ? timeout + : DEFAULT_CAPACITOR_RUN_TARGET_LIST_TIMEOUT_MS +} + +function runPackageRunnerForTargetList(runner: string, args: string[], timeoutMs: number): Promise { + let parsedRunner: ReturnType try { - const result = runPackageRunnerSync(runner, ['cap', 'run', platformName, '--list', '--json'], { - stdio: 'pipe', - encoding: 'utf8', + parsedRunner = splitRunnerCommand(runner) + } + catch (error) { + return Promise.resolve({ + stdout: '', + stderr: '', + status: null, + signal: null, + error: error instanceof Error ? error : new Error(String(error)), }) + } - if (result.error) - return { targets: [], error: result.error } + return new Promise((resolve) => { + let stdoutText = '' + let stderrText = '' + let settled = false + let timeout: ReturnType | undefined - if (result.status !== 0) { - const stderr = getSpawnOutputText(result.stderr) - const stdout = getSpawnOutputText(result.stdout) - return { targets: [], error: new Error(stderr || stdout || `cap run ${platformName} --list exited with code ${result.status ?? 'unknown'}`) } + const finish = (result: Omit) => { + if (settled) + return + settled = true + if (timeout) + clearTimeout(timeout) + resolve({ + stdout: stdoutText, + stderr: stderrText, + ...result, + }) } - return { targets: parseCapacitorRunTargetList(getSpawnOutputText(result.stdout)) } + const child = spawn(parsedRunner.command, [...parsedRunner.args, ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + }) + + timeout = setTimeout(() => { + child.kill('SIGTERM') + finish({ + status: null, + signal: 'SIGTERM', + timedOut: true, + }) + }, timeoutMs) + + child.stdout?.on('data', chunk => stdoutText += chunk.toString()) + child.stderr?.on('data', chunk => stderrText += chunk.toString()) + child.on('error', error => finish({ + status: null, + signal: null, + error, + })) + child.on('close', (status, signal) => finish({ + status, + signal, + })) + }) +} + +function createCapacitorRunTargetListError(runner: string, platformName: PlatformChoice, result: PackageRunnerListResult): Error { + const args = ['cap', 'run', platformName, '--list', '--json'] + const command = formatRunnerCommand(runner, args) + const output = getSpawnOutputText(result.stderr) || getSpawnOutputText(result.stdout) + + if (result.timedOut) { + const seconds = Math.max(1, Math.ceil(getCapacitorRunTargetListTimeoutMs() / 1000)) + return new Error(`Timed out after ${seconds}s while checking ${platformName} targets. Run manually to see the native error: ${command}`) + } + + if (result.error) + return new Error(`${formatError(result.error)}. Run manually to see the native error: ${command}`) + + return new Error(output || `cap run ${platformName} --list exited with code ${result.status ?? 'unknown'}. Run manually to see the native error: ${command}`) +} + +async function getCapacitorRunTargetList(runner: string, platformName: PlatformChoice): Promise { + try { + const args = ['cap', 'run', platformName, '--list', '--json'] + const result = await runPackageRunnerForTargetList(runner, args, getCapacitorRunTargetListTimeoutMs()) + + if (result.timedOut || result.error) + return { targets: [], error: createCapacitorRunTargetListError(runner, platformName, result) } + + if (result.status !== 0) + return { targets: [], error: createCapacitorRunTargetListError(runner, platformName, result) } + + const parseResult = parseCapacitorRunTargetListResult(getSpawnOutputText(result.stdout)) + if (parseResult.error) + return { targets: [], error: new Error(`${formatError(parseResult.error)} Run manually to see the native output: ${formatRunnerCommand(runner, args)}`) } + + return parseResult } catch (error) { return { targets: [], error: error instanceof Error ? error : new Error(String(error)) } } } -async function getCapacitorRunTargetListWithStatus(pm: PackageManagerInfo, platformName: PlatformChoice, message: string): Promise<{ targets: CapacitorRunTarget[], error?: Error }> { +async function getCapacitorRunTargetListWithStatus(pm: PackageManagerInfo, platformName: PlatformChoice, message: string): Promise { const s = pSpinner() s.start(message) await new Promise(resolve => setTimeout(resolve, 0)) try { - return getCapacitorRunTargetList(pm.runner, platformName) + const result = await getCapacitorRunTargetList(pm.runner, platformName) + if (result.error) + s.stop('Device check failed āŒ', 'error') + else + s.stop() + return result } - finally { - s.stop() + catch (error) { + s.stop('Device check failed āŒ', 'error') + return { targets: [], error: error instanceof Error ? error : new Error(String(error)) } } } @@ -2582,10 +2708,10 @@ async function handlePhysicalIosRunTargets(cancelHandler: RunDeviceCancelHandler return iosRunTargetActions.refresh } -async function handleMissingPhysicalIosRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo): Promise { - pLog.warn('No physical iOS device detected yet.') +async function handleMissingPhysicalIosRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, listError?: Error): Promise { + pLog.warn(listError ? 'The iOS device list is unavailable right now.' : 'No physical iOS device detected yet.') const nextAction = await pSelect({ - message: 'Connect and unlock your iPhone, then check again.', + message: listError ? 'Fix the error above, then reload the list.' : 'Connect and unlock your iPhone, then check again.', options: [ { value: iosRunTargetActions.refresh, label: 'Reload list' }, { value: iosRunTargetActions.simulator, label: 'Use iOS Simulator instead' }, @@ -2633,10 +2759,10 @@ async function handleSimulatorIosRunTargets(cancelHandler: RunDeviceCancelHandle return iosRunTargetActions.refresh } -async function handleMissingSimulatorIosRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo): Promise { - pLog.warn('No iOS Simulator detected yet.') +async function handleMissingSimulatorIosRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, listError?: Error): Promise { + pLog.warn(listError ? 'The iOS Simulator list is unavailable right now.' : 'No iOS Simulator detected yet.') const nextAction = await pSelect({ - message: 'Open Xcode or install a simulator, then check again.', + message: listError ? 'Fix the error above, then reload the list.' : 'Open Xcode or install a simulator, then check again.', options: [ { value: iosRunTargetActions.refresh, label: 'Reload list' }, { value: iosRunTargetActions.skip, label: 'Skip running now' }, @@ -2667,7 +2793,7 @@ async function selectSimulatorIosRunTarget(cancelHandler: RunDeviceCancelHandler const selectionResult = simulatorTargets.length > 0 ? await handleSimulatorIosRunTargets(cancelHandler, pm, simulatorTargets) - : await handleMissingSimulatorIosRunTargets(cancelHandler, pm) + : await handleMissingSimulatorIosRunTargets(cancelHandler, pm, result.error) if (selectionResult === iosRunTargetActions.refresh) continue return selectionResult @@ -2686,7 +2812,7 @@ async function selectPhysicalIosRunTarget(cancelHandler: RunDeviceCancelHandler, const selectionResult = physicalTargets.length > 0 ? await handlePhysicalIosRunTargets(cancelHandler, pm, physicalTargets) - : await handleMissingPhysicalIosRunTargets(cancelHandler, pm) + : await handleMissingPhysicalIosRunTargets(cancelHandler, pm, result.error) if (selectionResult === iosRunTargetActions.refresh) continue if (selectionResult === iosRunTargetActions.simulator) @@ -2730,11 +2856,11 @@ async function handleCapacitorRunTargets(cancelHandler: RunDeviceCancelHandler, return iosRunTargetActions.refresh } -async function handleMissingCapacitorRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, platformName: PlatformChoice): Promise { +async function handleMissingCapacitorRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, platformName: PlatformChoice, listError?: Error): Promise { const targetLabel = getRunTargetLabel(platformName) - pLog.warn(`No ${targetLabel} detected yet.`) + pLog.warn(listError ? `The ${targetLabel} list is unavailable right now.` : `No ${targetLabel} detected yet.`) const nextAction = await pSelect({ - message: 'Connect or start one, then reload the list.', + message: listError ? 'Fix the error above, then reload the list.' : 'Connect or start one, then reload the list.', options: [ { value: iosRunTargetActions.refresh, label: 'Reload list' }, { value: iosRunTargetActions.skip, label: 'Skip running now' }, @@ -2757,7 +2883,7 @@ async function selectCapacitorRunTarget(cancelHandler: RunDeviceCancelHandler, p const selectionResult = result.targets.length > 0 ? await handleCapacitorRunTargets(cancelHandler, pm, platformName, result.targets) - : await handleMissingCapacitorRunTargets(cancelHandler, pm, platformName) + : await handleMissingCapacitorRunTargets(cancelHandler, pm, platformName, result.error) if (selectionResult === iosRunTargetActions.refresh) continue return selectionResult diff --git a/src/init/ui/app.tsx b/src/init/ui/app.tsx index e0933336..ec4910b7 100644 --- a/src/init/ui/app.tsx +++ b/src/init/ui/app.tsx @@ -2,7 +2,7 @@ import type { InitCodeDiff, InitEncryptionSummary, InitRuntimeState, InitStreami import { Alert } from '@inkjs/ui' import { Box, Text, useStdout } from 'ink' import Spinner from 'ink-spinner' -import React, { useEffect, useState } from 'react' +import React, { useSyncExternalStore } from 'react' import { CurrentStepSection, InitHeader, ProgressSection, PromptArea, ScreenIntro, SpinnerArea } from './components' function StreamingOutputPanel({ output, width, rows }: Readonly<{ output: InitStreamingOutput, width: number, rows: number }>) { @@ -147,7 +147,7 @@ interface InitInkAppProps { } export default function InitInkApp({ getSnapshot, subscribe, updatePromptError }: Readonly) { - const [snapshot, setSnapshot] = useState(getSnapshot()) + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) const { stdout } = useStdout() const columns = stdout?.columns ?? 96 const rows = stdout?.rows ?? 24 @@ -193,13 +193,6 @@ export default function InitInkApp({ getSnapshot, subscribe, updatePromptError } const visibleLogs = visibleLogCount === 0 ? [] : snapshot.logs.slice(-visibleLogCount) const screen = snapshot.screen - useEffect(() => { - const unsubscribe = subscribe(() => setSnapshot(getSnapshot())) - return () => { - unsubscribe() - } - }, [getSnapshot, subscribe]) - // When a streaming command is running we hand the entire viewport over to // the streaming panel — no progress bar, no logs, no prompt. This keeps // long-lived `cap sync` output visible without fighting the normal diff --git a/test/test-onboarding-run-targets.mjs b/test/test-onboarding-run-targets.mjs index 6812c60e..2c1b21de 100644 --- a/test/test-onboarding-run-targets.mjs +++ b/test/test-onboarding-run-targets.mjs @@ -49,6 +49,16 @@ test('parses Android device and emulator targets', () => { ]) }) +test('parses Capacitor JSON output with package manager warnings', () => { + const targets = parseCapacitorRunTargetList(`npm warn Unknown project config "shamefully-hoist". +npm warn Unknown project config "strict-peer-dependencies". +[{"name":"iPhone martin","api":"iOS 26.4.2","id":"00008140-000931C01442801C"}]`) + + assert.deepEqual(targets, [ + { name: 'iPhone martin', api: 'iOS 26.4.2', id: '00008140-000931C01442801C' }, + ]) +}) + test('returns an empty target list for malformed Capacitor output', () => { assert.deepEqual(parseCapacitorRunTargetList(''), []) assert.deepEqual(parseCapacitorRunTargetList('not json'), [])