diff --git a/README.md b/README.md
index 5e01d989..f3036413 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,8 @@ Follow the documentation here: https://capacitorjs.com/docs/getting-started/
## š Table of Contents
- š [Init](#init)
+- š± [Run](#run)
+ - [Device](#run-device)
- š¹ [Star](#star)
- š¹ [Star-all](#star-all)
- šØāāļø [Doctor](#doctor)
@@ -86,6 +88,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:**
@@ -93,7 +96,7 @@ Capgo bundles are web assets and can be fetched by anyone who knows the URL. Use
npx @capgo/cli@latest init YOUR_API_KEY com.example.app
```
-## Options
+### Options
| Param | Type | Description |
| -------------- | ------------- | -------------------- |
@@ -103,6 +106,35 @@ npx @capgo/cli@latest init YOUR_API_KEY com.example.app
| **--supa-anon** | string | Custom Supabase anon key (for self-hosting) |
+## š± **Run**
+
+š± Run Capacitor apps on devices from the CLI.
+
+### š¹ **Device**
+
+```bash
+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, 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.
+
+**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
@@ -122,7 +154,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 |
| -------------- | ------------- | -------------------- |
@@ -146,7 +178,7 @@ This command helps diagnose issues with your setup.
npx @capgo/cli@latest doctor
```
-## Options
+### Options
| Param | Type | Description |
| -------------- | ------------- | -------------------- |
@@ -170,7 +202,7 @@ Use --apikey=******** in any command to override it.
npx @capgo/cli@latest login YOUR_API_KEY
```
-## Options
+### Options
| Param | Type | Description |
| -------------- | ------------- | -------------------- |
@@ -1223,7 +1255,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/package.json b/package.json
index b94ba905..0e74aba1 100644
--- a/package.json
+++ b/package.json
@@ -73,6 +73,8 @@
"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:run-device-command": "bun test/test-run-device-command.mjs",
"test:init-app-conflict": "bun test/test-init-app-conflict.mjs",
"test:prompt-preferences": "bun test/test-prompt-preferences.mjs",
"test:esm-sdk": "node test/test-sdk-esm.mjs",
@@ -81,7 +83,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:init-app-conflict && 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:init-app-conflict && 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 6008443c..4160e078 100644
--- a/skills/usage/SKILL.md
+++ b/skills/usage/SKILL.md
@@ -24,7 +24,8 @@ 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 local bundle ID already exists in the selected Capgo account, onboarding offers to reuse that app, then offers to delete and recreate it, then falls back to alternate bundle ID suggestions. If the user reuses a pending app that was already created in the web onboarding flow, the CLI syncs that selected dashboard app ID back into `capacitor.config.*` before the remaining steps continue. Outside that reused pending-app path, the CLI keeps using the local Capacitor app ID. It can also offer a final `npx skills add https://github.com/Cap-go/capgo-skills -g -y` install step before the GitHub support prompt; if accepted, the support menu includes `Cap-go/capgo-skills` alongside the updater-only and all-Capgo choices. If native platforms are missing, the onboarding can offer to run `cap add` for you. The updater step now verifies that `@capgo/capacitor-updater` is both declared in the selected `package.json` and resolvable from `node_modules`; if automatic install or later build/sync fails, onboarding prints the manual command, waits for the user to type `ready`, re-checks, and only then continues. If 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 local bundle ID already exists in the selected Capgo account, onboarding offers to reuse that app, then offers to delete and recreate it, then falls back to alternate bundle ID suggestions. If the user reuses a pending app that was already created in the web onboarding flow, the CLI syncs that selected dashboard app ID back into `capacitor.config.*` before the remaining steps continue. Outside that reused pending-app path, the CLI keeps using the local Capacitor app ID. It can also offer a final `npx skills add https://github.com/Cap-go/capgo-skills -g -y` install step before the GitHub support prompt; if accepted, the support menu includes `Cap-go/capgo-skills` alongside the updater-only and all-Capgo choices. If native platforms are missing, the onboarding can offer to run `cap add` for you. The updater step now verifies that `@capgo/capacitor-updater` is both declared in the selected `package.json` and resolvable from `node_modules`; if automatic install or later build/sync fails, onboarding prints the manual command, waits for the user to type `ready`, re-checks, and only then continues. 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 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.
@@ -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..8fe9eadf 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 === 'run')
+ emoji = 'š±'
else if (cmdName.includes('doctor'))
emoji = 'šØāāļø'
else if (cmdName.includes('login'))
@@ -195,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 c65ab485..89d1d1bd 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -35,6 +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 { getUserId } from './user/account'
import { formatError } from './utils'
@@ -65,6 +66,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 +75,23 @@ Example: npx @capgo/cli@latest init YOUR_API_KEY com.example.app`)
.option('--supa-host ', optionDescriptions.supaHost)
.option('--supa-anon ', optionDescriptions.supaAnon)
+const run = program
+ .command('run')
+ .description(`š± Run Capacitor apps on devices from the CLI.`)
+
+run
+ .command('device [platform]')
+ .description(`š± Run your Capacitor app on a connected device or simulator.
+
+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.
+
+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 4bf84169..3a726ff6 100644
--- a/src/init/command.ts
+++ b/src/init/command.ts
@@ -37,6 +37,8 @@ import { CAPGO_UPDATER_PACKAGE, getUpdaterInstallState } from './updater'
interface SuperOptions extends Options {
local: boolean
}
+
+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 '
@@ -2508,8 +2510,41 @@ function promoteEncryptionSummaryToEnabled(): void {
}
type PackageManagerInfo = ReturnType
-type PlatformChoice = 'ios' | 'android'
+export type PlatformChoice = 'ios' | 'android'
type BuildProjectStepOutcome = 'completed' | 'skipped'
+export type RunDeviceStepOutcome = { args: string[], command: string } | { args: undefined, command: string }
+
+export interface CapacitorRunTarget {
+ name: string
+ api: string | undefined
+ 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__',
+ skip: '__skip__',
+} 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
async function ensureNativePlatformForBuild(platform: PlatformChoice, config: CapacitorConfigSnapshot | undefined, runner: string): Promise {
const addPlatformCommand = formatRunnerCommand(runner, ['cap', 'add', platform])
@@ -2708,6 +2743,450 @@ async function buildProjectStep(orgId: string, apikey: string, appId: string, pl
await markStep(orgId, apikey, 'build-project', appId)
}
+export 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[] {
+ 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 { targets: [], error: new Error('Capacitor returned no target list output.') }
+
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(trimmed)
+ }
+ catch {
+ return { targets: [], error: new Error('Capacitor returned an invalid target list.') }
+ }
+
+ if (!Array.isArray(parsed))
+ return { targets: [], error: new Error('Capacitor returned target list data in an unexpected format.') }
+
+ const targets = 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 && !INVALID_CAPACITOR_RUN_TARGET_IDS.has(target.id) && target.name.length > 0)
+
+ return { targets }
+}
+
+export function getPhysicalIosRunTargets(targets: CapacitorRunTarget[]): CapacitorRunTarget[] {
+ 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 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 {
+ parsedRunner = splitRunnerCommand(runner)
+ }
+ catch (error) {
+ return Promise.resolve({
+ stdout: '',
+ stderr: '',
+ status: null,
+ signal: null,
+ error: error instanceof Error ? error : new Error(String(error)),
+ })
+ }
+
+ return new Promise((resolve) => {
+ let stdoutText = ''
+ let stderrText = ''
+ let settled = false
+ let timeout: ReturnType | undefined
+
+ const finish = (result: Omit) => {
+ if (settled)
+ return
+ settled = true
+ if (timeout)
+ clearTimeout(timeout)
+ resolve({
+ stdout: stdoutText,
+ stderr: stderrText,
+ ...result,
+ })
+ }
+
+ 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 {
+ const s = pSpinner()
+ s.start(message)
+ await new Promise(resolve => setTimeout(resolve, 0))
+ try {
+ const result = await getCapacitorRunTargetList(pm.runner, platformName)
+ if (result.error)
+ s.stop('Device check failed ā', 'error')
+ else
+ s.stop()
+ return result
+ }
+ catch (error) {
+ s.stop('Device check failed ā', '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 handlePhysicalIosRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, physicalTargets: CapacitorRunTarget[]): Promise {
+ const selectedTarget = await pSelect({
+ 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: 'Reload list' },
+ { value: iosRunTargetActions.simulator, label: 'Use iOS Simulator instead' },
+ { value: iosRunTargetActions.skip, label: 'Skip running now' },
+ ],
+ })
+
+ if (pIsCancel(selectedTarget))
+ await cancelHandler()
+
+ if (selectedTarget === iosRunTargetActions.refresh)
+ return iosRunTargetActions.refresh
+ if (selectedTarget === iosRunTargetActions.simulator)
+ return iosRunTargetActions.simulator
+ 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.')
+ return iosRunTargetActions.refresh
+}
+
+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: 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' },
+ { value: iosRunTargetActions.skip, label: 'Skip running now' },
+ ],
+ })
+
+ if (pIsCancel(nextAction))
+ await cancelHandler()
+
+ if (nextAction === iosRunTargetActions.refresh)
+ return iosRunTargetActions.refresh
+ if (nextAction === iosRunTargetActions.simulator)
+ return iosRunTargetActions.simulator
+ return getSkippedRunDeviceCommand(pm, 'ios')
+}
+
+async function handleSimulatorIosRunTargets(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, simulatorTargets: CapacitorRunTarget[]): Promise {
+ const selectedTarget = await pSelect({
+ 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: '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, '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(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: 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' },
+ ],
+ })
+
+ if (pIsCancel(nextAction))
+ await cancelHandler()
+
+ if (nextAction === iosRunTargetActions.refresh)
+ return iosRunTargetActions.refresh
+ return getSkippedRunDeviceCommand(pm, 'ios')
+}
+
+async function selectSimulatorIosRunTarget(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, initialTargets?: CapacitorRunTarget[]): Promise {
+ let knownTargets = initialTargets
+
+ while (true) {
+ const result = knownTargets
+ ? { targets: knownTargets }
+ : await getCapacitorRunTargetListWithStatus(pm, 'ios', 'Checking iOS Simulators...')
+ knownTargets = undefined
+
+ if ('error' in result && result.error)
+ pLog.warn(`Could not check iOS Simulators: ${formatError(result.error)}`)
+
+ const simulatorTargets = getSimulatorIosRunTargets(result.targets)
+
+ const selectionResult = simulatorTargets.length > 0
+ ? await handleSimulatorIosRunTargets(cancelHandler, pm, simulatorTargets)
+ : await handleMissingSimulatorIosRunTargets(cancelHandler, pm, result.error)
+ if (selectionResult === iosRunTargetActions.refresh)
+ continue
+ return selectionResult
+ }
+}
+
+async function selectPhysicalIosRunTarget(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo): Promise {
+ pLog.info('Connect your iPhone or iPad, unlock it, and tap Trust if prompted.')
+
+ while (true) {
+ const result = await getCapacitorRunTargetListWithStatus(pm, 'ios', 'Checking connected iOS devices...')
+ if (result.error)
+ pLog.warn(`Could not check connected iOS devices: ${formatError(result.error)}`)
+
+ const physicalTargets = getPhysicalIosRunTargets(result.targets)
+
+ const selectionResult = physicalTargets.length > 0
+ ? await handlePhysicalIosRunTargets(cancelHandler, pm, physicalTargets)
+ : await handleMissingPhysicalIosRunTargets(cancelHandler, pm, result.error)
+ if (selectionResult === iosRunTargetActions.refresh)
+ continue
+ if (selectionResult === iosRunTargetActions.simulator)
+ return selectSimulatorIosRunTarget(cancelHandler, pm, result.targets)
+ return selectionResult
+ }
+}
+
+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, listError?: Error): Promise {
+ const targetLabel = getRunTargetLabel(platformName)
+ pLog.warn(listError ? `The ${targetLabel} list is unavailable right now.` : `No ${targetLabel} detected yet.`)
+ const nextAction = await pSelect({
+ 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' },
+ ],
+ })
+
+ 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 = await getCapacitorRunTargetListWithStatus(pm, platformName, 'Checking available Android devices and emulators...')
+ 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, result.error)
+ if (selectionResult === iosRunTargetActions.refresh)
+ continue
+ return selectionResult
+ }
+}
+
+export async function resolveRunDeviceCommand(cancelHandler: RunDeviceCancelHandler, pm: PackageManagerInfo, platformName: PlatformChoice): Promise {
+ if (platformName !== 'ios')
+ return selectCapacitorRunTarget(cancelHandler, 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 cancelHandler()
+
+ if (targetKind === 'simulator')
+ return selectSimulatorIosRunTarget(cancelHandler, pm)
+
+ return selectPhysicalIosRunTarget(cancelHandler, pm)
+}
+
function getSelectablePlatformOptions(config?: CapacitorConfigSnapshot): Array<{ value: PlatformChoice, label: string }> {
const availablePlatforms = getNativePlatformAvailability(config)
const options: Array<{ value: PlatformChoice, label: string }> = []
@@ -2759,6 +3238,13 @@ async function handleMissingPlatformSelection(orgId: string, apikey: string, ava
pLog.warn(`Still could not add ${platformToAdd}.`)
}
+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".')
+}
+
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`)
@@ -2777,14 +3263,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(() => 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)
+ 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({
@@ -2794,12 +3297,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/src/init/runtime.tsx b/src/init/runtime.tsx
index c7ec54e0..568796b0 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
@@ -114,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())
@@ -156,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
@@ -170,8 +177,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..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
@@ -208,7 +201,7 @@ export default function InitInkApp({ getSnapshot, subscribe, updatePromptError }
if (snapshot.streamingOutput) {
return (
-
+ {screen ? : null}
-
+ {screen ? : null}
{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
new file mode 100644
index 00000000..d4cea49e
--- /dev/null
+++ b/src/run/device.ts
@@ -0,0 +1,174 @@
+import type { PlatformChoice } from '../init/command'
+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'
+
+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)
+}
+
+function canSelectRunDeviceTargetInteractively(): boolean {
+ return !!stdin.isTTY && !!stdout.isTTY
+}
+
+function handleNonInteractiveIosRunDevice(pm: ReturnType): never {
+ 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)
+}
+
+function getNonInteractiveRunDeviceCommand(pm: ReturnType, platformName: PlatformChoice): { args: string[], command: string } {
+ const args = ['cap', 'run', platformName]
+ return { args, command: formatRunnerCommand(pm.runner, args) }
+}
+
+async function selectRunDevicePlatform(platformName: string | undefined, interactive: boolean): Promise {
+ if (platformName)
+ return normalizeRunDevicePlatform(platformName)
+
+ if (!interactive)
+ throw new Error('No platform provided. Run in an interactive terminal to choose iOS or Android, or pass "ios" or "android".')
+
+ 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 (clackIsCancel(selectedPlatform)) {
+ clackCancel('Run device test canceled.')
+ exit(1)
+ }
+
+ 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 runResolvedDeviceCommandInteractive(pm: ReturnType, runCommand: { args: string[], command: string }): void {
+ 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}`)
+ 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
+
+ if (runFailed) {
+ if (runResult.error)
+ clackLog.error(formatError(runResult.error))
+ clackLog.info(`You can run the command manually with: ${runCommand.command}`)
+ nonInteractiveRunDeviceOutput.fail('Run device test failed.')
+ }
+
+ clackLog.info('App started')
+}
+
+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)
+
+ if (!interactive) {
+ const runCommand = getNonInteractiveRunDeviceCommand(pm, platformNameChoice)
+ if (options.launch === false) {
+ output.finish(`Resolved run command: ${runCommand.command}`)
+ return
+ }
+
+ if (platformNameChoice === 'ios')
+ handleNonInteractiveIosRunDevice(pm)
+
+ 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) {
+ output.finish(`Skipped device launch. Manual command: ${runCommand.command}`)
+ return
+ }
+
+ if (options.launch === false) {
+ output.finish(`Resolved run command: ${runCommand.command}`)
+ return
+ }
+
+ runResolvedDeviceCommandInteractive(pm, runCommand)
+ output.finish(`Run device test finished. Manual command: ${runCommand.command}`)
+ }
+ catch (error) {
+ output.fail(`Run device test failed: ${formatError(error)}`)
+ }
+}
diff --git a/test/test-onboarding-run-targets.mjs b/test/test-onboarding-run-targets.mjs
new file mode 100644
index 00000000..2c1b21de
--- /dev/null
+++ b/test/test-onboarding-run-targets.mjs
@@ -0,0 +1,99 @@
+#!/usr/bin/env node
+
+import assert from 'node:assert/strict'
+import {
+ getPhysicalIosRunTargets,
+ getSimulatorIosRunTargets,
+ 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' },
+ { name: 'Unresolved iPhone', api: 'iOS 26.0', id: '?' },
+ ]))
+
+ 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('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('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'), [])
+ 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' },
+ { 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' },
+ ])
+})
+
+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)
+}
+
+console.log('\nā
Onboarding run target handling works')
diff --git a/test/test-run-device-command.mjs b/test/test-run-device-command.mjs
new file mode 100644
index 00000000..14741eee
--- /dev/null
+++ b/test/test-run-device-command.mjs
@@ -0,0 +1,51 @@
+#!/usr/bin/env node
+
+import assert from 'node:assert/strict'
+import { spawnSync } from 'node:child_process'
+import { execPath } from 'node: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(execPath, ['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/)
+})
+
+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'],
+ })
+ const output = `${result.stdout}\n${result.stderr}`
+
+ assert.notEqual(result.status, 0, output)
+ assert.match(output, /No platform provided/)
+ assert.match(output, /choose iOS or Android/)
+})
+
+if (failures > 0) {
+ console.error(`\nā ${failures} run device command test(s) failed`)
+ process.exit(1)
+}
+
+console.log('\nā
Run device command handling works')
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 e75ff26e..efff76c0 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:**
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.mdx b/webdocs/run.mdx
new file mode 100644
index 00000000..827443f1
--- /dev/null
+++ b/webdocs/run.mdx
@@ -0,0 +1,33 @@
+---
+title: š± run
+description: "š± Run Capacitor apps on devices from the CLI."
+sidebar_label: run
+sidebar:
+ order: 2
+---
+
+š± Run Capacitor apps on devices from the CLI.
+
+### š¹ **Device**
+
+```bash
+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, 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.
+
+**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.