From 7203d5641443c5c9741f7284934f8da63a8b1617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 18 Mar 2026 12:26:32 +0100 Subject: [PATCH] feat: add install-from-source CLI command --- README.md | 4 +- skills/agent-device/SKILL.md | 1 + src/__tests__/cli-client-commands.test.ts | 155 ++++++++++++++++++ src/__tests__/client-shared.test.ts | 26 ++- src/cli-client-commands.ts | 59 +++++++ src/client-shared.ts | 19 +++ src/core/capabilities.ts | 4 + src/utils/__tests__/args.test.ts | 24 +++ src/utils/__tests__/cli-option-schema.test.ts | 28 ++++ src/utils/args.ts | 12 +- src/utils/cli-option-schema.ts | 18 ++ src/utils/command-schema.ts | 32 ++++ website/docs/docs/commands.md | 13 ++ website/docs/docs/introduction.md | 2 +- website/docs/docs/quick-start.md | 1 + 15 files changed, 394 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/cli-client-commands.test.ts diff --git a/README.md b/README.md index f4c3e2be8..6c701dee0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The project is in early development and considered experimental. Pull requests a ## Features - Platforms: iOS/tvOS (simulator + physical device core automation) and Android/AndroidTV (emulator + device). -- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `install`, `reinstall`, `push`, `trigger-app-event`. +- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `install`, `install-from-source`, `reinstall`, `push`, `trigger-app-event`. - Inspection commands: `snapshot` (accessibility tree), `diff snapshot` (structural baseline diff), `appstate`, `apps`, `devices`. - Clipboard commands: `clipboard read`, `clipboard write `. - Keyboard commands: `keyboard status|get|dismiss` (Android). @@ -344,8 +344,10 @@ Navigation helpers: - Use `boot` mainly when starting a new session and `open` fails because no booted simulator/emulator is available. - `open [app|url] [url]` already boots/activates the selected target when needed. - `install ` installs app binary without uninstalling first (Android + iOS simulator/device). +- `install-from-source ` installs from a URL source through the normal daemon artifact flow; repeat `--header name:value` for authenticated downloads. - `reinstall ` uninstalls and installs the app binary in one command (Android + iOS simulator/device). - `install`/`reinstall` accept package/bundle id style app names and support `~` in paths. +- `install-from-source` supports `--retain-paths` and `--retention-ms ` when callers need retained materialized artifact paths after the install. - When `AGENT_DEVICE_DAEMON_BASE_URL` targets a remote daemon, local `.apk`/`.aab`/`.ipa` files and `.app` bundles are uploaded automatically before `install`/`reinstall`. - Remote daemon clients can persist session-scoped runtime hints with `runtime set` before `open`; Android launches write React Native dev prefs, and iOS simulator launches write React Native bundle defaults before app start. Example: `agent-device runtime set --session my-session --platform android --metro-host 10.0.0.10 --metro-port 8081 --launch-url "myapp://dev"`. - Remote daemon screenshots and recordings are materialized back to the caller path instead of returning host-local daemon paths. diff --git a/skills/agent-device/SKILL.md b/skills/agent-device/SKILL.md index 3381168f9..ed85a7307 100644 --- a/skills/agent-device/SKILL.md +++ b/skills/agent-device/SKILL.md @@ -174,6 +174,7 @@ agent-device open [app|url] [url] agent-device open [app] --relaunch agent-device close [app] agent-device install +agent-device install-from-source [--header "name:value"] agent-device reinstall agent-device session list ``` diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts new file mode 100644 index 000000000..be2d4d9a6 --- /dev/null +++ b/src/__tests__/cli-client-commands.test.ts @@ -0,0 +1,155 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { tryRunClientBackedCommand } from '../cli-client-commands.ts'; +import type { AgentDeviceClient, AppInstallFromSourceOptions } from '../client.ts'; +import { AppError } from '../utils/errors.ts'; + +test('install-from-source forwards URL and repeated headers to client.apps.installFromSource', async () => { + let observed: AppInstallFromSourceOptions | undefined; + const client = createStubClient({ + installFromSource: async (options) => { + observed = options; + return { + launchTarget: 'com.example.demo', + packageName: 'com.example.demo', + identifiers: { appId: 'com.example.demo', package: 'com.example.demo' }, + }; + }, + }); + + const handled = await tryRunClientBackedCommand({ + command: 'install-from-source', + positionals: ['https://example.com/app.apk'], + flags: { + json: false, + help: false, + version: false, + platform: 'android', + header: ['authorization: Bearer token', 'x-build-id: 42'], + retainPaths: true, + retentionMs: 60_000, + }, + client, + }); + + assert.equal(handled, true); + assert.equal(observed?.platform, 'android'); + assert.equal(observed?.retainPaths, true); + assert.equal(observed?.retentionMs, 60_000); + assert.deepEqual(observed?.source, { + kind: 'url', + url: 'https://example.com/app.apk', + headers: { + authorization: 'Bearer token', + 'x-build-id': '42', + }, + }); +}); + +test('install-from-source rejects malformed header syntax', async () => { + const client = createStubClient({ + installFromSource: async () => { + throw new Error('unexpected call'); + }, + }); + + await assert.rejects( + () => + tryRunClientBackedCommand({ + command: 'install-from-source', + positionals: ['https://example.com/app.apk'], + flags: { + json: false, + help: false, + version: false, + header: ['authorization'], + }, + client, + }), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message.includes('Expected "name:value"'), + ); +}); + +function createStubClient(params: { + installFromSource: AgentDeviceClient['apps']['installFromSource']; +}): AgentDeviceClient { + return { + devices: { + list: async () => [], + }, + sessions: { + list: async () => [], + close: async () => ({ session: 'default', identifiers: { session: 'default' } }), + }, + simulators: { + ensure: async () => ({ + udid: 'sim-1', + device: 'iPhone 16', + runtime: 'iOS-18-0', + created: false, + booted: true, + identifiers: { + deviceId: 'sim-1', + deviceName: 'iPhone 16', + udid: 'sim-1', + }, + }), + }, + apps: { + install: async () => ({ + app: 'Demo', + appPath: '/tmp/Demo.app', + platform: 'ios', + identifiers: { appId: 'com.example.demo' }, + }), + reinstall: async () => ({ + app: 'Demo', + appPath: '/tmp/Demo.app', + platform: 'ios', + identifiers: { appId: 'com.example.demo' }, + }), + installFromSource: params.installFromSource, + open: async () => ({ + session: 'default', + identifiers: { session: 'default' }, + }), + close: async () => ({ + session: 'default', + identifiers: { session: 'default' }, + }), + }, + materializations: { + release: async (options) => ({ + released: true, + materializationId: options.materializationId, + identifiers: { session: options.session ?? 'default' }, + }), + }, + runtime: { + set: async () => ({ + session: 'default', + configured: true, + identifiers: { session: 'default' }, + }), + show: async () => ({ + session: 'default', + configured: false, + identifiers: { session: 'default' }, + }), + }, + capture: { + snapshot: async () => ({ + nodes: [], + truncated: false, + identifiers: { session: 'default' }, + }), + screenshot: async () => ({ + path: '/tmp/screenshot.png', + identifiers: { session: 'default' }, + }), + }, + }; +} diff --git a/src/__tests__/client-shared.test.ts b/src/__tests__/client-shared.test.ts index 33a523555..33976ebb2 100644 --- a/src/__tests__/client-shared.test.ts +++ b/src/__tests__/client-shared.test.ts @@ -1,6 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { serializeOpenResult, serializeSessionListEntry } from '../client-shared.ts'; +import { + serializeInstallFromSourceResult, + serializeOpenResult, + serializeSessionListEntry, +} from '../client-shared.ts'; test('serializeSessionListEntry preserves legacy android session payload shape', () => { const data = serializeSessionListEntry({ @@ -74,3 +78,23 @@ test('serializeOpenResult includes android serial for open payloads', () => { serial: 'emulator-5554', }); }); + +test('serializeInstallFromSourceResult uses install-family package naming', () => { + const data = serializeInstallFromSourceResult({ + launchTarget: 'com.example.demo', + appName: 'Demo', + appId: 'com.example.demo', + packageName: 'com.example.demo', + identifiers: { + appId: 'com.example.demo', + package: 'com.example.demo', + }, + }); + + assert.deepEqual(data, { + launchTarget: 'com.example.demo', + appName: 'Demo', + appId: 'com.example.demo', + package: 'com.example.demo', + }); +}); diff --git a/src/cli-client-commands.ts b/src/cli-client-commands.ts index e8b45625b..a8037fc45 100644 --- a/src/cli-client-commands.ts +++ b/src/cli-client-commands.ts @@ -9,6 +9,7 @@ import { serializeDeployResult, serializeDevice, serializeEnsureSimulatorResult, + serializeInstallFromSourceResult, serializeOpenResult, serializeRuntimeResult, serializeSessionListEntry, @@ -113,6 +114,11 @@ const clientCommandHandlers: Partial> = { if (flags.json) printJson({ success: true, data: serializeDeployResult(result) }); return true; }, + 'install-from-source': async ({ positionals, flags, client }) => { + const result = await runInstallFromSourceCommand(positionals, flags, client); + if (flags.json) printJson({ success: true, data: serializeInstallFromSourceResult(result) }); + return true; + }, open: async ({ positionals, flags, client }) => { if (!positionals[0]) { return false; @@ -239,6 +245,59 @@ async function runDeployCommand( : await client.apps.reinstall(options); } +async function runInstallFromSourceCommand( + positionals: string[], + flags: CliFlags, + client: AgentDeviceClient, +) { + const url = positionals[0]?.trim(); + if (!url) { + throw new AppError('INVALID_ARGS', 'install-from-source requires: install-from-source '); + } + if (positionals.length > 1) { + throw new AppError( + 'INVALID_ARGS', + 'install-from-source accepts exactly one positional argument: ', + ); + } + return await client.apps.installFromSource({ + ...buildSelectionOptions(flags), + retainPaths: flags.retainPaths, + retentionMs: flags.retentionMs, + source: { + kind: 'url', + url, + headers: parseInstallSourceHeaders(flags.header), + }, + }); +} + +function parseInstallSourceHeaders( + headerFlags: CliFlags['header'], +): Record | undefined { + if (!headerFlags || headerFlags.length === 0) return undefined; + const headers: Record = {}; + for (const rawHeader of headerFlags) { + const separator = rawHeader.indexOf(':'); + if (separator <= 0) { + throw new AppError( + 'INVALID_ARGS', + `Invalid --header value "${rawHeader}". Expected "name:value".`, + ); + } + const name = rawHeader.slice(0, separator).trim(); + const value = rawHeader.slice(separator + 1).trim(); + if (!name) { + throw new AppError( + 'INVALID_ARGS', + `Invalid --header value "${rawHeader}". Header name cannot be empty.`, + ); + } + headers[name] = value; + } + return headers; +} + function writeRuntimeResult(result: RuntimeResult, flags: CliFlags): void { const data = serializeRuntimeResult(result); if (flags.json) { diff --git a/src/client-shared.ts b/src/client-shared.ts index b83e21030..46058d40c 100644 --- a/src/client-shared.ts +++ b/src/client-shared.ts @@ -5,6 +5,7 @@ import type { AgentDeviceSessionDevice, AppCloseResult, AppDeployResult, + AppInstallFromSourceResult, AppOpenResult, CaptureSnapshotResult, EnsureSimulatorResult, @@ -116,6 +117,24 @@ export function serializeDeployResult(result: AppDeployResult): Record { + return { + launchTarget: result.launchTarget, + ...(result.appName ? { appName: result.appName } : {}), + ...(result.appId ? { appId: result.appId } : {}), + ...(result.bundleId ? { bundleId: result.bundleId } : {}), + ...(result.packageName ? { package: result.packageName } : {}), + ...(result.installablePath ? { installablePath: result.installablePath } : {}), + ...(result.archivePath ? { archivePath: result.archivePath } : {}), + ...(result.materializationId ? { materializationId: result.materializationId } : {}), + ...(result.materializationExpiresAt + ? { materializationExpiresAt: result.materializationExpiresAt } + : {}), + }; +} + export function serializeOpenResult(result: AppOpenResult): Record { return { session: result.session, diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index bc46f0a11..1c436bd2a 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -94,6 +94,10 @@ const COMMAND_CAPABILITY_MATRIX: Record = { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, }, + 'install-from-source': { + ios: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, + }, reinstall: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 37eb77972..fef3aea19 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -107,6 +107,28 @@ test('parseArgs accepts install command args', () => { assert.deepEqual(parsed.positionals, ['com.example.app', './build/app.apk']); }); +test('parseArgs accepts install-from-source url and repeated headers', () => { + const parsed = parseArgs( + [ + 'install-from-source', + 'https://example.com/builds/app.apk', + '--header', + 'authorization: Bearer token', + '--header', + 'x-build-id: 42', + '--retain-paths', + '--retention-ms', + '60000', + ], + { strictFlags: true }, + ); + assert.equal(parsed.command, 'install-from-source'); + assert.deepEqual(parsed.positionals, ['https://example.com/builds/app.apk']); + assert.deepEqual(parsed.flags.header, ['authorization: Bearer token', 'x-build-id: 42']); + assert.equal(parsed.flags.retainPaths, true); + assert.equal(parsed.flags.retentionMs, 60000); +}); + test('parseArgs accepts clipboard subcommands', () => { const read = parseArgs(['clipboard', 'read'], { strictFlags: true }); assert.equal(read.command, 'clipboard'); @@ -398,6 +420,8 @@ test('parseArgs rejects invalid swipe pattern', () => { test('usage includes --relaunch flag', () => { assert.match(usage(), /--relaunch/); + assert.match(usage(), /install-from-source /); + assert.match(usage(), /--header /); assert.match(usage(), /--restart/); assert.match(usage(), /--target mobile\|tv/); assert.match(usage(), /--ios-simulator-device-set /); diff --git a/src/utils/__tests__/cli-option-schema.test.ts b/src/utils/__tests__/cli-option-schema.test.ts index f77bd82c6..d64b7a27e 100644 --- a/src/utils/__tests__/cli-option-schema.test.ts +++ b/src/utils/__tests__/cli-option-schema.test.ts @@ -40,6 +40,11 @@ test('configurable option specs are filtered by command support', () => { assert.equal(openSpecs.has('platform'), true); assert.equal(openSpecs.has('activity'), true); assert.equal(openSpecs.has('snapshotDepth'), false); + + const installFromSourceSpecs = new Set( + getConfigurableOptionSpecs('install-from-source').map((spec) => spec.key), + ); + assert.equal(installFromSourceSpecs.has('header'), true); }); test('option schema resolves tokens back to canonical option specs', () => { @@ -90,3 +95,26 @@ test('option schema rejects invalid source values with INVALID_ARGS', () => { (error) => error instanceof AppError && error.code === 'INVALID_ARGS', ); }); + +test('option schema parses repeatable string options from config arrays and env strings', () => { + const spec = getOptionSpec('header'); + assert.ok(spec); + assert.deepEqual( + parseOptionValueFromSource( + spec, + ['authorization: Bearer token', 'x-build-id: 42'], + 'config file /tmp/test.json', + 'header', + ), + ['authorization: Bearer token', 'x-build-id: 42'], + ); + assert.deepEqual( + parseOptionValueFromSource( + spec, + 'authorization: Bearer token', + 'environment variable AGENT_DEVICE_HEADER', + 'AGENT_DEVICE_HEADER', + ), + ['authorization: Bearer token'], + ); +}); diff --git a/src/utils/args.ts b/src/utils/args.ts index 43f8370ad..5eafb090e 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -79,7 +79,17 @@ export function parseRawArgs(argv: string[]): RawParsedArgs { const parsed = parseFlagValue(definition, token, inlineValue, argv[i + 1]); if (parsed.consumeNext) i += 1; - (flags as Record)[definition.key] = parsed.value; + const existingValue = (flags as Record)[definition.key]; + if (definition.multiple) { + const values = Array.isArray(existingValue) + ? [...existingValue, parsed.value] + : existingValue === undefined + ? [parsed.value] + : [existingValue, parsed.value]; + (flags as Record)[definition.key] = values; + } else { + (flags as Record)[definition.key] = parsed.value; + } providedFlags.push({ key: definition.key, token }); } diff --git a/src/utils/cli-option-schema.ts b/src/utils/cli-option-schema.ts index 0eb9d0774..5e17bf4c6 100644 --- a/src/utils/cli-option-schema.ts +++ b/src/utils/cli-option-schema.ts @@ -64,6 +64,24 @@ export function parseOptionValueFromSource( rawKey: string, ): unknown { const definition = resolveSourceValueDefinition(spec); + if (definition.multiple) { + const rawValues = Array.isArray(value) ? value : [value]; + return rawValues.map((entry) => + parseOptionValueFromSource( + { + ...spec, + flagDefinitions: spec.flagDefinitions.map((flagDefinition) => ({ + ...flagDefinition, + multiple: false, + })), + }, + entry, + sourceLabel, + rawKey, + ), + ); + } + if (definition.type === 'boolean') { return parseBooleanValue(value, sourceLabel, rawKey); } diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index dcc99e315..4861b9d80 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -49,12 +49,15 @@ export type CliFlags = { pauseMs?: number; pattern?: 'one-way' | 'ping-pong'; activity?: string; + header?: string[]; saveScript?: boolean | string; shutdown?: boolean; relaunch?: boolean; headless?: boolean; restart?: boolean; noRecord?: boolean; + retainPaths?: boolean; + retentionMs?: number; replayUpdate?: boolean; steps?: string; stepsFile?: string; @@ -76,6 +79,7 @@ export type FlagDefinition = { key: FlagKey; names: readonly string[]; type: FlagType; + multiple?: boolean; enumValues?: readonly string[]; min?: number; max?: number; @@ -325,6 +329,14 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--activity ', usageDescription: 'Android app launch activity (package/Activity); not for URL opens', }, + { + key: 'header', + names: ['--header'], + type: 'string', + multiple: true, + usageLabel: '--header ', + usageDescription: 'install-from-source: repeatable HTTP header for URL downloads', + }, { key: 'session', names: ['--session'], @@ -457,6 +469,21 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--restart', usageDescription: 'logs clear: stop active stream, clear logs, then start streaming again', }, + { + key: 'retainPaths', + names: ['--retain-paths'], + type: 'boolean', + usageLabel: '--retain-paths', + usageDescription: 'install-from-source: keep materialized artifact paths after install', + }, + { + key: 'retentionMs', + names: ['--retention-ms'], + type: 'int', + min: 1, + usageLabel: '--retention-ms ', + usageDescription: 'install-from-source: retention TTL for materialized artifact paths', + }, { key: 'noRecord', names: ['--no-record'], @@ -632,6 +659,11 @@ const COMMAND_SCHEMAS: Record = { positionalArgs: ['app', 'path'], allowedFlags: [], }, + 'install-from-source': { + description: 'Install app from a URL source through the normal daemon artifact flow', + positionalArgs: ['url'], + allowedFlags: ['header', 'retainPaths', 'retentionMs'], + }, push: { description: 'Simulate push notification payload delivery', positionalArgs: ['bundleOrPackage', 'payloadOrJson'], diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 3e19504ef..3aa32d281 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -265,6 +265,19 @@ agent-device reinstall com.example.app ./build/MyApp.app --platform ios - `.aab` accepts the same bundletool requirements and optional `AGENT_DEVICE_ANDROID_BUNDLETOOL_MODE` override as `install`. - `.ipa` uses `` as the selection hint when multiple `Payload/*.app` bundles are present. +## App install from source URL + +```bash +agent-device install-from-source https://example.com/builds/app.apk --platform android +agent-device install-from-source https://example.com/builds/MyApp.ipa --platform ios --header "authorization: Bearer TOKEN" +``` + +- `install-from-source ` installs from a URL source through the normal daemon artifact flow. +- Repeat `--header ` for authenticated or signed artifact requests. +- Supports the same device coverage as `install`: Android devices/emulators, iOS simulators, and iOS physical devices. +- `--retain-paths` keeps retained materialized artifact paths after install, and `--retention-ms ` sets their TTL. +- URL downloads follow the same `installFromSource()` safety checks and host restrictions as the JS client API. + ## Push notification simulation ```bash diff --git a/website/docs/docs/introduction.md b/website/docs/docs/introduction.md index 9d951bb69..fc39758a8 100644 --- a/website/docs/docs/introduction.md +++ b/website/docs/docs/introduction.md @@ -21,7 +21,7 @@ For exploratory QA and bug-hunting workflows, see `skills/dogfood/SKILL.md` in t ## Platform support highlights -- iOS core runner commands: `snapshot`, `diff snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `long-press`, `focus`, `type`, `scroll`, `scrollintoview`, `back`, `home`, `app-switcher`, `open` (app), `close`, `screenshot`, `apps`, `appstate`, `install`, `reinstall`, `trigger-app-event`. +- iOS core runner commands: `snapshot`, `diff snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `long-press`, `focus`, `type`, `scroll`, `scrollintoview`, `back`, `home`, `app-switcher`, `open` (app), `close`, `screenshot`, `apps`, `appstate`, `install`, `install-from-source`, `reinstall`, `trigger-app-event`. - iOS `appstate` is session-scoped on the selected target device. - iOS simulator-only: `alert`, `pinch`, `settings`, `push`, `clipboard`. - Session performance metrics: `perf`/`metrics` is available on iOS and Android and currently reports startup timing sampled from `open` command round-trip duration. diff --git a/website/docs/docs/quick-start.md b/website/docs/docs/quick-start.md index d789b4fcc..f1c9bc4cb 100644 --- a/website/docs/docs/quick-start.md +++ b/website/docs/docs/quick-start.md @@ -47,6 +47,7 @@ agent-device fill @e3 "test@example.com" # Clear then type (Android verifies and agent-device get text @e1 # Get text content agent-device screenshot page.png # Save to specific path agent-device install com.example.app ./build/app.apk # Install app binary in-place +agent-device install-from-source https://example.com/builds/app.apk --platform android agent-device reinstall com.example.app ./build/app.apk # Fresh-state uninstall + install agent-device close ```