Skip to content

Commit da9d452

Browse files
authored
feat: add client metro prepare command (#236)
* feat: add client metro prepare command * fix: tighten metro prepare cli behavior
1 parent be734d1 commit da9d452

13 files changed

Lines changed: 1259 additions & 1 deletion

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ Navigation helpers:
349349
- `install`/`reinstall` accept package/bundle id style app names and support `~` in paths.
350350
- `install-from-source` supports `--retain-paths` and `--retention-ms <ms>` when callers need retained materialized artifact paths after the install.
351351
- When `AGENT_DEVICE_DAEMON_BASE_URL` targets a remote daemon, local `.apk`/`.aab`/`.ipa` files and `.app` bundles are uploaded automatically before `install`/`reinstall`.
352+
- `metro prepare --public-base-url <url>` starts or reuses a local Metro server for sandbox/client flows and prints runtime JSON to stdout. `--json` wraps the same payload in the standard `{ success, data }` envelope. Pass `--proxy-base-url <url>` plus `AGENT_DEVICE_PROXY_TOKEN` (preferred) or `--bearer-token <token>` when the runtime must be bridged through `agent-device-proxy`, and `--runtime-file <path>` only when a persisted artifact is needed.
352353
- 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"`.
353354
- Remote daemon screenshots and recordings are materialized back to the caller path instead of returning host-local daemon paths.
354355
- To force a daemon-side path instead of uploading a local file, prefix it with `remote:`, for example `remote:/srv/builds/MyApp.app`.
@@ -567,6 +568,7 @@ Environment selectors:
567568
- `AGENT_DEVICE_DAEMON_BASE_URL=http(s)://host:port[/base-path]` connect directly to a remote HTTP daemon and skip local daemon metadata/startup.
568569
- Remote daemon installs upload local artifacts through `POST /upload`; use a `remote:` path prefix when you need the daemon to read an existing server-side artifact path as-is.
569570
- `AGENT_DEVICE_DAEMON_AUTH_TOKEN=<token>` auth token for remote HTTP daemon mode; sent in both the JSON-RPC request token and HTTP auth headers (`Authorization: Bearer` and `x-agent-device-token`).
571+
- `AGENT_DEVICE_PROXY_TOKEN=<token>` preferred bearer token for `metro prepare --proxy-base-url <url>` so the proxy secret does not need to be passed on the command line. `AGENT_DEVICE_METRO_BEARER_TOKEN` is also supported.
570572
- `AGENT_DEVICE_DAEMON_SERVER_MODE=socket|http|dual` daemon server mode. `http` and `dual` expose JSON-RPC 2.0 at `POST /rpc` (`GET /health` available for liveness).
571573
- `AGENT_DEVICE_DAEMON_TRANSPORT=auto|socket|http` client preference when connecting to daemon metadata.
572574
- `AGENT_DEVICE_HTTP_AUTH_HOOK=<module-path>` optional HTTP auth hook module path for JSON-RPC server mode.

src/__tests__/cli-client-commands.test.ts

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import test from 'node:test';
22
import assert from 'node:assert/strict';
33
import { tryRunClientBackedCommand } from '../cli-client-commands.ts';
4-
import type { AgentDeviceClient, AppInstallFromSourceOptions } from '../client.ts';
4+
import type {
5+
AgentDeviceClient,
6+
AppInstallFromSourceOptions,
7+
MetroPrepareOptions,
8+
} from '../client.ts';
59
import { AppError } from '../utils/errors.ts';
610

711
test('install-from-source forwards URL and repeated headers to client.apps.installFromSource', async () => {
@@ -73,8 +77,129 @@ test('install-from-source rejects malformed header syntax', async () => {
7377
);
7478
});
7579

80+
test('metro prepare forwards normalized options to client.metro.prepare', async () => {
81+
let observed: MetroPrepareOptions | undefined;
82+
const client = createStubClient({
83+
installFromSource: async () => {
84+
throw new Error('unexpected install call');
85+
},
86+
prepareMetro: async (options) => {
87+
observed = options;
88+
return {
89+
projectRoot: '/tmp/project',
90+
kind: 'react-native',
91+
dependenciesInstalled: false,
92+
packageManager: null,
93+
started: false,
94+
reused: true,
95+
pid: 0,
96+
logPath: '/tmp/project/.agent-device/metro.log',
97+
statusUrl: 'http://127.0.0.1:8081/status',
98+
runtimeFilePath: null,
99+
iosRuntime: {
100+
platform: 'ios',
101+
bundleUrl: 'https://sandbox.example.test/index.bundle?platform=ios',
102+
},
103+
androidRuntime: {
104+
platform: 'android',
105+
bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android',
106+
},
107+
bridge: null,
108+
};
109+
},
110+
});
111+
112+
const stdout = await captureStdout(async () => {
113+
const handled = await tryRunClientBackedCommand({
114+
command: 'metro',
115+
positionals: ['prepare'],
116+
flags: {
117+
json: false,
118+
help: false,
119+
version: false,
120+
metroProjectRoot: './apps/demo',
121+
metroPublicBaseUrl: 'https://sandbox.example.test',
122+
metroProxyBaseUrl: 'https://proxy.example.test',
123+
metroBearerToken: 'secret',
124+
metroPreparePort: 9090,
125+
metroKind: 'expo',
126+
metroRuntimeFile: './.agent-device/metro-runtime.json',
127+
metroNoReuseExisting: true,
128+
metroNoInstallDeps: true,
129+
},
130+
client,
131+
});
132+
assert.equal(handled, true);
133+
});
134+
const payload = JSON.parse(stdout);
135+
136+
assert.deepEqual(observed, {
137+
projectRoot: './apps/demo',
138+
publicBaseUrl: 'https://sandbox.example.test',
139+
proxyBaseUrl: 'https://proxy.example.test',
140+
bearerToken: 'secret',
141+
port: 9090,
142+
kind: 'expo',
143+
runtimeFilePath: './.agent-device/metro-runtime.json',
144+
reuseExisting: false,
145+
installDependenciesIfNeeded: false,
146+
listenHost: undefined,
147+
statusHost: undefined,
148+
startupTimeoutMs: undefined,
149+
probeTimeoutMs: undefined,
150+
});
151+
assert.equal(payload.kind, 'react-native');
152+
assert.equal(payload.runtimeFilePath, null);
153+
});
154+
155+
test('metro prepare wraps output in the standard success envelope for --json', async () => {
156+
const client = createStubClient({
157+
installFromSource: async () => {
158+
throw new Error('unexpected install call');
159+
},
160+
});
161+
162+
const stdout = await captureStdout(async () => {
163+
const handled = await tryRunClientBackedCommand({
164+
command: 'metro',
165+
positionals: ['prepare'],
166+
flags: {
167+
json: true,
168+
help: false,
169+
version: false,
170+
metroPublicBaseUrl: 'https://sandbox.example.test',
171+
},
172+
client,
173+
});
174+
assert.equal(handled, true);
175+
});
176+
177+
const payload = JSON.parse(stdout);
178+
assert.equal(payload.success, true);
179+
assert.equal(payload.data.kind, 'react-native');
180+
assert.equal(payload.data.iosRuntime.platform, 'ios');
181+
});
182+
183+
async function captureStdout(run: () => Promise<void>): Promise<string> {
184+
let stdout = '';
185+
const originalWrite = process.stdout.write.bind(process.stdout);
186+
(process.stdout as any).write = ((chunk: unknown) => {
187+
stdout += String(chunk);
188+
return true;
189+
}) as typeof process.stdout.write;
190+
191+
try {
192+
await run();
193+
} finally {
194+
process.stdout.write = originalWrite;
195+
}
196+
197+
return stdout;
198+
}
199+
76200
function createStubClient(params: {
77201
installFromSource: AgentDeviceClient['apps']['installFromSource'];
202+
prepareMetro?: AgentDeviceClient['metro']['prepare'];
78203
}): AgentDeviceClient {
79204
return {
80205
devices: {
@@ -140,6 +265,31 @@ function createStubClient(params: {
140265
identifiers: { session: 'default' },
141266
}),
142267
},
268+
metro: {
269+
prepare:
270+
params.prepareMetro ??
271+
(async () => ({
272+
projectRoot: '/tmp/project',
273+
kind: 'react-native',
274+
dependenciesInstalled: false,
275+
packageManager: null,
276+
started: false,
277+
reused: true,
278+
pid: 0,
279+
logPath: '/tmp/project/.agent-device/metro.log',
280+
statusUrl: 'http://127.0.0.1:8081/status',
281+
runtimeFilePath: null,
282+
iosRuntime: {
283+
platform: 'ios',
284+
bundleUrl: 'https://sandbox.example.test/index.bundle?platform=ios',
285+
},
286+
androidRuntime: {
287+
platform: 'android',
288+
bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android',
289+
},
290+
bridge: null,
291+
})),
292+
},
143293
capture: {
144294
snapshot: async () => ({
145295
nodes: [],

0 commit comments

Comments
 (0)