Skip to content

Commit 1101019

Browse files
authored
feat: add install-from-source CLI command (#235)
1 parent 905f8ea commit 1101019

15 files changed

Lines changed: 394 additions & 4 deletions

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ The project is in early development and considered experimental. Pull requests a
1414

1515
## Features
1616
- Platforms: iOS/tvOS (simulator + physical device core automation) and Android/AndroidTV (emulator + device).
17-
- 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`.
17+
- 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`.
1818
- Inspection commands: `snapshot` (accessibility tree), `diff snapshot` (structural baseline diff), `appstate`, `apps`, `devices`.
1919
- Clipboard commands: `clipboard read`, `clipboard write <text>`.
2020
- Keyboard commands: `keyboard status|get|dismiss` (Android).
@@ -344,8 +344,10 @@ Navigation helpers:
344344
- Use `boot` mainly when starting a new session and `open` fails because no booted simulator/emulator is available.
345345
- `open [app|url] [url]` already boots/activates the selected target when needed.
346346
- `install <app> <path>` installs app binary without uninstalling first (Android + iOS simulator/device).
347+
- `install-from-source <url>` installs from a URL source through the normal daemon artifact flow; repeat `--header name:value` for authenticated downloads.
347348
- `reinstall <app> <path>` uninstalls and installs the app binary in one command (Android + iOS simulator/device).
348349
- `install`/`reinstall` accept package/bundle id style app names and support `~` in paths.
350+
- `install-from-source` supports `--retain-paths` and `--retention-ms <ms>` when callers need retained materialized artifact paths after the install.
349351
- When `AGENT_DEVICE_DAEMON_BASE_URL` targets a remote daemon, local `.apk`/`.aab`/`.ipa` files and `.app` bundles are uploaded automatically before `install`/`reinstall`.
350352
- 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"`.
351353
- Remote daemon screenshots and recordings are materialized back to the caller path instead of returning host-local daemon paths.

skills/agent-device/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ agent-device open [app|url] [url]
174174
agent-device open [app] --relaunch
175175
agent-device close [app]
176176
agent-device install <app> <path-to-binary>
177+
agent-device install-from-source <url> [--header "name:value"]
177178
agent-device reinstall <app> <path-to-binary>
178179
agent-device session list
179180
```
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { tryRunClientBackedCommand } from '../cli-client-commands.ts';
4+
import type { AgentDeviceClient, AppInstallFromSourceOptions } from '../client.ts';
5+
import { AppError } from '../utils/errors.ts';
6+
7+
test('install-from-source forwards URL and repeated headers to client.apps.installFromSource', async () => {
8+
let observed: AppInstallFromSourceOptions | undefined;
9+
const client = createStubClient({
10+
installFromSource: async (options) => {
11+
observed = options;
12+
return {
13+
launchTarget: 'com.example.demo',
14+
packageName: 'com.example.demo',
15+
identifiers: { appId: 'com.example.demo', package: 'com.example.demo' },
16+
};
17+
},
18+
});
19+
20+
const handled = await tryRunClientBackedCommand({
21+
command: 'install-from-source',
22+
positionals: ['https://example.com/app.apk'],
23+
flags: {
24+
json: false,
25+
help: false,
26+
version: false,
27+
platform: 'android',
28+
header: ['authorization: Bearer token', 'x-build-id: 42'],
29+
retainPaths: true,
30+
retentionMs: 60_000,
31+
},
32+
client,
33+
});
34+
35+
assert.equal(handled, true);
36+
assert.equal(observed?.platform, 'android');
37+
assert.equal(observed?.retainPaths, true);
38+
assert.equal(observed?.retentionMs, 60_000);
39+
assert.deepEqual(observed?.source, {
40+
kind: 'url',
41+
url: 'https://example.com/app.apk',
42+
headers: {
43+
authorization: 'Bearer token',
44+
'x-build-id': '42',
45+
},
46+
});
47+
});
48+
49+
test('install-from-source rejects malformed header syntax', async () => {
50+
const client = createStubClient({
51+
installFromSource: async () => {
52+
throw new Error('unexpected call');
53+
},
54+
});
55+
56+
await assert.rejects(
57+
() =>
58+
tryRunClientBackedCommand({
59+
command: 'install-from-source',
60+
positionals: ['https://example.com/app.apk'],
61+
flags: {
62+
json: false,
63+
help: false,
64+
version: false,
65+
header: ['authorization'],
66+
},
67+
client,
68+
}),
69+
(error) =>
70+
error instanceof AppError &&
71+
error.code === 'INVALID_ARGS' &&
72+
error.message.includes('Expected "name:value"'),
73+
);
74+
});
75+
76+
function createStubClient(params: {
77+
installFromSource: AgentDeviceClient['apps']['installFromSource'];
78+
}): AgentDeviceClient {
79+
return {
80+
devices: {
81+
list: async () => [],
82+
},
83+
sessions: {
84+
list: async () => [],
85+
close: async () => ({ session: 'default', identifiers: { session: 'default' } }),
86+
},
87+
simulators: {
88+
ensure: async () => ({
89+
udid: 'sim-1',
90+
device: 'iPhone 16',
91+
runtime: 'iOS-18-0',
92+
created: false,
93+
booted: true,
94+
identifiers: {
95+
deviceId: 'sim-1',
96+
deviceName: 'iPhone 16',
97+
udid: 'sim-1',
98+
},
99+
}),
100+
},
101+
apps: {
102+
install: async () => ({
103+
app: 'Demo',
104+
appPath: '/tmp/Demo.app',
105+
platform: 'ios',
106+
identifiers: { appId: 'com.example.demo' },
107+
}),
108+
reinstall: async () => ({
109+
app: 'Demo',
110+
appPath: '/tmp/Demo.app',
111+
platform: 'ios',
112+
identifiers: { appId: 'com.example.demo' },
113+
}),
114+
installFromSource: params.installFromSource,
115+
open: async () => ({
116+
session: 'default',
117+
identifiers: { session: 'default' },
118+
}),
119+
close: async () => ({
120+
session: 'default',
121+
identifiers: { session: 'default' },
122+
}),
123+
},
124+
materializations: {
125+
release: async (options) => ({
126+
released: true,
127+
materializationId: options.materializationId,
128+
identifiers: { session: options.session ?? 'default' },
129+
}),
130+
},
131+
runtime: {
132+
set: async () => ({
133+
session: 'default',
134+
configured: true,
135+
identifiers: { session: 'default' },
136+
}),
137+
show: async () => ({
138+
session: 'default',
139+
configured: false,
140+
identifiers: { session: 'default' },
141+
}),
142+
},
143+
capture: {
144+
snapshot: async () => ({
145+
nodes: [],
146+
truncated: false,
147+
identifiers: { session: 'default' },
148+
}),
149+
screenshot: async () => ({
150+
path: '/tmp/screenshot.png',
151+
identifiers: { session: 'default' },
152+
}),
153+
},
154+
};
155+
}

src/__tests__/client-shared.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import test from 'node:test';
22
import assert from 'node:assert/strict';
3-
import { serializeOpenResult, serializeSessionListEntry } from '../client-shared.ts';
3+
import {
4+
serializeInstallFromSourceResult,
5+
serializeOpenResult,
6+
serializeSessionListEntry,
7+
} from '../client-shared.ts';
48

59
test('serializeSessionListEntry preserves legacy android session payload shape', () => {
610
const data = serializeSessionListEntry({
@@ -74,3 +78,23 @@ test('serializeOpenResult includes android serial for open payloads', () => {
7478
serial: 'emulator-5554',
7579
});
7680
});
81+
82+
test('serializeInstallFromSourceResult uses install-family package naming', () => {
83+
const data = serializeInstallFromSourceResult({
84+
launchTarget: 'com.example.demo',
85+
appName: 'Demo',
86+
appId: 'com.example.demo',
87+
packageName: 'com.example.demo',
88+
identifiers: {
89+
appId: 'com.example.demo',
90+
package: 'com.example.demo',
91+
},
92+
});
93+
94+
assert.deepEqual(data, {
95+
launchTarget: 'com.example.demo',
96+
appName: 'Demo',
97+
appId: 'com.example.demo',
98+
package: 'com.example.demo',
99+
});
100+
});

src/cli-client-commands.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
serializeDeployResult,
1010
serializeDevice,
1111
serializeEnsureSimulatorResult,
12+
serializeInstallFromSourceResult,
1213
serializeOpenResult,
1314
serializeRuntimeResult,
1415
serializeSessionListEntry,
@@ -113,6 +114,11 @@ const clientCommandHandlers: Partial<Record<string, ClientCommandHandler>> = {
113114
if (flags.json) printJson({ success: true, data: serializeDeployResult(result) });
114115
return true;
115116
},
117+
'install-from-source': async ({ positionals, flags, client }) => {
118+
const result = await runInstallFromSourceCommand(positionals, flags, client);
119+
if (flags.json) printJson({ success: true, data: serializeInstallFromSourceResult(result) });
120+
return true;
121+
},
116122
open: async ({ positionals, flags, client }) => {
117123
if (!positionals[0]) {
118124
return false;
@@ -239,6 +245,59 @@ async function runDeployCommand(
239245
: await client.apps.reinstall(options);
240246
}
241247

248+
async function runInstallFromSourceCommand(
249+
positionals: string[],
250+
flags: CliFlags,
251+
client: AgentDeviceClient,
252+
) {
253+
const url = positionals[0]?.trim();
254+
if (!url) {
255+
throw new AppError('INVALID_ARGS', 'install-from-source requires: install-from-source <url>');
256+
}
257+
if (positionals.length > 1) {
258+
throw new AppError(
259+
'INVALID_ARGS',
260+
'install-from-source accepts exactly one positional argument: <url>',
261+
);
262+
}
263+
return await client.apps.installFromSource({
264+
...buildSelectionOptions(flags),
265+
retainPaths: flags.retainPaths,
266+
retentionMs: flags.retentionMs,
267+
source: {
268+
kind: 'url',
269+
url,
270+
headers: parseInstallSourceHeaders(flags.header),
271+
},
272+
});
273+
}
274+
275+
function parseInstallSourceHeaders(
276+
headerFlags: CliFlags['header'],
277+
): Record<string, string> | undefined {
278+
if (!headerFlags || headerFlags.length === 0) return undefined;
279+
const headers: Record<string, string> = {};
280+
for (const rawHeader of headerFlags) {
281+
const separator = rawHeader.indexOf(':');
282+
if (separator <= 0) {
283+
throw new AppError(
284+
'INVALID_ARGS',
285+
`Invalid --header value "${rawHeader}". Expected "name:value".`,
286+
);
287+
}
288+
const name = rawHeader.slice(0, separator).trim();
289+
const value = rawHeader.slice(separator + 1).trim();
290+
if (!name) {
291+
throw new AppError(
292+
'INVALID_ARGS',
293+
`Invalid --header value "${rawHeader}". Header name cannot be empty.`,
294+
);
295+
}
296+
headers[name] = value;
297+
}
298+
return headers;
299+
}
300+
242301
function writeRuntimeResult(result: RuntimeResult, flags: CliFlags): void {
243302
const data = serializeRuntimeResult(result);
244303
if (flags.json) {

src/client-shared.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
AgentDeviceSessionDevice,
66
AppCloseResult,
77
AppDeployResult,
8+
AppInstallFromSourceResult,
89
AppOpenResult,
910
CaptureSnapshotResult,
1011
EnsureSimulatorResult,
@@ -116,6 +117,24 @@ export function serializeDeployResult(result: AppDeployResult): Record<string, u
116117
};
117118
}
118119

120+
export function serializeInstallFromSourceResult(
121+
result: AppInstallFromSourceResult,
122+
): Record<string, unknown> {
123+
return {
124+
launchTarget: result.launchTarget,
125+
...(result.appName ? { appName: result.appName } : {}),
126+
...(result.appId ? { appId: result.appId } : {}),
127+
...(result.bundleId ? { bundleId: result.bundleId } : {}),
128+
...(result.packageName ? { package: result.packageName } : {}),
129+
...(result.installablePath ? { installablePath: result.installablePath } : {}),
130+
...(result.archivePath ? { archivePath: result.archivePath } : {}),
131+
...(result.materializationId ? { materializationId: result.materializationId } : {}),
132+
...(result.materializationExpiresAt
133+
? { materializationExpiresAt: result.materializationExpiresAt }
134+
: {}),
135+
};
136+
}
137+
119138
export function serializeOpenResult(result: AppOpenResult): Record<string, unknown> {
120139
return {
121140
session: result.session,

src/core/capabilities.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
9494
ios: { simulator: true, device: true },
9595
android: { emulator: true, device: true, unknown: true },
9696
},
97+
'install-from-source': {
98+
ios: { simulator: true, device: true },
99+
android: { emulator: true, device: true, unknown: true },
100+
},
97101
reinstall: {
98102
ios: { simulator: true, device: true },
99103
android: { emulator: true, device: true, unknown: true },

src/utils/__tests__/args.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,28 @@ test('parseArgs accepts install command args', () => {
107107
assert.deepEqual(parsed.positionals, ['com.example.app', './build/app.apk']);
108108
});
109109

110+
test('parseArgs accepts install-from-source url and repeated headers', () => {
111+
const parsed = parseArgs(
112+
[
113+
'install-from-source',
114+
'https://example.com/builds/app.apk',
115+
'--header',
116+
'authorization: Bearer token',
117+
'--header',
118+
'x-build-id: 42',
119+
'--retain-paths',
120+
'--retention-ms',
121+
'60000',
122+
],
123+
{ strictFlags: true },
124+
);
125+
assert.equal(parsed.command, 'install-from-source');
126+
assert.deepEqual(parsed.positionals, ['https://example.com/builds/app.apk']);
127+
assert.deepEqual(parsed.flags.header, ['authorization: Bearer token', 'x-build-id: 42']);
128+
assert.equal(parsed.flags.retainPaths, true);
129+
assert.equal(parsed.flags.retentionMs, 60000);
130+
});
131+
110132
test('parseArgs accepts clipboard subcommands', () => {
111133
const read = parseArgs(['clipboard', 'read'], { strictFlags: true });
112134
assert.equal(read.command, 'clipboard');
@@ -398,6 +420,8 @@ test('parseArgs rejects invalid swipe pattern', () => {
398420

399421
test('usage includes --relaunch flag', () => {
400422
assert.match(usage(), /--relaunch/);
423+
assert.match(usage(), /install-from-source <url>/);
424+
assert.match(usage(), /--header <name:value>/);
401425
assert.match(usage(), /--restart/);
402426
assert.match(usage(), /--target mobile\|tv/);
403427
assert.match(usage(), /--ios-simulator-device-set <path>/);

0 commit comments

Comments
 (0)