@g9tm/app-pilot-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1594 @@
1
+ #!/usr/bin/env node
2
+ // g9pilot — the G9AppPilot controller CLI (pilot/AiGuides/04).
3
+ //
4
+ // g9pilot hello [--url http://127.0.0.1:8799]
5
+ // g9pilot call <command> [json] signed; prints the answer
6
+ // g9pilot trace [list|<id>] [--out <dir>] [--crash --serial <adb serial> | --simulator <udid>] | trace --file <trace.json>
7
+ // a failure trace from the device's black box (default the newest): prints what
8
+ // most likely went wrong and writes trace.json + index.html (a static viewer) to --out;
9
+ // --crash adds the OS's crash log (an app that died cannot report it itself)
10
+ // g9pilot os <what> … --serial <adb serial> | --simulator <udid> the device around the app (the stage scope's OS half):
11
+ // os crashlogs [--package <id>] [--lines 400] the OS crash buffer (logcat -b crash; the simulator's reports)
12
+ // os permission grant|revoke <permission> --package <id> pm grant / simctl privacy (android.permission.CAMERA, camera…)
13
+ // os location <lat> <lon> a fake position (emulator geo fix; simulator location set)
14
+ // os net offline|online the whole device network (airplane mode): native traffic too
15
+ // os dialog allow|deny answers the system permission dialog on screen (Android)
16
+ // os video <seconds> --out <dir> records the screen (Android: pieces under screenrecord's 180 s
17
+ // limit, each pulled; simulator: simctl io recordVideo, SIGINT)
18
+ // os doze on|off|step Android Doze (deviceidle force-idle / unforce + battery reset)
19
+ // os standby <active|working_set|frequent|rare|restricted> --package <id> Android App Standby bucket
20
+ // os appearance dark|light the system theme (cmd uimode night / simctl ui appearance)
21
+ // os fontscale <1.3 | extra-large …> the system text size (settings font_scale / simctl content_size)
22
+ // os notify <payload.json> --package <id> a push to the app (simulator: simctl push; Android: refused —
23
+ // `cmd notification post` posts as the shell, not the app)
24
+ // g9pilot status --brief one line per configured device: app, version, page ready, keys (for a Skill)
25
+ // g9pilot events [--since N] [--kinds a,b] follow the event stream
26
+ // g9pilot proxy --listen 8799 --target http://127.0.0.1:18799
27
+ // a local signing proxy: an unchanged runner talks to --listen, every request
28
+ // reaches the device signed (the key never leaves this process)
29
+ // g9pilot keys new --kid <id> a fresh ES256 pair: the public key is printed, the private key goes ONLY to
30
+ // --out <file> (or nowhere: you store it yourself from the printed instructions)
31
+ // g9pilot keys public the public key of the configured private key
32
+ // g9pilot conformance [--json] the protocol-2 conformance suite against --url (exit 1 on any failure)
33
+ // g9pilot savepoint save|load|list|delete [<name>] [--serial emulator-5554] [--forward 8797]
34
+ // level 0 of a savepoint: the WHOLE Android emulator (app, data, OS) as a snapshot
35
+ // (`adb emu avd snapshot`); load takes seconds and the app comes back as it was
36
+ // g9pilot savepoint save|load|list <name> --level app --package <id> [--serial <any device>] [--dir <folder>]
37
+ // level 1: the APP's data (files, databases, preferences) of a debuggable build,
38
+ // to a tar on this machine and back (`run-as`); the app is stopped first.
39
+ // Every level-1 save: [--server <database / environment> --server-commit <sha>] →
40
+ // <name>.meta.json beside the tar (the server the app's data belongs to)
41
+ // g9pilot savepoint save|load|list|delete <name> --simulator <udid>
42
+ // level 0 on the iOS simulator: the whole device (simctl clone; a load copies the
43
+ // clone's data back with APFS clones, so the device keeps its UDID); on the Mac
44
+ // g9pilot savepoint save|load|list <name> --level app --simulator <udid> --package <bundle id> [--dir <folder>]
45
+ // the same on the iOS simulator: Documents + Library of the data container
46
+ // (not Caches, SplashBoard, tmp); run on the Mac
47
+ // g9pilot savepoint save|load|list <name> --level app --via bridge --package <id> [--device <iPhone udid> |
48
+ // --simulator <udid> | --serial <s>] the APP packs it at its next launch (before
49
+ // it opens its data) and one file moves: the iPhone path (devicectl copies files only)
50
+ // g9pilot tokens gen | approve [--snapshot <file>] [--dry-run]
51
+ // design tokens (DTCG files in g9pilot.json "tokens") → the app's generated module;
52
+ // the device's design.snapshot written back into the files (no commit)
53
+ // g9pilot catalog shoot --serial <s> | --simulator <udid> [--approve-new] [--only A,B] [--variant catalog]
54
+ // the app's component catalog (catalog.list / catalog.show) in every studio mode,
55
+ // each state against its reference <component>.<state> (variant <variant>-m<mode>)
56
+ // g9pilot visual check <name> --serial <adb serial> | --simulator <udid> | --file <png> [--variant android-light-fa]
57
+ // the screen against its approved reference (lossless OS capture, masks for a clock
58
+ // or a live map; g9pilot.json "visual"): exit 0 match, 1 differs (a .diff.png beside
59
+ // it), 2 no reference yet or another size
60
+ // g9pilot visual approve <name> | visual list the last capture becomes the reference (no commit) | what exists
61
+ // g9pilot studio [--port 47901] the local studio: the device's screen beside its tokens, live edits, approve
62
+ // g9pilot mcp an MCP server on stdio for agents (packages/mcp): twelve tools over this bridge;
63
+ // `claude mcp add app-pilot -- g9pilot mcp --config <g9pilot.json>`
64
+ // g9pilot session send <summary.json> a session summary (format g9/pilot-session) to the hub of g9pilot.json "hub";
65
+ // proxy, mcp, studio and relay send theirs themselves when a session ends (10 minutes
66
+ // without a command, G9PILOT_SESSION_IDLE_MS, or the process's end) — command names and
67
+ // counts only, never an argument
68
+ // g9pilot relay [--name <name>] the devices in g9pilot.json "devices" offered to the hub's studio page: the hub
69
+ // adapter's long poll; each request is run here, signed here, studio commands only
70
+ // (design tokens, a screenshot, the app's identity)
71
+ // g9pilot approvals list [--status approved] | apply <DA-…> [--dry-run]
72
+ // the design approvals made on the hub's studio page: apply writes one into the token
73
+ // files here (g9pilot.json "tokens") and regenerates them, then reports «applied»;
74
+ // never a commit
75
+ // g9pilot studio --emit <dir> [--header Name=value] the studio page as a hub serves it (studio.fa.html,
76
+ // studio.en.html, assets/studio.js); --header = what the hub wants on a write
77
+ // g9pilot host --listen 47900 [--bind 0.0.0.0] (g9pilot.json "reserve": {hold, release, idleMinutes} books a shared
78
+ // device on first use and frees it when idle; a refused booking is HTTP 423)
79
+ // a relay for the devices attached to THIS machine (adb serials, g9pilot.json
80
+ // "hostDevices" such as a booted simulator): /devices lists them, /d/<name>/…
81
+ // reaches one, signed requests pass unchanged (no key here). A token
82
+ // (G9PILOT_HOST_TOKEN, or --token-env) guards it; off the loopback it is required.
83
+ // --host <name> --device <name> any command above, through a relay named in g9pilot.json "hosts"
84
+ // g9pilot dev [--cmd "npm run dev"] [--cwd app] [--port 3000] [--bundle main.lynx.bundle] [--serial <adb serial>]
85
+ // [--watch-flows [tag:@smoke | <flow id>]] after each rebuild: the page ready again, settled, then the
86
+ // runner (g9pilot.json "runner") replays them; one ✓/✗ line per change
87
+ // live development: starts the dev server, waits for the bundle, `adb reverse`s the
88
+ // port to every Android device (or --serial), then sends the signed dev.server so the
89
+ // app loads from it (HMR, state kept). Ctrl+C puts the app back on its own bundle.
90
+ // The dev server is told its port in G9PILOT_DEV_PORT.
91
+ //
92
+ // Configuration: g9pilot.json in the working directory (or --config), all optional:
93
+ // { "url": "http://127.0.0.1:8799", "kid": "owner-…", "key": { "env": "G9PILOT_KEY", "file": "…",
94
+ // "windows": "<Credential Manager target>", "macos": { "service": "…", "account": "…" } },
95
+ // "hosts": { "<name>": { "url": "http://…:47900", "tokenEnv": "G9PILOT_HOST_TOKEN" } },
96
+ // "hostDevices": [{ "name": "…", "url": "http://127.0.0.1:8796" }], "devicePort": 8799 }
97
+ // The private key is read from env/file/OS store and is never printed.
98
+ import fs from 'node:fs';
99
+ import http from 'node:http';
100
+ import os from 'node:os';
101
+ import path from 'node:path';
102
+ import { execFileSync, spawn } from 'node:child_process';
103
+ import { runConformance } from '@g9tm/app-pilot-client/conformance';
104
+ import { explainTrace, generateKeyPair, PilotClient, publicKeyOf, loadHub, readSummary, runRelay, sessionRecorder, signRequest, tallyCalls, traceViewerHtml } from '@g9tm/app-pilot-client';
105
+ import { loadPrivateKey } from '@g9tm/app-pilot-client/keys';
106
+ import { studioHtml, studioScript, studioServer, tokenSets, tokensApprove, tokensGen } from '../src/design.mjs';
107
+ import { approveVisual, captureOsPng, checkVisual, listVisual, visualSettings } from '../src/visual.mjs';
108
+
109
+ const argv = process.argv.slice(2);
110
+ const flags = {};
111
+ const positional = [];
112
+ for (let i = 0; i < argv.length; i++) {
113
+ const a = argv[i];
114
+ if (a.startsWith('--')) {
115
+ const k = a.slice(2);
116
+ const next = argv[i + 1];
117
+ if (next === undefined || next.startsWith('--')) flags[k] = true;
118
+ else flags[k] = argv[++i];
119
+ } else positional.push(a);
120
+ }
121
+
122
+ const configPath = flags.config ?? path.join(process.cwd(), 'g9pilot.json');
123
+ const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, 'utf8')) : {};
124
+ const kid = flags.kid ?? config.kid;
125
+
126
+ /** `--host <name> --device <name>`: the relay's URL for that device, and the relay's token header (never signed). */
127
+ function viaHost() {
128
+ if (!flags.host) return { base: null, headers: {} };
129
+ const host = config.hosts?.[String(flags.host)];
130
+ if (!host?.url) fail(`No host "${flags.host}" in g9pilot.json "hosts".`);
131
+ if (!flags.device) fail('--host needs --device <name> (g9pilot host lists them at /devices).');
132
+ const token = process.env[host.tokenEnv ?? 'G9PILOT_HOST_TOKEN'];
133
+ return { base: `${String(host.url).replace(/\/+$/, '')}/d/${encodeURIComponent(String(flags.device))}`, headers: token ? { 'x-g9p-host-token': token } : {} };
134
+ }
135
+ const relay = viaHost();
136
+ const url = String(relay.base ?? flags.url ?? (flags.port ? `http://127.0.0.1:${flags.port}` : (config.url ?? 'http://127.0.0.1:8799')));
137
+ const clientFor = (opts = {}) => new PilotClient({ base: url, kid, extraHeaders: relay.headers, ...opts });
138
+ /** A session record for a long-running command; its device is the g9pilot.json device of that address, else the address. */
139
+ let hubLoad = null;
140
+ /** The hub of g9pilot.json "hub" (its adapter, loaded once), or null. */
141
+ const hub = () => (hubLoad ??= loadHub(config, path.dirname(path.resolve(configPath))));
142
+ const recorder = (tool, address) => sessionRecorder({ tool, kid, device: (config.devices ?? []).find((d) => d.url === address)?.name ?? address, hub: config.hub ? hub() : null, enabled: Boolean(config.hub) });
143
+
144
+ function key({ required = true } = {}) {
145
+ const found = loadPrivateKey(config.key ?? {});
146
+ if (!found && required) {
147
+ fail('No private key: set G9PILOT_KEY, or name a file / Credential Manager target / keychain item in g9pilot.json "key".');
148
+ }
149
+ return found;
150
+ }
151
+
152
+ function fail(message, code = 2) {
153
+ process.stderr.write(`g9pilot: ${message}\n`);
154
+ process.exit(code);
155
+ }
156
+
157
+ const print = (v) => process.stdout.write(`${typeof v === 'string' ? v : JSON.stringify(v, null, 2)}\n`);
158
+
159
+ async function main() {
160
+ const [cmd, ...rest] = positional;
161
+ switch (cmd) {
162
+ case 'hello': {
163
+ print(await clientFor().hello());
164
+ return;
165
+ }
166
+ case 'call': {
167
+ const [command, argsJson] = rest;
168
+ if (!command) fail('call needs a command: g9pilot call qa.ping');
169
+ const k = key({ required: !flags.unsigned });
170
+ const client = clientFor({ privateKey: k?.key, leaseId: flags.lease ?? null });
171
+ const answer = await client.call(command, argsJson ? JSON.parse(argsJson) : {}, { timeoutMs: Number(flags.timeout ?? 30_000) });
172
+ print(answer);
173
+ process.exitCode = answer?.ok ? 0 : 1;
174
+ return;
175
+ }
176
+ case 'trace': {
177
+ // A trace already on disk (a runner's failure bundle, <report>/failures/<flow>.trace.json): explained and viewed
178
+ // without a device.
179
+ if (flags.file) {
180
+ const trace = JSON.parse(fs.readFileSync(String(flags.file), 'utf8'));
181
+ const out = path.resolve(String(flags.out ?? path.join(path.dirname(String(flags.file)), `${path.basename(String(flags.file), '.json')}-viewer`)));
182
+ fs.mkdirSync(out, { recursive: true });
183
+ fs.writeFileSync(path.join(out, 'index.html'), traceViewerHtml(trace));
184
+ print({ ...explainTrace(trace), written: out });
185
+ return;
186
+ }
187
+ const client = clientFor({ privateKey: key({ required: !flags.unsigned })?.key });
188
+ const [which] = rest;
189
+ if (which === 'list') {
190
+ const answer = await client.call('trace.list');
191
+ print(answer.ok ? answer.result.traces : answer);
192
+ process.exitCode = answer.ok ? 0 : 1;
193
+ return;
194
+ }
195
+ const answer = await client.call('trace.get', which ? { id: which } : {});
196
+ if (!answer.ok) {
197
+ print(answer);
198
+ process.exitCode = 1;
199
+ return;
200
+ }
201
+ const trace = answer.result;
202
+ if (flags.crash) trace.sections = { ...(trace.sections ?? {}), osCrashLog: crashLog({ lines: 200 }) };
203
+ const e = explainTrace(trace);
204
+ const out = path.resolve(String(flags.out ?? `pilot-trace-${trace.id}`));
205
+ fs.mkdirSync(out, { recursive: true });
206
+ fs.writeFileSync(path.join(out, 'trace.json'), JSON.stringify(trace, null, 2));
207
+ fs.writeFileSync(path.join(out, 'index.html'), traceViewerHtml(trace));
208
+ print({ ...e, written: out });
209
+ return;
210
+ }
211
+ case 'os':
212
+ return osCommand(rest);
213
+ case 'status': {
214
+ const targets = config.devices ?? [{ name: 'default', url }];
215
+ const lines = [];
216
+ for (const t of targets) {
217
+ try {
218
+ const h = await new PilotClient({ base: t.url }).hello({ timeoutMs: 3000 });
219
+ lines.push(`${t.name}: ${h.app} ${h.versionName} (${h.environment}, ${h.platform ?? '?'}) page ${h.jsReady ? 'ready' : 'NOT ready'}, protocol ${h.protocol ?? 1}, keys ${JSON.stringify(h.kids ?? [])}, signatures ${h.signatureRequired ? 'required' : 'optional'}${h.devtool ? ', devtool' : ''}`);
220
+ } catch (e) {
221
+ lines.push(`${t.name}: no bridge at ${t.url} (${e.message})`);
222
+ }
223
+ }
224
+ const k = key({ required: false });
225
+ lines.push(`key: ${k ? `${kid ?? '(kid from the hello)'} from ${k.source}` : 'none configured'}`);
226
+ print(flags.brief ? lines.join('\n') : { devices: lines });
227
+ return;
228
+ }
229
+ case 'events': {
230
+ const k = key();
231
+ const client = clientFor({ privateKey: k.key });
232
+ const kinds = flags.kinds ? new Set(String(flags.kinds).split(',')) : null;
233
+ client.events({
234
+ since: Number(flags.since ?? 0),
235
+ onEvent: (e) => {
236
+ if (!kinds || kinds.has(e.kind)) print(JSON.stringify(e));
237
+ },
238
+ onError: (e) => process.stderr.write(`events: ${e.message}\n`),
239
+ });
240
+ return;
241
+ }
242
+ case 'session': {
243
+ const [sub, file] = rest;
244
+ if (sub !== 'send' || !file) fail('session send <summary.json>');
245
+ print(await (await hubOrFail()).sendSession(readSummary(String(file))));
246
+ return;
247
+ }
248
+ case 'relay':
249
+ return relayCommand();
250
+ case 'approvals':
251
+ return approvals(rest);
252
+ case 'proxy':
253
+ return proxy();
254
+ case 'host':
255
+ return host();
256
+ case 'dev':
257
+ return dev();
258
+ case 'mcp':
259
+ return mcp();
260
+ case 'savepoint':
261
+ return savepoint(rest);
262
+ case 'tokens': {
263
+ const sets = tokenSets(config, path.dirname(path.resolve(configPath)));
264
+ const [sub] = rest;
265
+ if (sub === 'gen') {
266
+ const written = tokensGen(sets);
267
+ print(written.length ? `generated ${written.join(', ')}` : 'the generated modules are up to date');
268
+ return;
269
+ }
270
+ if (sub === 'approve') {
271
+ let snapshot;
272
+ if (flags.snapshot) snapshot = JSON.parse(fs.readFileSync(String(flags.snapshot), 'utf8'));
273
+ else {
274
+ const client = clientFor({ privateKey: key({ required: !flags.unsigned })?.key });
275
+ const answer = await client.call('design.snapshot');
276
+ if (!answer.ok) fail(`design.snapshot: ${answer.error?.message ?? JSON.stringify(answer.error)}`);
277
+ snapshot = answer.result;
278
+ }
279
+ print(tokensApprove(sets, snapshot, { dryRun: flags['dry-run'] === true, ...approveFollowUps() }));
280
+ return;
281
+ }
282
+ fail('tokens gen | tokens approve [--snapshot <file>] [--dry-run]');
283
+ return;
284
+ }
285
+ case 'catalog': {
286
+ // The app's live component catalog against its references (plan v2 P1-19): for each studio mode (g9pilot.json
287
+ // studio.modes; none = as it is), each component and state the app lists (catalog.list) is shown alone
288
+ // (catalog.show), the OS picture taken (lossless, --serial / --simulator) and compared with its reference
289
+ // `<component>.<state>` in the variant `<--variant or catalog>-m<mode>`. A new one or a changed one is LISTED;
290
+ // `--approve-new` takes the first pictures as the references (a baseline: look at them first).
291
+ if (rest[0] !== 'shoot') fail('catalog shoot [--serial <s> | --simulator <udid>] [--approve-new] [--only Component[,…]]');
292
+ const settings0 = visualSettings(config, path.dirname(path.resolve(configPath)), flags);
293
+ const client = clientFor({ privateKey: key({ required: !flags.unsigned })?.key });
294
+ const call = async (command, args = {}) => {
295
+ const r = await client.call(command, args, { timeoutMs: 30_000 });
296
+ if (!r?.ok) throw new Error(`${command}: ${r?.error ?? 'no answer'}`);
297
+ return r.result;
298
+ };
299
+ const list = (await call('catalog.list')).components;
300
+ const only = flags.only ? String(flags.only).split(',') : null;
301
+ const modes = config.studio?.modes?.length ? config.studio.modes : [{ name: 'as it is', commands: [] }];
302
+ const rows = [];
303
+ try {
304
+ for (const [mi, mode] of modes.entries()) {
305
+ for (const cmd of mode.commands ?? []) await call(cmd.command, cmd.args ?? {});
306
+ const settings = { ...settings0, variant: `${flags.variant ?? 'catalog'}-m${mi}` };
307
+ for (const entry of list) {
308
+ if (only && !only.includes(entry.id)) continue;
309
+ for (const state of entry.states) {
310
+ await call('catalog.show', { component: entry.id, state });
311
+ await call('ui.waitIdle', {}).catch(() => null);
312
+ await new Promise((r) => setTimeout(r, 350));
313
+ const png = captureOsPng({ serial: flags.serial, simulator: flags.simulator, adb: String(flags.adb ?? process.env.ADB ?? 'adb') });
314
+ const name = `${entry.id}.${state}`;
315
+ let r = checkVisual(settings, name, png);
316
+ if (r.verdict === 'new' && flags['approve-new']) {
317
+ approveVisual(settings, name);
318
+ r = { ...r, verdict: 'new → reference' };
319
+ }
320
+ rows.push({ mode: mode.name, name, verdict: r.verdict, ...(r.ratio !== undefined ? { ratio: r.ratio } : {}), ...(r.diffImage ? { diff: r.diffImage } : {}) });
321
+ }
322
+ }
323
+ }
324
+ } finally {
325
+ await call('catalog.show', {}).catch(() => null);
326
+ await call('dev.close', {}).catch(() => null);
327
+ }
328
+ const count = (v) => rows.filter((x) => x.verdict.startsWith(v)).length;
329
+ print(JSON.stringify({ shots: rows.length, match: count('match'), differs: count('differs'), new: count('new'), size: count('size'), rows: rows.filter((x) => x.verdict !== 'match') }, null, 2));
330
+ process.exitCode = count('differs') || count('size') ? 1 : 0;
331
+ return;
332
+ }
333
+ case 'visual': {
334
+ const settings = visualSettings(config, path.dirname(path.resolve(configPath)), flags);
335
+ const [sub, name] = rest;
336
+ if (sub === 'list') {
337
+ const rows = listVisual(settings);
338
+ print(rows.length ? rows.map((r) => `${r.variant}/${r.name}${r.pending ? ' (a capture waits for approval)' : ''}`).join(String.fromCharCode(10)) : `no references under ${settings.dir}`);
339
+ return;
340
+ }
341
+ if (!name) fail('visual check <name> [--serial s | --simulator udid | --file shot.png] | visual approve <name> | visual list');
342
+ if (sub === 'approve') {
343
+ print(`approved: ${approveVisual(settings, String(name))}`);
344
+ return;
345
+ }
346
+ if (sub !== 'check') fail(`visual ${sub}: check, approve or list`);
347
+ const png = flags.file ? fs.readFileSync(String(flags.file)) : captureOsPng({ serial: flags.serial, simulator: flags.simulator, adb: String(flags.adb ?? process.env.ADB ?? 'adb') });
348
+ const r = checkVisual(settings, String(name), png);
349
+ print(JSON.stringify(r, null, 2));
350
+ if (r.verdict === 'differs') process.exitCode = 1;
351
+ else if (r.verdict !== 'match') process.exitCode = 2;
352
+ return;
353
+ }
354
+ case 'studio': {
355
+ // The same page for a hub's studio (one UI, two transports): its files, written where the hub serves them.
356
+ if (flags.emit) {
357
+ const dir = path.resolve(String(flags.emit));
358
+ fs.mkdirSync(path.join(dir, 'assets'), { recursive: true });
359
+ for (const lang of ['fa', 'en']) fs.writeFileSync(path.join(dir, `studio.${lang}.html`), studioHtml(lang === 'fa' ? 'استودیوی طراحی' : 'Design studio', { mode: 'hub', lang, scriptUrl: '/assets/studio.js', headers: Object.fromEntries([].concat(flags.header ?? []).map((h) => String(h).split('=')).filter(([k, v]) => k && v)) }));
360
+ fs.writeFileSync(path.join(dir, 'assets', 'studio.js'), studioScript());
361
+ print(`wrote studio.fa.html, studio.en.html, assets/studio.js under ${dir}`);
362
+ return;
363
+ }
364
+ const sets = tokenSets(config, path.dirname(path.resolve(configPath)));
365
+ const client = clientFor({ privateKey: key({ required: !flags.unsigned })?.key });
366
+ tallyCalls(client, recorder('studio', url));
367
+ const port = Number(flags.port ?? 47901);
368
+ // Several devices at once (plan v2 P1-17): --devices all | a,b (names in g9pilot.json "devices"), each its own
369
+ // client; a token change goes to all. `studio.modes`: the states each grid shot is taken in. A reference overlay
370
+ // is `g9pilot visual`: the device entry's `serial` / `simulator` gives the OS picture to compare.
371
+ const listed = config.devices ?? [];
372
+ const chosen = flags.devices ? (flags.devices === 'all' ? listed : listed.filter((d) => String(flags.devices).split(',').includes(d.name))) : [];
373
+ const pk = key({ required: !flags.unsigned })?.key;
374
+ const devices = chosen.map((d) => {
375
+ const c = new PilotClient({ base: String(d.url).replace(/\/+$/, ''), kid, privateKey: pk });
376
+ return { name: d.name, entry: d, call: (command, args) => c.call(command, args, { timeoutMs: 20_000 }) };
377
+ });
378
+ const settings = config.visual ? visualSettings(config, path.dirname(path.resolve(configPath)), flags) : null;
379
+ const reference = settings
380
+ ? (name, refName) => {
381
+ const d = devices.find((x) => x.name === name)?.entry ?? {};
382
+ const png = captureOsPng({ serial: d.serial ?? flags.serial, simulator: d.simulator ?? flags.simulator, adb: String(flags.adb ?? process.env.ADB ?? 'adb') });
383
+ const r = checkVisual(settings, refName, png);
384
+ const diffFile = r.diffImage && fs.existsSync(r.diffImage) ? r.diffImage : null;
385
+ return { verdict: r.verdict, ratio: r.ratio ?? null, diff: diffFile ? fs.readFileSync(diffFile).toString('base64') : null };
386
+ }
387
+ : null;
388
+ studioServer({ port, call: (command, args) => client.call(command, args, { timeoutMs: 20_000 }), devices: devices.length ? devices : null, modes: config.studio?.modes ?? [], reference, approve: (snapshot, o) => tokensApprove(sets, snapshot, { ...o, ...approveFollowUps() }) });
389
+ print(`G9AppPilot Studio: http://127.0.0.1:${port} (${devices.length ? devices.map((d) => d.name).join(', ') : `device ${url}`}; ${(config.studio?.modes ?? []).length} mode(s); Ctrl+C ends it)`);
390
+ return;
391
+ }
392
+ case 'conformance': {
393
+ const k = key({ required: false });
394
+ const report = await runConformance({ base: url, privateKey: k?.key, kid, fetchImpl: (u, o = {}) => fetch(u, { ...o, headers: { ...relay.headers, ...(o.headers ?? {}) } }) });
395
+ if (flags.json) print(report);
396
+ else {
397
+ for (const c of report.checks) print(`${c.skipped ? 'SKIP' : c.ok ? 'PASS' : 'FAIL'} ${c.name}${c.detail ? ` (${c.detail})` : ''}`);
398
+ print(`${report.app}: ${report.passed} passed, ${report.failed} failed, ${report.skipped} skipped`);
399
+ }
400
+ process.exitCode = report.failed ? 1 : 0;
401
+ return;
402
+ }
403
+ case 'keys': {
404
+ const [sub] = rest;
405
+ if (sub === 'new') {
406
+ if (!flags.kid) fail('keys new --kid <id>');
407
+ const pair = generateKeyPair();
408
+ if (flags.out) fs.writeFileSync(String(flags.out), pair.privateKey, { mode: 0o600 });
409
+ print({ kid: flags.kid, publicKey: pair.publicKey, privateKey: flags.out ? `written to ${flags.out}` : 'NOT kept — pass --out <file> to keep it' });
410
+ return;
411
+ }
412
+ if (sub === 'public') {
413
+ print(publicKeyOf(key().key));
414
+ return;
415
+ }
416
+ fail('keys new --kid <id> [--out file] | keys public');
417
+ return;
418
+ }
419
+ default:
420
+ fail('commands: hello, call <command> [json], status [--brief], events, proxy --listen <port> --target <url>, host --listen <port>, dev, conformance, keys new|public', cmd ? 2 : 0);
421
+ }
422
+ }
423
+
424
+ /** The hub of g9pilot.json "hub", or the reason there is none (session send, relay and approvals need it). */
425
+ async function hubOrFail() {
426
+ const h = await hub();
427
+ if (!h) fail('g9pilot.json has no "hub" (an adapter: "hub": {"adapter": "<path or package>", …its options}).');
428
+ if (h.error) fail(h.error);
429
+ return h;
430
+ }
431
+
432
+ /** `g9pilot relay`: this machine's devices for the hub's studio page, until Ctrl+C. */
433
+ async function relayCommand() {
434
+ const h = await hubOrFail();
435
+ const k = key();
436
+ const targets = config.devices?.length ? config.devices : [{ name: 'default', url }];
437
+ const devices = [];
438
+ for (const t of targets) {
439
+ const client = new PilotClient({ base: t.url, privateKey: k.key, kid, extraHeaders: relay.headers });
440
+ let hello = null;
441
+ try {
442
+ const h = await client.hello({ timeoutMs: 3000 });
443
+ hello = { app: h.app, appVersion: h.versionName, platform: h.platform ?? null };
444
+ } catch {
445
+ continue; // a device that does not answer is not offered
446
+ }
447
+ devices.push({ name: t.name, hello, call: (command, args) => client.call(command, args, { timeoutMs: 20_000 }) });
448
+ }
449
+ if (!devices.length) fail(`none of the devices in g9pilot.json answers (${targets.map((t) => t.url).join(', ')})`);
450
+ const tally = recorder('relay', devices[0].name);
451
+ const stop = new AbortController();
452
+ process.once('SIGINT', () => stop.abort());
453
+ process.stderr.write(`g9pilot relay: ${devices.map((d) => `${d.name} (${d.hello.app} ${d.hello.appVersion})`).join(', ')} → ${h.url} (key ${k.source}; Ctrl+C ends it)\n`);
454
+ await runRelay({ poll: h.relayPoll, name: String(flags.name ?? os.hostname()), devices, signal: stop.signal, onRequest: (_d, command, ok) => tally.record(command, { ok }), log: (l) => process.stderr.write(`${l}\n`) });
455
+ }
456
+
457
+ /** `g9pilot approvals list | apply <id>`: the hub's design approvals into this checkout's token files (never a commit). */
458
+ async function approvals([sub, id]) {
459
+ const h = await hubOrFail();
460
+ if (sub === 'list') {
461
+ const r = await h.approvals.list({ status: String(flags.status ?? 'approved'), branch: flags.branch ? String(flags.branch) : '' });
462
+ print(r.approvals.length ? r.approvals.map((a) => `${a.id} ${a.status} ${a.branch ?? '—'} ${a.app ?? ''} ${a.appVersion ?? ''} by ${a.by} ${a.changes.map((c) => `${c.id}: ${c.from} → ${c.to}`).join('; ')}`).join('\n') : 'no design approval in that state');
463
+ return;
464
+ }
465
+ // A person committed what an apply wrote (the /pilot-approve skill asks first): the approval says so, with the commit.
466
+ if (sub === 'committed' && id) {
467
+ await h.approvals.setStatus({ id, status: 'committed', ...(flags.commit ? { commit: String(flags.commit) } : {}) });
468
+ print(`${id}: committed${flags.commit ? ` (${flags.commit})` : ''}`);
469
+ return;
470
+ }
471
+ if (sub !== 'apply' || !id) fail('approvals list [--status approved|applied|committed|rejected] [--branch b] | approvals apply <DA-…> [--dry-run] | approvals committed <DA-…> [--commit <sha>]');
472
+ const a = await h.approvals.get(id);
473
+ if (a.status !== 'approved' && !flags.again) fail(`${id} is ${a.status}, not approved (--again to apply it once more)`);
474
+ if (a.branch && h.branch && a.branch !== h.branch) process.stderr.write(`g9pilot: ${id} was approved on ${a.branch}; this checkout is on ${h.branch}\n`);
475
+ const sets = tokenSets(config, path.dirname(path.resolve(configPath)));
476
+ const result = tokensApprove(sets, { changes: a.changes }, { dryRun: flags['dry-run'] === true, ...approveFollowUps() });
477
+ print(result);
478
+ if (flags['dry-run']) return;
479
+ await h.approvals.setStatus({ id, status: 'applied', files: result.files, tokens: result.changed.length, modules: result.generated.length });
480
+ print(`${id}: applied (the files changed; nothing is committed)`);
481
+ }
482
+
483
+ /** A signing proxy: the unchanged runner (protocol 1, unsigned) talks to --listen; the device gets signed requests. */
484
+ function proxy() {
485
+ const listen = Number(flags.listen ?? 8799);
486
+ const target = String(flags.target ?? 'http://127.0.0.1:18799').replace(/\/+$/, '');
487
+ const k = key();
488
+ const client = new PilotClient({ base: target, privateKey: k.key, kid });
489
+ const tally = recorder('proxy', target);
490
+ const server = http.createServer(async (req, res) => {
491
+ const t0 = Date.now();
492
+ const chunks = [];
493
+ for await (const c of req) chunks.push(c);
494
+ const body = Buffer.concat(chunks).toString('utf8');
495
+ const reqPath = (req.url ?? '/').split('?')[0];
496
+ try {
497
+ let headers = {};
498
+ const open = req.method === 'GET' && (reqPath === '/' || reqPath === '/qa' || reqPath === '/pilot');
499
+ if (!open) {
500
+ const hello = client.helloCache ?? (await client.hello());
501
+ headers = signRequest({ privateKey: k.key, kid: kid ?? hello.kids?.[0], appId: hello.app, method: req.method, path: reqPath, body, now: Date.now() + Math.round(client.offsetMs) });
502
+ }
503
+ const send = (h) =>
504
+ fetch(`${target}${req.url}`, {
505
+ method: req.method,
506
+ headers: { 'content-type': req.headers['content-type'] ?? 'application/json', ...(req.headers['last-event-id'] ? { 'last-event-id': req.headers['last-event-id'] } : {}), ...h },
507
+ body: req.method === 'GET' ? undefined : body,
508
+ });
509
+ let upstream = await send(headers);
510
+ // The device clock moved (a restored emulator snapshot, a phone that slept): correct from the time it reports and
511
+ // send once more, as the client does — a proxy that did not left every request of the session refused.
512
+ if (!open && upstream.status === 401) {
513
+ const refused = await upstream.clone().json().catch(() => null);
514
+ if (refused?.code === 'CLOCK_SKEW' && typeof refused.deviceTime === 'number') {
515
+ client.offsetMs = refused.deviceTime - Date.now();
516
+ const hello = client.helloCache ?? (await client.hello());
517
+ const again = signRequest({ privateKey: k.key, kid: kid ?? hello.kids?.[0], appId: hello.app, method: req.method, path: reqPath, body, now: Date.now() + Math.round(client.offsetMs) });
518
+ upstream = await send(again);
519
+ }
520
+ }
521
+ if (open) {
522
+ // The hello says what a client behind the proxy needs: no token and no signature of its own (the proxy signs —
523
+ // a client that read `signatureRequired` used to look for a key it does not need, and failed without one).
524
+ const hello = await upstream.json();
525
+ client.helloCache = hello;
526
+ if (typeof hello.deviceTime === 'number') client.offsetMs = hello.deviceTime - Date.now();
527
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
528
+ res.end(JSON.stringify({ ...hello, tokenRequired: false, signatureRequired: false, signedBy: 'g9pilot proxy' }));
529
+ return;
530
+ }
531
+ // The session's record: a command's name and how it ended (never its arguments or its answer).
532
+ const command = req.method === 'POST' && (reqPath === '/qa' || reqPath === '/pilot') ? (() => { try { return JSON.parse(body).command; } catch { return null; } })() : null;
533
+ const verdict = command || upstream.status === 401 ? upstream.clone().json().catch(() => null) : null;
534
+ res.writeHead(upstream.status, { 'content-type': upstream.headers.get('content-type') ?? 'application/json' });
535
+ if (upstream.body) for await (const chunk of upstream.body) res.write(chunk);
536
+ res.end();
537
+ const answer = await verdict;
538
+ if (upstream.status === 401) tally.refused(answer?.code ?? 'REFUSED');
539
+ else if (command) { tally.hello(client.helloCache); tally.record(command, { ok: upstream.ok && answer?.ok !== false, ms: Date.now() - t0 }); }
540
+ if (upstream.status === 401) client.helloCache = null; // a key or clock change: read the hello again next time
541
+ } catch (e) {
542
+ res.writeHead(502, { 'content-type': 'application/json' });
543
+ res.end(JSON.stringify({ ok: false, error: `g9pilot proxy: the device at ${target} did not answer (${e.message})`, code: 'PROXY_UPSTREAM', category: 'automation' }));
544
+ }
545
+ });
546
+ server.listen(listen, '127.0.0.1', () => process.stderr.write(`g9pilot proxy: 127.0.0.1:${listen} → ${target} (key ${k.source})\n`));
547
+ }
548
+
549
+ /**
550
+ * The relay for the devices attached to this machine. It holds no key: a signed request passes through unchanged (the
551
+ * signature covers the device's path, `/qa`, which is what the device receives). Android devices are reached through
552
+ * `adb forward` (a local port per serial, made on first use); a booted simulator shares this machine's loopback, so it is
553
+ * named in "hostDevices" with its URL. Server-Sent Events stream through.
554
+ */
555
+ function host() {
556
+ const listen = Number(flags.listen ?? 47900);
557
+ const bind = String(flags.bind ?? '127.0.0.1');
558
+ const token = process.env[String(flags['token-env'] ?? 'G9PILOT_HOST_TOKEN')] ?? '';
559
+ const loopback = bind === '127.0.0.1' || bind === '::1' || bind === 'localhost';
560
+ if (!loopback && token.length < 16) fail(`host: binding ${bind} needs a token of at least 16 characters (G9PILOT_HOST_TOKEN or --token-env).`);
561
+ const devicePort = Number(flags['device-port'] ?? config.devicePort ?? 8799);
562
+ const adb = String(flags.adb ?? process.env.ADB ?? 'adb');
563
+ const forwards = new Map();
564
+ let nextPort = Number(flags['forward-base'] ?? listen + 10);
565
+
566
+ const adbSerials = () => {
567
+ try {
568
+ return execFileSync(adb, ['devices'], { encoding: 'utf8', timeout: 10_000 })
569
+ .split('\n')
570
+ .slice(1)
571
+ .map((l) => l.trim().split(/\s+/))
572
+ .filter((p) => p[1] === 'device')
573
+ .map((p) => p[0]);
574
+ } catch {
575
+ return [];
576
+ }
577
+ };
578
+ const fixed = () => config.hostDevices ?? [];
579
+ const devices = () => [...adbSerials().map((serial) => ({ name: serial, kind: 'android' })), ...fixed().map((d) => ({ name: d.name, kind: d.kind ?? 'url' }))];
580
+ const urlFor = (name) => {
581
+ const f = fixed().find((d) => d.name === name);
582
+ if (f) return String(f.url).replace(/\/+$/, '');
583
+ if (!adbSerials().includes(name)) return null;
584
+ let port = forwards.get(name);
585
+ if (!port) {
586
+ port = nextPort++;
587
+ execFileSync(adb, ['-s', name, 'forward', `tcp:${port}`, `tcp:${devicePort}`], { timeout: 10_000 });
588
+ forwards.set(name, port);
589
+ }
590
+ return `http://127.0.0.1:${port}`;
591
+ };
592
+ const deny = (res, status, code, error) => {
593
+ res.writeHead(status, { 'content-type': 'application/json' });
594
+ res.end(JSON.stringify({ ok: false, error, code, category: 'auth' }));
595
+ };
596
+ // A shared device is reserved before it is handed out (plan v2 P1-14): g9pilot.json "reserve": {hold: [cmd…],
597
+ // release: [cmd…], idleMinutes, resources?} with {device} {resource} {minutes} {id} filled in — whoever runs the lab names its own booking
598
+ // (this tool knows none). The id the hold prints (`"id":"…"` or the last word) is what release gets; a refused hold
599
+ // answers 423 with the booking tool's own words; idle devices (and all of them when the host stops) are released.
600
+ const reserve = config.reserve?.hold ? config.reserve : null;
601
+ const holds = new Map();
602
+ const idleMs = Number(reserve?.idleMinutes ?? 15) * 60_000;
603
+ const runReserve = (cmd, vars) => {
604
+ const [file, ...args] = cmd.map((x) => String(x).replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? '')));
605
+ try {
606
+ return { ok: true, out: execFileSync(file === 'node' ? process.execPath : file, args, { encoding: 'utf8', timeout: 60_000 }) };
607
+ } catch (e) {
608
+ return { ok: false, out: `${e.stdout ?? ''}${e.stderr ?? ''}`.trim() || e.message };
609
+ }
610
+ };
611
+ const hold = (name) => {
612
+ if (!reserve) return null;
613
+ const known = holds.get(name);
614
+ if (known) {
615
+ known.lastUse = Date.now();
616
+ return null;
617
+ }
618
+ // {resource}: what the booking calls this device — reserve.resources[name], the fixed device's own `resource`,
619
+ // reserve.resources['*'], else its name.
620
+ const resource = reserve.resources?.[name] ?? fixed().find((d) => d.name === name)?.resource ?? (config.devices ?? []).find((d) => d.name === name)?.resource ?? reserve.resources?.['*'] ?? name;
621
+ const r = runReserve(reserve.hold, { device: name, resource, minutes: Math.ceil(idleMs / 60_000) + 5 });
622
+ if (!r.ok) return `The device ${name} could not be reserved: ${r.out.slice(-300)}`;
623
+ const id = /"id"\s*:\s*\\?"([^"\\]+)/.exec(r.out)?.[1] ?? r.out.trim().split(/\s+/).pop();
624
+ holds.set(name, { id, lastUse: Date.now() });
625
+ process.stderr.write(`g9pilot host: ${name} reserved (${id})\n`);
626
+ return null;
627
+ };
628
+ const release = (name) => {
629
+ const h = holds.get(name);
630
+ if (!h) return;
631
+ holds.delete(name);
632
+ if (reserve.release) runReserve(reserve.release, { device: name, id: h.id });
633
+ process.stderr.write(`g9pilot host: ${name} released (${h.id})\n`);
634
+ };
635
+ if (reserve) {
636
+ setInterval(() => {
637
+ for (const [name, h] of holds) if (Date.now() - h.lastUse > idleMs) release(name);
638
+ }, Math.min(30_000, idleMs)).unref();
639
+ const stop = () => {
640
+ for (const name of [...holds.keys()]) release(name);
641
+ process.exit(0);
642
+ };
643
+ process.on('SIGINT', stop);
644
+ process.on('SIGTERM', stop);
645
+ }
646
+
647
+ const server = http.createServer(async (req, res) => {
648
+ if (token && req.headers['x-g9p-host-token'] !== token) return deny(res, 401, 'HOST_TOKEN', 'This relay needs its token (x-g9p-host-token).');
649
+ const [pathOnly, query = ''] = (req.url ?? '/').split('?');
650
+ if (pathOnly === '/devices') {
651
+ const list = [];
652
+ for (const d of devices()) {
653
+ let hello = null;
654
+ try {
655
+ const u = urlFor(d.name);
656
+ hello = u ? await (await fetch(`${u}/qa`, { signal: AbortSignal.timeout(2500) })).json() : null;
657
+ } catch {
658
+ hello = null;
659
+ }
660
+ list.push({ ...d, bridge: hello ? { app: hello.app, protocol: hello.protocol, jsReady: hello.jsReady, signatureRequired: hello.signatureRequired } : null });
661
+ }
662
+ res.writeHead(200, { 'content-type': 'application/json' });
663
+ return res.end(JSON.stringify({ ok: true, devices: list }));
664
+ }
665
+ const m = pathOnly.match(/^\/d\/([^/]+)(\/.*)$/);
666
+ if (!m) return deny(res, 404, 'NOT_FOUND', 'Paths: /devices, /d/<device>/qa, /d/<device>/pilot, /d/<device>/pilot/events.');
667
+ const name = decodeURIComponent(m[1]);
668
+ let target;
669
+ try {
670
+ target = urlFor(name);
671
+ } catch (e) {
672
+ return deny(res, 502, 'HOST_FORWARD', `adb forward for ${name} failed: ${e.message}`);
673
+ }
674
+ if (!target) return deny(res, 404, 'NO_DEVICE', `No device "${name}" on this host.`);
675
+ const refused = hold(name);
676
+ if (refused) return deny(res, 423, 'DEVICE_RESERVED', refused);
677
+ const chunks = [];
678
+ for await (const c of req) chunks.push(c);
679
+ const headers = {};
680
+ for (const [k, v] of Object.entries(req.headers)) {
681
+ if ((k.startsWith('x-g9p-') && k !== 'x-g9p-host-token') || k === 'content-type' || k === 'accept' || k === 'last-event-id') headers[k] = v;
682
+ }
683
+ const ctrl = new AbortController();
684
+ res.on('close', () => ctrl.abort());
685
+ try {
686
+ const upstream = await fetch(`${target}${m[2]}${query ? `?${query}` : ''}`, {
687
+ method: req.method,
688
+ headers,
689
+ body: req.method === 'GET' || req.method === 'HEAD' ? undefined : Buffer.concat(chunks),
690
+ signal: ctrl.signal,
691
+ });
692
+ res.writeHead(upstream.status, { 'content-type': upstream.headers.get('content-type') ?? 'application/json', 'cache-control': 'no-cache' });
693
+ if (upstream.body) for await (const chunk of upstream.body) res.write(chunk);
694
+ res.end();
695
+ } catch (e) {
696
+ if (!ctrl.signal.aborted) deny(res, 502, 'HOST_UPSTREAM', `The device ${name} did not answer (${e.message}).`);
697
+ }
698
+ });
699
+ server.listen(listen, bind, () => process.stderr.write(`g9pilot host: ${bind}:${listen}${token ? ' (token required)' : ''}; devices: ${devices().map((d) => d.name).join(', ') || 'none yet'}\n`));
700
+ }
701
+
702
+ /**
703
+ * Live development. The page's dev server runs on THIS machine; each device reaches it on its own loopback (`adb reverse`
704
+ * on Android, the Mac's loopback for a simulator), so the bundle's HMR client, which connects back to the URL it was
705
+ * built with, finds it too. The bridge command `dev.server` (admin: it changes what code runs) points the app at it.
706
+ */
707
+ async function dev() {
708
+ const port = Number(flags.port ?? config.dev?.port ?? 3000);
709
+ const bundle = String(flags.bundle ?? config.dev?.bundle ?? 'main.lynx.bundle');
710
+ const cwd = path.resolve(String(flags.cwd ?? config.dev?.cwd ?? '.'));
711
+ const command = String(flags.cmd ?? config.dev?.cmd ?? 'npm run dev');
712
+ const bundleUrl = `http://127.0.0.1:${port}/${bundle}`;
713
+ const k = key();
714
+ const client = clientFor({ privateKey: k.key });
715
+ const log = (line) => process.stderr.write(`g9pilot dev: ${line}\n`);
716
+
717
+ const { spawn } = await import('node:child_process');
718
+ const server = spawn(command, { cwd, shell: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, G9PILOT_DEV_PORT: String(port) } });
719
+ const relayOut = (stream, out) => stream.on('data', (d) => out.write(String(d).replace(/^/gm, ' │ ')));
720
+ relayOut(server.stdout, process.stdout);
721
+ relayOut(server.stderr, process.stderr);
722
+ // --watch-flows [target] (plan v2 P1-14): after every rebuild the dev server reports (its own «built / compiled /
723
+ // ready» line, `dev.rebuilt` in g9pilot.json to name another), the app takes the hot update, settles, and the runner
724
+ // replays the flows (default `tag:@smoke`) — a change that breaks a basic path says so within seconds of the save.
725
+ const watchTarget = flags['watch-flows'] ? (flags['watch-flows'] === true ? 'tag:@smoke' : String(flags['watch-flows'])) : null;
726
+ if (watchTarget) {
727
+ const runner = config.runner?.path ? config.runner : null;
728
+ if (!runner) fail('--watch-flows needs a runner (g9pilot.json "runner": {path, args}).');
729
+ const rebuilt = new RegExp(config.dev?.rebuilt ?? '(built|compiled|rebuilt|ready) in [\\d.]+ ?m?s', 'i');
730
+ let timer = null;
731
+ let running = false;
732
+ let again = false;
733
+ let first = true;
734
+ const replay = async () => {
735
+ if (running) {
736
+ again = true;
737
+ return;
738
+ }
739
+ running = true;
740
+ const t0 = Date.now();
741
+ // A change the hot update cannot take reloads the whole page: wait for the page to declare itself again (the
742
+ // hello's jsReady), then for it to settle — the first try ran the flow into «the page has not called ready()».
743
+ for (const until = Date.now() + 45_000; Date.now() < until; ) {
744
+ const hello = await fetch(`${url}/qa`, { signal: AbortSignal.timeout(2000) }).then((r) => r.json()).catch(() => null);
745
+ if (hello?.jsReady) break;
746
+ await new Promise((r) => setTimeout(r, 500));
747
+ }
748
+ await clientFor({ privateKey: key().key }).call('ui.waitIdle', {}, { timeoutMs: 20_000 }).catch(() => null);
749
+ const { spawn: spawnRun } = await import('node:child_process');
750
+ // Each replay's report in its own temporary folder — never the runner's default beside the project.
751
+ const reportDir = fs.mkdtempSync(path.join((await import('node:os')).tmpdir(), 'g9pilot-watch-'));
752
+ const child = spawnRun(process.execPath, [runner.path, 'run', watchTarget, ...(runner.args ?? []), '--quiet', '--report', reportDir], { stdio: ['ignore', 'pipe', 'pipe'] });
753
+ let out = '';
754
+ child.stdout.on('data', (d) => (out += d));
755
+ child.stderr.on('data', (d) => (out += d));
756
+ child.on('close', (code) => {
757
+ const summary = out.trim().split(/\r?\n/).filter(Boolean).slice(-2).join(' · ');
758
+ process.stderr.write(`g9pilot dev: ${code === 0 ? '✓' : '✗'} ${watchTarget} after the change — ${summary} (${Math.round((Date.now() - t0) / 100) / 10} s)\n`);
759
+ running = false;
760
+ if (again) {
761
+ again = false;
762
+ void replay();
763
+ }
764
+ });
765
+ };
766
+ const onLine = (d) => {
767
+ if (!rebuilt.test(String(d))) return;
768
+ if (first) {
769
+ first = false; // the first build is the one the app loads at start, not a change
770
+ return;
771
+ }
772
+ clearTimeout(timer);
773
+ timer = setTimeout(() => void replay(), Number(config.dev?.settleMs ?? 2500));
774
+ };
775
+ server.stdout.on('data', onLine);
776
+ server.stderr.on('data', onLine);
777
+ }
778
+ server.on('exit', (code) => {
779
+ log(`the dev server stopped (${code})`);
780
+ process.exit(code ?? 1);
781
+ });
782
+
783
+ const deadline = Date.now() + Number(flags.timeout ?? 180_000);
784
+ for (;;) {
785
+ const ok = await fetch(bundleUrl, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
786
+ .then((r) => r.ok)
787
+ .catch(() => false);
788
+ if (ok) break;
789
+ if (Date.now() > deadline) {
790
+ server.kill();
791
+ fail(`the dev server did not serve ${bundleUrl} in time`);
792
+ }
793
+ await new Promise((r) => setTimeout(r, 1000));
794
+ }
795
+ log(`serving ${bundleUrl}`);
796
+
797
+ const adb = String(flags.adb ?? process.env.ADB ?? 'adb');
798
+ let serials = flags.serial ? [String(flags.serial)] : [];
799
+ if (!serials.length) {
800
+ try {
801
+ serials = execFileSync(adb, ['devices'], { encoding: 'utf8', timeout: 10_000 })
802
+ .split('\n')
803
+ .slice(1)
804
+ .map((l) => l.trim().split(/\s+/))
805
+ .filter((p) => p[1] === 'device')
806
+ .map((p) => p[0]);
807
+ } catch {
808
+ serials = [];
809
+ }
810
+ }
811
+ for (const serial of serials) {
812
+ try {
813
+ execFileSync(adb, ['-s', serial, 'reverse', `tcp:${port}`, `tcp:${port}`], { timeout: 10_000 });
814
+ log(`${serial}: adb reverse tcp:${port}`);
815
+ } catch (e) {
816
+ log(`${serial}: adb reverse failed (${e.message})`);
817
+ }
818
+ }
819
+ // A reverse port can vanish mid-session (another tool restarts the adb server: 2026-11-05 the app then failed «Fetch
820
+ // template resource failed … no bundle»). Put it back whenever it is gone, every 5 s, quietly.
821
+ const keepReverse = setInterval(() => {
822
+ for (const serial of serials) {
823
+ try {
824
+ const list = execFileSync(adb, ['-s', serial, 'reverse', '--list'], { encoding: 'utf8', timeout: 5_000 });
825
+ if (!list.includes(`tcp:${port}`)) {
826
+ execFileSync(adb, ['-s', serial, 'reverse', `tcp:${port}`, `tcp:${port}`], { timeout: 5_000 });
827
+ log(`${serial}: adb reverse tcp:${port} was gone — set again`);
828
+ }
829
+ } catch {
830
+ // the device went away; the next round tries again
831
+ }
832
+ }
833
+ }, 5_000);
834
+ keepReverse.unref?.();
835
+
836
+ const point = async (url) => {
837
+ const answer = await client.call('dev.server', { url }, { timeoutMs: 20_000 });
838
+ if (!answer?.ok) log(`dev.server: ${answer?.code ?? ''} ${answer?.error ?? JSON.stringify(answer)}`);
839
+ return answer;
840
+ };
841
+ const answer = await point(bundleUrl);
842
+ if (answer?.ok) log(`the app loads ${bundleUrl}: save a file and it changes in place${watchTarget ? `, then ${watchTarget} replays` : ''} (Ctrl+C ends).`);
843
+
844
+ let ending = false;
845
+ const end = async () => {
846
+ if (ending) return;
847
+ ending = true;
848
+ if (!flags.keep) {
849
+ await point(null).catch(() => undefined);
850
+ log('the app is back on its own bundle');
851
+ }
852
+ server.kill();
853
+ process.exit(0);
854
+ };
855
+ process.on('SIGINT', end);
856
+ process.on('SIGTERM', end);
857
+ }
858
+
859
+ /**
860
+ * Level 0 of a savepoint: an Android emulator's own snapshot (the app, its data, the OS, the clock), through the emulator
861
+ * console (`adb emu avd snapshot …`). A real phone or a simulator has no such thing: level 1 (the app's data) is for them.
862
+ */
863
+ function savepoint([sub, name]) {
864
+ const adb = String(flags.adb ?? process.env.ADB ?? 'adb');
865
+ const serial = String(flags.serial ?? 'emulator-5554');
866
+ if (flags.level === 'app' && (flags.via === 'bridge' || flags.device)) return bridgeAppSavepoint(sub, name);
867
+ if (flags.level === 'app' && flags.simulator) return simulatorAppSavepoint(String(flags.simulator), sub, name);
868
+ if (flags.simulator) return simulatorSavepoint(String(flags.simulator), sub, name);
869
+ if (flags.level === 'app') return appSavepoint(adb, serial, sub, name);
870
+ if (!serial.startsWith('emulator-')) fail(`savepoint: ${serial} is not an emulator — level 0 exists only on an Android emulator.`);
871
+ const emu = (args, timeout = 180_000) => execFileSync(adb, ['-s', serial, 'emu', 'avd', 'snapshot', ...args], { encoding: 'utf8', timeout }).trim();
872
+ const named = () => {
873
+ if (!name || !/^[A-Za-z0-9._-]{1,64}$/.test(name)) fail('savepoint needs a name: letters, digits, . _ - (at most 64).');
874
+ return name;
875
+ };
876
+ const started = Date.now();
877
+ const report = (what, out) => {
878
+ const failed = /KO|error|fail/i.test(out);
879
+ print({ ok: !failed, what, name: name ?? null, serial, ms: Date.now() - started, ...(flags.clockNote ? { clock: flags.clockNote } : {}), ...(flags.gpuNote ? { note: flags.gpuNote } : {}), emulator: out.split(/\r?\n/).filter((l) => l && l !== 'OK').slice(0, 6) });
880
+ process.exitCode = failed ? 1 : 0;
881
+ };
882
+ switch (sub) {
883
+ case 'save':
884
+ return report('saved', emu(['save', named()]));
885
+ case 'load': {
886
+ const out = emu(['load', named()]);
887
+ // The emulator drops off adb while it swaps its state, and the forwards with it (measured: «device offline», the
888
+ // bridge unreachable until a new forward): wait for it, then make them again (--forward 8797[,8798]).
889
+ try {
890
+ execFileSync(adb, ['-s', serial, 'wait-for-device'], { timeout: 60_000 });
891
+ } catch {
892
+ // reported by the caller's next command
893
+ }
894
+ // The snapshot brings its own clock back (measured: 191 s behind after a 3-minute-old snapshot). Asked to sync from
895
+ // the network, else set with su (an image that has it); a Play image has neither — then the offset is reported, and
896
+ // the bridge's clients correct for it (CLOCK_SKEW → deviceTime). A token the server issued may still look old.
897
+ let clock = 'unknown';
898
+ try {
899
+ const now = Math.floor(Date.now() / 1000);
900
+ execFileSync(adb, ['-s', serial, 'shell', `cmd network_time_update_service force_refresh >/dev/null 2>&1; su 0 toybox date -u @${now} >/dev/null 2>&1; true`], { timeout: 20_000 });
901
+ const after = Number(execFileSync(adb, ['-s', serial, 'shell', 'date +%s'], { encoding: 'utf8', timeout: 10_000 }).trim());
902
+ const behind = Math.round(Date.now() / 1000 - after);
903
+ clock = Math.abs(behind) <= 5 ? 'in step with this machine' : `${behind} s behind this machine (no network time and no su on this image; clients correct for it)`;
904
+ } catch {
905
+ clock = 'unknown';
906
+ }
907
+ flags.clockNote = clock;
908
+ for (const port of String(flags.forward ?? '').split(',').filter(Boolean)) {
909
+ try {
910
+ execFileSync(adb, ['-s', serial, 'forward', `tcp:${port}`, `tcp:${port}`], { timeout: 15_000 });
911
+ } catch {
912
+ // ditto
913
+ }
914
+ }
915
+ flags.gpuNote = 'A restored snapshot does not always bring the GPU back: on a host-GPU emulator a GL-heavy app (a map) has taken the emulator down at its next draw. Use --level app for such an app: it is also about twice as fast (measured 19 s / 11 s against 43 s / 25 s on a 3 GB emulator).';
916
+ return report('loaded', out);
917
+ }
918
+ case 'delete':
919
+ return report('deleted', emu(['delete', named()]));
920
+ case 'list':
921
+ return report('list', emu(['list'], 30_000));
922
+ default:
923
+ fail('savepoint save|load|list|delete <name> [--serial emulator-5554]');
924
+ }
925
+ }
926
+
927
+ /**
928
+ * Level 1 of a savepoint: the app's own data folder (`run-as`, so a debuggable build), streamed to
929
+ * `<dir>/<package>/<name>.tar` on this machine and back. The app is stopped before either, so no database is copied
930
+ * half-written; after a load it starts from that data. Works on a real phone as on an emulator.
931
+ */
932
+ /**
933
+ * Beside a level-1 savepoint `<name>.tar`: `<name>.meta.json` — when, what, from where, and the SERVER its data belongs
934
+ * to (plan v2 P1-11: `--server <database or environment>` and `--server-commit <sha>`, given by whoever knows them — a
935
+ * runner, a test hub, a person). An app's data names rows a server holds: loaded against another server it may mean
936
+ * nothing, so the pair is written down. Returns the metadata.
937
+ */
938
+ function writeSavepointMeta(file, meta) {
939
+ const full = {
940
+ format: 'g9pilot/savepoint-meta',
941
+ at: new Date().toISOString(),
942
+ ...meta,
943
+ server: flags.server || flags['server-commit'] ? { name: flags.server ? String(flags.server) : null, commit: flags['server-commit'] ? String(flags['server-commit']) : null } : null,
944
+ };
945
+ fs.writeFileSync(file.replace(/\.tar$/, '.meta.json'), JSON.stringify(full, null, 2));
946
+ return full;
947
+ }
948
+
949
+ /** The entry names of a tar (ustar headers, 512-byte blocks): enough to prove what a savepoint holds. */
950
+ export function tarEntries(buf) {
951
+ const names = [];
952
+ let at = 0;
953
+ while (at + 512 <= buf.length) {
954
+ const header = buf.subarray(at, at + 512);
955
+ if (header.every((b) => b === 0)) break;
956
+ const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/s, '');
957
+ const prefix = header.subarray(345, 500).toString('utf8').replace(/\0.*$/s, '');
958
+ const size = parseInt(header.subarray(124, 136).toString('utf8').replace(/\0.*$/s, '').trim() || '0', 8);
959
+ const type = String.fromCharCode(header[156] || 48);
960
+ if (type !== 'L' && type !== 'x' && type !== 'g') names.push(prefix ? `${prefix}/${name}` : name);
961
+ at += 512 + Math.ceil(size / 512) * 512;
962
+ }
963
+ return names;
964
+ }
965
+
966
+ async function appSavepoint(adb, serial, sub, name) {
967
+ const pkg = flags.package;
968
+ if (!pkg || !/^[A-Za-z][\w]*(\.[A-Za-z][\w]*)+$/.test(String(pkg))) fail('--level app needs --package <application id>.');
969
+ const dir = path.resolve(String(flags.dir ?? path.join(process.env.HOME ?? process.env.USERPROFILE ?? '.', '.g9pilot', 'savepoints')), String(pkg));
970
+ fs.mkdirSync(dir, { recursive: true });
971
+ if (sub === 'list') {
972
+ print({ ok: true, dir, savepoints: fs.readdirSync(dir).filter((f) => f.endsWith('.tar')).map((f) => ({ name: f.slice(0, -4), bytes: fs.statSync(path.join(dir, f)).size })) });
973
+ return;
974
+ }
975
+ if (!name || !/^[A-Za-z0-9._-]{1,64}$/.test(name)) fail('savepoint needs a name: letters, digits, . _ - (at most 64).');
976
+ const file = path.join(dir, `${name}.tar`);
977
+ const { spawnSync } = await import('node:child_process');
978
+ const started = Date.now();
979
+ spawnSync(adb, ['-s', serial, 'shell', 'am', 'force-stop', pkg], { timeout: 20_000 });
980
+ // Only what the app owns and needs again: not its caches (any path with «cache» in it), not the code cache. A cache
981
+ // folder may also be out of run-as's reach (SELinux; measured on API 36: `d?????????`), and toybox tar STOPS at the
982
+ // first unreadable entry — the first savepoint lost shared_prefs (the session) that way and still said «saved».
983
+ const parts = 'files databases shared_prefs no_backup';
984
+ const keep = `$(ls -d ${parts} 2>/dev/null)`;
985
+ if (sub === 'save') {
986
+ const r = spawnSync(adb, ['-s', serial, 'exec-out', `run-as ${pkg} sh -c 'cd /data/data/${pkg} && tar -cf - --exclude="*cache*" ${keep}'`], { maxBuffer: 2 * 1024 ** 3, timeout: 10 * 60_000 });
987
+ if (r.status !== 0 || !r.stdout?.length) fail(`savepoint save: run-as failed (${String(r.stderr || '').trim().slice(0, 200) || 'no data'}) — a debuggable build is needed.`);
988
+ // Proof, not hope: the tar holds every entry the device has (the same selection).
989
+ const listed = spawnSync(adb, ['-s', serial, 'shell', `run-as ${pkg} sh -c 'cd /data/data/${pkg} && find ${keep} ! -path "*cache*" 2>/dev/null | wc -l'`], { encoding: 'utf8', timeout: 60_000 });
990
+ const onDevice = Number(String(listed.stdout).trim());
991
+ const inTar = tarEntries(r.stdout);
992
+ if (Number.isFinite(onDevice) && inTar.length < onDevice) {
993
+ fail(`savepoint save: the tar holds ${inTar.length} of ${onDevice} entries — the device refused part of the app's data (last in the tar: ${inTar.at(-1) ?? 'none'}). Nothing was saved.`);
994
+ }
995
+ fs.writeFileSync(file, r.stdout);
996
+ const meta = writeSavepointMeta(file, { level: 'app', name, package: pkg, serial, entries: inTar.length, bytes: r.stdout.length });
997
+ print({ ok: true, what: 'saved', level: 'app', name, package: pkg, serial, file, bytes: r.stdout.length, entries: inTar.length, server: meta.server, ms: Date.now() - started });
998
+ return;
999
+ }
1000
+ if (sub === 'load') {
1001
+ if (!fs.existsSync(file)) fail(`No savepoint ${name} for ${pkg} in ${dir}.`);
1002
+ const clear = `run-as ${pkg} sh -c 'cd /data/data/${pkg} && rm -rf ${parts}'`;
1003
+ spawnSync(adb, ['-s', serial, 'shell', clear], { timeout: 60_000 });
1004
+ const tar = fs.readFileSync(file);
1005
+ const r = spawnSync(adb, ['-s', serial, 'exec-in', `run-as ${pkg} sh -c 'cd /data/data/${pkg} && tar -xf -'`], { input: tar, timeout: 10 * 60_000 });
1006
+ if (r.status !== 0) fail(`savepoint load: ${String(r.stderr || '').trim().slice(0, 200)}`);
1007
+ const expected = tarEntries(tar).length;
1008
+ const listed = spawnSync(adb, ['-s', serial, 'shell', `run-as ${pkg} sh -c 'cd /data/data/${pkg} && find ${keep} ! -path "*cache*" 2>/dev/null | wc -l'`], { encoding: 'utf8', timeout: 60_000 });
1009
+ const onDevice = Number(String(listed.stdout).trim());
1010
+ if (Number.isFinite(onDevice) && onDevice < expected) fail(`savepoint load: ${onDevice} of ${expected} entries arrived on the device — load it again.`);
1011
+ print({ ok: true, what: 'loaded', level: 'app', name, package: pkg, serial, bytes: fs.statSync(file).size, ms: Date.now() - started, next: 'start the app' });
1012
+ return;
1013
+ }
1014
+ fail('savepoint save|load|list <name> --level app --package <id>');
1015
+ }
1016
+
1017
+ /**
1018
+ * Level 0 on the iOS simulator (plan v2 P1-10, research R5): the WHOLE device — every app, its data, the keychain, the
1019
+ * settings. Save = shut down + `simctl clone` (APFS copy-on-write: seconds, little disk). simctl cannot restore INTO a
1020
+ * device and a clone has its own UDID, so load = shut down + the clone's `data` folder copied back over the original's
1021
+ * with APFS clones (`cp -c`) + boot: the device keeps its UDID, which the lab and every flow name. Run on the Mac.
1022
+ */
1023
+ async function simulatorSavepoint(udid, sub, name) {
1024
+ const { spawnSync } = await import('node:child_process');
1025
+ const os = await import('node:os');
1026
+ const devices = path.join(os.homedir(), 'Library', 'Developer', 'CoreSimulator', 'Devices');
1027
+ const prefix = `g9pilot-sp:${udid.slice(0, 8)}:`;
1028
+ const all = () => JSON.parse(run('xcrun', ['simctl', 'list', 'devices', '--json'])).devices;
1029
+ const clones = () => Object.values(all()).flat().filter((d) => String(d.name).startsWith(prefix)).map((d) => ({ name: d.name.slice(prefix.length), udid: d.udid }));
1030
+ const state = () => Object.values(all()).flat().find((d) => d.udid === udid)?.state ?? null;
1031
+ if (!state()) fail(`No simulator ${udid}.`);
1032
+ if (sub === 'list') return print({ ok: true, level: 0, simulator: udid, savepoints: clones() });
1033
+ if (!name || !/^[A-Za-z0-9._-]{1,48}$/.test(name)) fail('savepoint needs a name: letters, digits, . _ - (at most 48).');
1034
+ const started = Date.now();
1035
+ const shutdown = () => {
1036
+ if (state() !== 'Shutdown') spawnSync('xcrun', ['simctl', 'shutdown', udid], { timeout: 120_000 });
1037
+ };
1038
+ const boot = () => {
1039
+ spawnSync('xcrun', ['simctl', 'boot', udid], { timeout: 120_000 });
1040
+ spawnSync('xcrun', ['simctl', 'bootstatus', udid, '-b'], { timeout: 300_000 });
1041
+ };
1042
+ const existing = clones().find((c) => c.name === name);
1043
+ if (sub === 'save') {
1044
+ const wasBooted = state() === 'Booted';
1045
+ shutdown();
1046
+ const t0 = Date.now();
1047
+ if (existing) spawnSync('xcrun', ['simctl', 'delete', existing.udid], { timeout: 120_000 });
1048
+ const r = spawnSync('xcrun', ['simctl', 'clone', udid, `${prefix}${name}`], { encoding: 'utf8', timeout: 600_000 });
1049
+ if (r.status !== 0) fail(`simctl clone: ${String(r.stderr).trim().slice(0, 300)}`);
1050
+ const cloneMs = Date.now() - t0;
1051
+ if (wasBooted) boot();
1052
+ return print({ ok: true, what: 'saved', level: 0, simulator: udid, name, clone: String(r.stdout).trim(), cloneMs, ms: Date.now() - started, rebooted: wasBooted });
1053
+ }
1054
+ if (sub === 'load') {
1055
+ if (!existing) fail(`No level-0 savepoint ${name} of ${udid} (g9pilot savepoint list --simulator ${udid}).`);
1056
+ shutdown();
1057
+ const t0 = Date.now();
1058
+ const dst = path.join(devices, udid, 'data');
1059
+ const src = path.join(devices, existing.udid, 'data');
1060
+ if (!fs.existsSync(src)) fail(`The clone's data folder is missing: ${src}`);
1061
+ // Beside the original, never over it: the swap happens only after a good copy (the first try removed the original
1062
+ // first and a refused file left the lab simulator with a partial copy — 2026-11-04). Some of the simulator's own
1063
+ // caches cannot be copied by a user (Library/Caches/locationd: «Operation not permitted»); they rebuild themselves,
1064
+ // so a refusal there is accepted — anywhere else the load stops and the device stays as it was.
1065
+ const fresh = `${dst}.g9new`;
1066
+ const old = `${dst}.g9old`;
1067
+ fs.rmSync(fresh, { recursive: true, force: true });
1068
+ const r = spawnSync('cp', ['-cRp', src, fresh], { encoding: 'utf8', timeout: 600_000, maxBuffer: 64 * 1024 * 1024 });
1069
+ const refused = String(r.stderr ?? '').split('\n').filter(Boolean);
1070
+ const serious = refused.filter((l) => !/\/data\/Library\/Caches\//.test(l));
1071
+ if (!fs.existsSync(fresh) || serious.length) {
1072
+ fs.rmSync(fresh, { recursive: true, force: true });
1073
+ fail(`cp -c refused ${serious.length} file(s) outside the caches; the device is unchanged: ${serious.slice(0, 3).join(' | ').slice(0, 400)}`);
1074
+ }
1075
+ fs.rmSync(old, { recursive: true, force: true });
1076
+ fs.renameSync(dst, old);
1077
+ fs.renameSync(fresh, dst);
1078
+ fs.rmSync(old, { recursive: true, force: true });
1079
+ const copyMs = Date.now() - t0;
1080
+ const t1 = Date.now();
1081
+ boot();
1082
+ return print({ ok: true, what: 'loaded', level: 0, simulator: udid, name, copyMs, cachesSkipped: refused.length, bootMs: Date.now() - t1, ms: Date.now() - started, next: 'start the app' });
1083
+ }
1084
+ if (sub === 'delete') {
1085
+ if (!existing) fail(`No level-0 savepoint ${name} of ${udid}.`);
1086
+ spawnSync('xcrun', ['simctl', 'delete', existing.udid], { timeout: 120_000 });
1087
+ return print({ ok: true, what: 'deleted', level: 0, simulator: udid, name });
1088
+ }
1089
+ fail('savepoint save|load|list|delete <name> --simulator <udid>');
1090
+ }
1091
+
1092
+ /**
1093
+ * Level 1 on the iOS simulator (plan v2 P1-08, research R5): the app's data container (`simctl get_app_container … data`)
1094
+ * is a folder of this Mac, so it is a tar of Documents and Library — not Library/Caches, not the launch snapshots
1095
+ * (SplashBoard), not tmp — to `<dir>/<bundle id>/<name>.tar` and back. The app is terminated first; after a load it
1096
+ * starts from that data. The Keychain is not in the container: a session kept there stays as it is on this simulator.
1097
+ */
1098
+ async function simulatorAppSavepoint(udid, sub, name) {
1099
+ const bundle = flags.package ?? flags.bundle;
1100
+ if (!bundle || !/^[A-Za-z][\w-]*(\.[A-Za-z][\w-]*)+$/.test(String(bundle))) fail('--level app --simulator <udid> needs --package <bundle id>.');
1101
+ const dir = path.resolve(String(flags.dir ?? path.join(process.env.HOME ?? process.env.USERPROFILE ?? '.', '.g9pilot', 'savepoints')), String(bundle));
1102
+ fs.mkdirSync(dir, { recursive: true });
1103
+ if (sub === 'list') {
1104
+ print({ ok: true, dir, savepoints: fs.readdirSync(dir).filter((f) => f.endsWith('.tar')).map((f) => ({ name: f.slice(0, -4), bytes: fs.statSync(path.join(dir, f)).size })) });
1105
+ return;
1106
+ }
1107
+ if (!name || !/^[A-Za-z0-9._-]{1,64}$/.test(name)) fail('savepoint needs a name: letters, digits, . _ - (at most 64).');
1108
+ const file = path.join(dir, `${name}.tar`);
1109
+ const { spawnSync } = await import('node:child_process');
1110
+ const started = Date.now();
1111
+ const container = run('xcrun', ['simctl', 'get_app_container', udid, String(bundle), 'data']).trim();
1112
+ if (!container || !fs.existsSync(container)) fail(`savepoint: ${bundle} is not installed on simulator ${udid}.`);
1113
+ spawnSync('xcrun', ['simctl', 'terminate', udid, String(bundle)], { timeout: 20_000 });
1114
+ const SKIP = ['Library/Caches', 'Library/SplashBoard', 'Library/Saved Application State'];
1115
+ const skipped = (rel) => SKIP.some((x) => rel === x || rel.startsWith(`${x}/`));
1116
+ // Every entry (folders too) the savepoint should hold — the proof both ways, as on Android.
1117
+ const entries = () => {
1118
+ const out = [];
1119
+ const walk = (rel) => {
1120
+ for (const e of fs.readdirSync(path.join(container, rel), { withFileTypes: true })) {
1121
+ const r = `${rel}/${e.name}`;
1122
+ if (skipped(r)) continue;
1123
+ out.push(r);
1124
+ if (e.isDirectory()) walk(r);
1125
+ }
1126
+ };
1127
+ for (const top of ['Documents', 'Library']) {
1128
+ if (!fs.existsSync(path.join(container, top))) continue;
1129
+ out.push(top);
1130
+ walk(top);
1131
+ }
1132
+ return out;
1133
+ };
1134
+ // Preferences live in cfprefsd: flush its copy into the file before the tar, and hand the file back to it after a
1135
+ // load (a replaced plist alone can be overwritten by the cached domain).
1136
+ const prefs = path.join(container, 'Library', 'Preferences', `${bundle}.plist`);
1137
+ if (sub === 'save') {
1138
+ // The app's domain is keyed by its file in the container (a bare bundle id is another domain outside it).
1139
+ const flushed = path.join(container, 'tmp', `g9pilot-prefs-${process.pid}.plist`);
1140
+ fs.mkdirSync(path.dirname(flushed), { recursive: true });
1141
+ const ex = spawnSync('xcrun', ['simctl', 'spawn', udid, 'defaults', 'export', prefs.replace(/\.plist$/, ''), flushed], { timeout: 30_000 });
1142
+ if (ex.status === 0 && fs.existsSync(flushed)) fs.copyFileSync(flushed, prefs);
1143
+ fs.rmSync(flushed, { force: true });
1144
+ const want = entries();
1145
+ const r = spawnSync('tar', ['-cf', '-', '-C', container, ...SKIP.flatMap((x) => ['--exclude', x]), ...want.filter((x) => !x.includes('/'))], { maxBuffer: 2 * 1024 ** 3, timeout: 10 * 60_000, env: { ...process.env, COPYFILE_DISABLE: '1' } }); // no AppleDouble ._ files
1146
+ if (r.status !== 0 || !r.stdout?.length) fail(`savepoint save: tar failed (${String(r.stderr || '').trim().slice(0, 200)})`);
1147
+ const inTar = tarEntries(r.stdout);
1148
+ if (inTar.length < want.length) fail(`savepoint save: the tar holds ${inTar.length} of ${want.length} entries. Nothing was saved.`);
1149
+ fs.writeFileSync(file, r.stdout);
1150
+ const meta = writeSavepointMeta(file, { level: 'app', name, package: bundle, simulator: udid, entries: inTar.length, bytes: r.stdout.length });
1151
+ print({ ok: true, what: 'saved', level: 'app', name, package: bundle, simulator: udid, file, bytes: r.stdout.length, entries: inTar.length, server: meta.server, ms: Date.now() - started });
1152
+ return;
1153
+ }
1154
+ if (sub === 'load') {
1155
+ if (!fs.existsSync(file)) fail(`No savepoint ${name} for ${bundle} in ${dir}.`);
1156
+ // Out with what the app has now (but the skipped caches), in with the savepoint.
1157
+ for (const top of ['Documents', 'Library']) {
1158
+ const at = path.join(container, top);
1159
+ if (!fs.existsSync(at)) continue;
1160
+ for (const e of fs.readdirSync(at)) if (!skipped(`${top}/${e}`)) fs.rmSync(path.join(at, e), { recursive: true, force: true });
1161
+ }
1162
+ const tar = fs.readFileSync(file);
1163
+ const r = spawnSync('tar', ['-xf', '-', '-C', container], { input: tar, timeout: 10 * 60_000 });
1164
+ if (r.status !== 0) fail(`savepoint load: ${String(r.stderr || '').trim().slice(0, 200)}`);
1165
+ const expected = tarEntries(tar).length;
1166
+ const now = entries().length;
1167
+ if (now < expected) fail(`savepoint load: ${now} of ${expected} entries arrived — load it again.`);
1168
+ if (fs.existsSync(prefs)) spawnSync('xcrun', ['simctl', 'spawn', udid, 'defaults', 'import', prefs.replace(/\.plist$/, ''), prefs], { timeout: 30_000 });
1169
+ else spawnSync('xcrun', ['simctl', 'spawn', udid, 'defaults', 'delete', prefs.replace(/\.plist$/, '')], { timeout: 30_000 });
1170
+ print({ ok: true, what: 'loaded', level: 'app', name, package: bundle, simulator: udid, bytes: tar.length, entries: expected, ms: Date.now() - started, next: 'start the app' });
1171
+ return;
1172
+ }
1173
+ fail('savepoint save|load|list <name> --level app --simulator <udid> --package <bundle id>');
1174
+ }
1175
+
1176
+ /**
1177
+ * Level 1 packed BY THE APP (`--via bridge`, and always for an iPhone: `--device <udid>`): the bridge does the tar at
1178
+ * the app's next launch, before the app opens its data (PilotState.kt / G9PilotState.m), and this side moves ONE file
1179
+ * by the device's own channel — devicectl on an iPhone (it copies files, not folders), the container folder on the
1180
+ * simulator, run-as on Android. `state.cmd` carries a nonce that comes back in `state.done`.
1181
+ */
1182
+ async function bridgeAppSavepoint(sub, name) {
1183
+ const id = String(flags.package ?? flags.bundle ?? '');
1184
+ if (!/^[A-Za-z][\w-]*(\.[A-Za-z][\w-]*)+$/.test(id)) fail('--level app --via bridge needs --package <application / bundle id>.');
1185
+ const dir = path.resolve(String(flags.dir ?? path.join(process.env.HOME ?? process.env.USERPROFILE ?? '.', '.g9pilot', 'savepoints')), id);
1186
+ fs.mkdirSync(dir, { recursive: true });
1187
+ if (sub === 'list') {
1188
+ print({ ok: true, dir, savepoints: fs.readdirSync(dir).filter((f) => f.endsWith('.tar')).map((f) => ({ name: f.slice(0, -4), bytes: fs.statSync(path.join(dir, f)).size })) });
1189
+ return;
1190
+ }
1191
+ if (!name || !/^[A-Za-z0-9._-]{1,64}$/.test(name)) fail('savepoint needs a name: letters, digits, . _ - (at most 64).');
1192
+ const file = path.join(dir, `${name}.tar`);
1193
+ const { spawnSync } = await import('node:child_process');
1194
+ const os = await import('node:os');
1195
+ const ch = bridgeChannel(id, spawnSync, os);
1196
+ const started = Date.now();
1197
+ const nonce = Math.random().toString(36).slice(2, 12);
1198
+ const waitDone = (what) => {
1199
+ for (const until = Date.now() + Number(flags.timeout ?? 120_000); Date.now() < until; ) {
1200
+ const raw = ch.get('state.done');
1201
+ if (raw) {
1202
+ try {
1203
+ const d = JSON.parse(raw.toString('utf8'));
1204
+ if (d.nonce === nonce && d.what === what) return d;
1205
+ } catch {
1206
+ // being written
1207
+ }
1208
+ }
1209
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1_000);
1210
+ }
1211
+ fail(`savepoint ${what}: the app did not answer in time (a debug build whose G9AppPilot packs savepoints at launch — PilotState / G9PilotState — and calls applyPendingState or install at launch?)`);
1212
+ };
1213
+ if (sub === 'save') {
1214
+ ch.put('state.cmd', Buffer.from(`export ${nonce}\n`));
1215
+ ch.relaunch();
1216
+ const done = waitDone('export');
1217
+ if (!done.ok) fail(`savepoint save: ${done.error}`);
1218
+ const tar = ch.get('state.tar');
1219
+ if (!tar?.length) fail('savepoint save: state.tar could not be copied off the device.');
1220
+ const entries = tarEntries(tar).length;
1221
+ if (entries !== done.entries) fail(`savepoint save: the copy holds ${entries} of the ${done.entries} entries the app packed. Nothing was saved.`);
1222
+ fs.writeFileSync(file, tar);
1223
+ const meta = writeSavepointMeta(file, { level: 'app', via: 'bridge', channel: ch.kind, name, package: id, entries, bytes: tar.length });
1224
+ print({ ok: true, what: 'saved', level: 'app', via: 'bridge', channel: ch.kind, name, package: id, file, bytes: tar.length, entries, server: meta.server, packMs: done.ms, ms: Date.now() - started });
1225
+ return;
1226
+ }
1227
+ if (sub === 'load') {
1228
+ if (!fs.existsSync(file)) fail(`No savepoint ${name} for ${id} in ${dir}.`);
1229
+ const tar = fs.readFileSync(file);
1230
+ ch.put('state-in.tar', tar);
1231
+ ch.put('state.cmd', Buffer.from(`import ${nonce}\n`));
1232
+ ch.relaunch();
1233
+ const done = waitDone('import');
1234
+ if (!done.ok) fail(`savepoint load: ${done.error}`);
1235
+ const expected = tarEntries(tar).length;
1236
+ if (done.entries !== expected) fail(`savepoint load: the app applied ${done.entries} of ${expected} entries — load it again.`);
1237
+ print({ ok: true, what: 'loaded', level: 'app', via: 'bridge', channel: ch.kind, name, package: id, bytes: tar.length, entries: expected, applyMs: done.ms, ms: Date.now() - started, note: 'the app is running from that data now' });
1238
+ return;
1239
+ }
1240
+ fail('savepoint save|load|list <name> --level app --via bridge --package <id> [--device <udid> | --simulator <udid> | --serial <s>]');
1241
+ }
1242
+
1243
+ /** One file in and out of the bridge's work folder, and a relaunch, by the device's own channel. */
1244
+ function bridgeChannel(id, spawnSync, os) {
1245
+ if (flags.device) {
1246
+ const udid = String(flags.device);
1247
+ const remote = (f) => `tmp/g9pilot/${f}`;
1248
+ const ctl = (args, timeout = 120_000) => spawnSync('xcrun', ['devicectl', ...args], { timeout, encoding: 'utf8' });
1249
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'g9pilot-'));
1250
+ const where = ['--device', udid, '--domain-type', 'appDataContainer', '--domain-identifier', id, '--user', 'mobile'];
1251
+ return {
1252
+ kind: 'devicectl',
1253
+ put(f, bytes) {
1254
+ const local = path.join(scratch, f);
1255
+ fs.writeFileSync(local, bytes);
1256
+ const r = ctl(['device', 'copy', 'to', ...where, '--source', local, '--destination', remote(f)], 15 * 60_000);
1257
+ if (r.status !== 0) fail(`devicectl copy to: ${String(r.stderr || r.stdout).trim().slice(-300)}`);
1258
+ },
1259
+ get(f) {
1260
+ const local = path.join(scratch, `in-${f}`);
1261
+ fs.rmSync(local, { force: true });
1262
+ const r = ctl(['device', 'copy', 'from', ...where, '--source', remote(f), '--destination', local], 15 * 60_000);
1263
+ return r.status === 0 && fs.existsSync(local) ? fs.readFileSync(local) : null;
1264
+ },
1265
+ relaunch() {
1266
+ const r = ctl(['device', 'process', 'launch', '--device', udid, '--terminate-existing', id]);
1267
+ if (r.status !== 0) fail(`devicectl launch: ${String(r.stderr || r.stdout).trim().slice(-300)}`);
1268
+ },
1269
+ };
1270
+ }
1271
+ if (flags.simulator) {
1272
+ const udid = String(flags.simulator);
1273
+ const container = run('xcrun', ['simctl', 'get_app_container', udid, id, 'data']).trim();
1274
+ const work = path.join(container, 'tmp', 'g9pilot');
1275
+ fs.mkdirSync(work, { recursive: true });
1276
+ return {
1277
+ kind: 'simulator',
1278
+ put: (f, bytes) => fs.writeFileSync(path.join(work, f), bytes),
1279
+ get: (f) => (fs.existsSync(path.join(work, f)) ? fs.readFileSync(path.join(work, f)) : null),
1280
+ relaunch() {
1281
+ spawnSync('xcrun', ['simctl', 'terminate', udid, id], { timeout: 20_000 });
1282
+ const r = spawnSync('xcrun', ['simctl', 'launch', udid, id], { timeout: 60_000, encoding: 'utf8' });
1283
+ if (r.status !== 0) fail(`simctl launch: ${String(r.stderr).trim().slice(-300)}`);
1284
+ },
1285
+ };
1286
+ }
1287
+ const adb = String(flags.adb ?? process.env.ADB ?? 'adb');
1288
+ const serial = String(flags.serial ?? 'emulator-5554');
1289
+ const work = `/data/data/${id}/cache/g9pilot`;
1290
+ return {
1291
+ kind: 'run-as',
1292
+ put(f, bytes) {
1293
+ const r = spawnSync(adb, ['-s', serial, 'exec-in', `run-as ${id} sh -c 'mkdir -p ${work} && cat > ${work}/${f}'`], { input: bytes, timeout: 10 * 60_000 });
1294
+ if (r.status !== 0) fail(`run-as write: ${String(r.error?.message ?? r.stderr).trim().slice(0, 200)} — a debuggable build is needed.`);
1295
+ },
1296
+ get(f) {
1297
+ const r = spawnSync(adb, ['-s', serial, 'exec-out', `run-as ${id} sh -c 'cat ${work}/${f} 2>/dev/null'`], { maxBuffer: 2 * 1024 ** 3, timeout: 10 * 60_000 });
1298
+ return r.status === 0 && r.stdout?.length ? r.stdout : null;
1299
+ },
1300
+ relaunch() {
1301
+ spawnSync(adb, ['-s', serial, 'shell', 'am', 'force-stop', id], { timeout: 20_000 });
1302
+ spawnSync(adb, ['-s', serial, 'shell', 'monkey', '-p', id, '-c', 'android.intent.category.LAUNCHER', '1'], { timeout: 30_000 });
1303
+ },
1304
+ };
1305
+ }
1306
+
1307
+ // ---- the OS around the app (g9pilot os …) ----------------------------------------------------------------------------
1308
+
1309
+ function deviceTarget() {
1310
+ if (flags.simulator) return { kind: 'simulator', udid: String(flags.simulator) };
1311
+ return { kind: 'android', adb: String(flags.adb ?? process.env.ADB ?? 'adb'), serial: String(flags.serial ?? 'emulator-5554') };
1312
+ }
1313
+
1314
+ const run = (file, args, opts = {}) => execFileSync(file, args, { encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024, ...opts });
1315
+ const adbOf = (t) => (args, opts) => run(t.adb, ['-s', t.serial, ...args], opts);
1316
+
1317
+ /** The OS's crash records for the app (or all), newest last. */
1318
+ function crashLog({ lines = Number(flags.lines ?? 400), pkg = flags.package } = {}) {
1319
+ const t = deviceTarget();
1320
+ if (t.kind === 'android') {
1321
+ const out = adbOf(t)(['logcat', '-b', 'crash', '-d', '-v', 'time']);
1322
+ const all = out.split(/\r?\n/).filter(Boolean);
1323
+ const mine = pkg ? all.filter((l, i) => l.includes(String(pkg)) || (i > 0 && /^\S+ \S+ [EF]\/AndroidRuntime/.test(l))) : all;
1324
+ return { source: 'logcat -b crash', count: mine.length, lines: mine.slice(-lines) };
1325
+ }
1326
+ if (process.platform !== 'darwin') fail('os crashlogs --simulator runs on the Mac that hosts the simulator (or through `--host`).');
1327
+ const dir = path.join(process.env.HOME ?? '', 'Library', 'Logs', 'DiagnosticReports');
1328
+ const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /\.(ips|crash)$/.test(f) && (!pkg || f.toLowerCase().includes(String(pkg).split('.').pop().toLowerCase()))) : [];
1329
+ files.sort((a, b) => fs.statSync(path.join(dir, a)).mtimeMs - fs.statSync(path.join(dir, b)).mtimeMs);
1330
+ const newest = files.slice(-3).map((f) => ({ file: f, head: fs.readFileSync(path.join(dir, f), 'utf8').split('\n').slice(0, lines).join('\n') }));
1331
+ return { source: dir, count: files.length, reports: newest };
1332
+ }
1333
+
1334
+ /** Answers the system permission dialog by its buttons' ids (the permission controller's own), with a real tap. */
1335
+ function answerDialog(t, allow) {
1336
+ const adb = adbOf(t);
1337
+ adb(['shell', 'uiautomator', 'dump', '/sdcard/g9pilot-ui.xml']);
1338
+ const xml = adb(['exec-out', 'cat', '/sdcard/g9pilot-ui.xml']);
1339
+ const ids = allow
1340
+ ? ['permission_allow_foreground_only_button', 'permission_allow_button', 'permission_allow_one_time_button', 'permission_allow_always_button']
1341
+ : ['permission_deny_button', 'permission_deny_and_dont_ask_again_button'];
1342
+ for (const id of ids) {
1343
+ const m = new RegExp(`resource-id="[^"]*:id/${id}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`).exec(xml);
1344
+ if (!m) continue;
1345
+ const [x, y] = [Math.round((+m[1] + +m[3]) / 2), Math.round((+m[2] + +m[4]) / 2)];
1346
+ adb(['shell', 'input', 'tap', String(x), String(y)]);
1347
+ return { answered: allow ? 'allow' : 'deny', button: id, at: { x, y } };
1348
+ }
1349
+ return { answered: null, note: 'No system permission dialog is on screen.' };
1350
+ }
1351
+
1352
+ /** One OS action; returns its result or throws (the CLI prints it, MCP's pilot_stage `os.*` returns it). */
1353
+ export function osAction(what, a = {}) {
1354
+ const t = deviceTarget();
1355
+ const sim = t.kind === 'simulator';
1356
+ const simctl = (x) => run('xcrun', ['simctl', ...x]);
1357
+ const bad = (m) => {
1358
+ throw new Error(m);
1359
+ };
1360
+ switch (what) {
1361
+ case 'crashlogs':
1362
+ return crashLog({ lines: Number(a.lines ?? flags.lines ?? 400), pkg: a.package ?? flags.package });
1363
+ case 'permission': {
1364
+ const { verb, permission } = a;
1365
+ const pkg = a.package ?? flags.package;
1366
+ if (!['grant', 'revoke'].includes(verb) || !permission || !pkg) bad('os permission grant|revoke <permission> --package <id>');
1367
+ if (sim) simctl(['privacy', t.udid, verb, permission, String(pkg)]);
1368
+ else adbOf(t)(['shell', 'pm', verb, String(pkg), permission]);
1369
+ return { [verb === 'grant' ? 'granted' : 'revoked']: permission, package: pkg, ...(verb === 'revoke' && !sim ? { note: 'Android stops an app whose runtime permission is revoked' } : {}) };
1370
+ }
1371
+ case 'location': {
1372
+ const lat = Number(a.lat);
1373
+ const lon = Number(a.lon);
1374
+ if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) bad('os location <lat> <lon> (degrees)');
1375
+ if (sim) simctl(['location', t.udid, 'set', `${lat},${lon}`]);
1376
+ else if (t.serial.startsWith('emulator-')) adbOf(t)(['emu', 'geo', 'fix', String(lon), String(lat)]);
1377
+ else bad('A real Android phone takes a fake position only from a mock-location app; use an emulator.');
1378
+ return { location: { lat, lon } };
1379
+ }
1380
+ case 'net': {
1381
+ const { state } = a;
1382
+ if (!['offline', 'online'].includes(state)) bad('os net offline|online');
1383
+ if (sim) bad('The simulator shares the Mac’s network; shape the page’s requests with net.shape instead.');
1384
+ adbOf(t)(['shell', 'cmd', 'connectivity', 'airplane-mode', state === 'offline' ? 'enable' : 'disable']);
1385
+ return { network: state, note: 'the whole device: the sync hub and map tiles too (net.shape shapes only the page’s fetch)' };
1386
+ }
1387
+ case 'dialog': {
1388
+ const { answer } = a;
1389
+ if (!['allow', 'deny'].includes(answer)) bad('os dialog allow|deny');
1390
+ if (sim) bad('The simulator’s dialogs: grant first with `os permission grant` (simctl privacy).');
1391
+ return answerDialog(t, answer === 'allow');
1392
+ }
1393
+ case 'video': {
1394
+ const seconds = Math.max(1, Math.min(3600, Math.round(Number(a.seconds ?? 30))));
1395
+ const out = String(a.out ?? flags.out ?? '.');
1396
+ fs.mkdirSync(out, { recursive: true });
1397
+ if (sim) return recordSimulatorVideo(t.udid, seconds, out);
1398
+ return recordAndroidVideo(t, seconds, out);
1399
+ }
1400
+ case 'doze': {
1401
+ const { state } = a;
1402
+ if (sim) bad('Doze is Android only; iOS has no equivalent a test can switch.');
1403
+ const adb = adbOf(t);
1404
+ if (state === 'on') {
1405
+ adb(['shell', 'dumpsys', 'battery', 'unplug']);
1406
+ adb(['shell', 'dumpsys', 'deviceidle', 'enable', 'all']);
1407
+ adb(['shell', 'dumpsys', 'deviceidle', 'force-idle']);
1408
+ } else if (state === 'off') {
1409
+ adb(['shell', 'dumpsys', 'deviceidle', 'unforce']);
1410
+ adb(['shell', 'dumpsys', 'battery', 'reset']);
1411
+ } else if (state === 'step') {
1412
+ adb(['shell', 'dumpsys', 'deviceidle', 'step']);
1413
+ } else bad('os doze on|off|step');
1414
+ const mState = /mState=(\w+)/.exec(adb(['shell', 'dumpsys', 'deviceidle']))?.[1] ?? null;
1415
+ return { doze: state, mState, ...(state === 'on' ? { undo: 'os doze off (it also resets the battery state)' } : {}) };
1416
+ }
1417
+ case 'standby': {
1418
+ const { bucket } = a;
1419
+ const pkg = a.package ?? flags.package;
1420
+ if (sim) bad('App Standby buckets are Android only.');
1421
+ if (!['active', 'working_set', 'frequent', 'rare', 'restricted'].includes(bucket) || !pkg) bad('os standby active|working_set|frequent|rare|restricted --package <id>');
1422
+ adbOf(t)(['shell', 'am', 'set-standby-bucket', String(pkg), bucket]);
1423
+ return { package: pkg, bucket: String(adbOf(t)(['shell', 'am', 'get-standby-bucket', String(pkg)])).trim() };
1424
+ }
1425
+ case 'appearance': {
1426
+ const { mode } = a;
1427
+ if (!['dark', 'light'].includes(mode)) bad('os appearance dark|light');
1428
+ if (sim) simctl(['ui', t.udid, 'appearance', mode]);
1429
+ else adbOf(t)(['shell', 'cmd', 'uimode', 'night', mode === 'dark' ? 'yes' : 'no']);
1430
+ return { appearance: mode };
1431
+ }
1432
+ case 'fontscale': {
1433
+ const { value } = a;
1434
+ const SIZES = ['extra-small', 'small', 'medium', 'large', 'extra-large', 'extra-extra-large', 'extra-extra-extra-large', 'accessibility-medium', 'accessibility-large', 'accessibility-extra-large', 'accessibility-extra-extra-large', 'accessibility-extra-extra-extra-large'];
1435
+ const n = Number(value);
1436
+ if (sim) {
1437
+ // iOS takes a category; a number is mapped (1 = large, the default).
1438
+ const category = SIZES.includes(value) ? value : Number.isFinite(n) ? (n <= 0.85 ? 'small' : n <= 1 ? 'large' : n <= 1.15 ? 'extra-large' : n <= 1.3 ? 'extra-extra-large' : 'extra-extra-extra-large') : null;
1439
+ if (!category) bad(`os fontscale <number | ${SIZES.join(' | ')}>`);
1440
+ simctl(['ui', t.udid, 'content_size', category]);
1441
+ return { contentSize: category };
1442
+ }
1443
+ if (!Number.isFinite(n) || n < 0.5 || n > 2) bad('os fontscale <0.5 … 2> on Android (1 = normal)');
1444
+ adbOf(t)(['shell', 'settings', 'put', 'system', 'font_scale', String(n)]);
1445
+ return { fontScale: n, undo: 'os fontscale 1' };
1446
+ }
1447
+ case 'notify': {
1448
+ const file = a.payload;
1449
+ const pkg = a.package ?? flags.package;
1450
+ if (!sim) bad('Android: `cmd notification post` posts as the shell, not as the app — a test of the app\'s own notification needs an app command that posts one locally.');
1451
+ if (!file || !fs.existsSync(String(file)) || !pkg) bad('os notify <payload.json> --package <bundle id> (an APNs payload: {"aps": {"alert": …}})');
1452
+ simctl(['push', t.udid, String(pkg), String(file)]);
1453
+ return { pushed: String(file), package: pkg };
1454
+ }
1455
+ default:
1456
+ return bad('os crashlogs | permission | location | net | dialog | video | doze | standby | appearance | fontscale | notify — see the header of g9pilot.mjs');
1457
+ }
1458
+ }
1459
+
1460
+ /**
1461
+ * Android screen video in pieces: screenrecord stops at 180 s (its own limit), so a longer recording is consecutive
1462
+ * pieces of at most 175 s, each pulled to `out` (not joined — the list says the order). Research R4.
1463
+ */
1464
+ function recordAndroidVideo(t, seconds, out) {
1465
+ const adb = adbOf(t);
1466
+ const pieces = [];
1467
+ let left = seconds;
1468
+ for (let i = 0; left > 0; i++) {
1469
+ const length = Math.min(175, left);
1470
+ const remote = `/sdcard/g9pilot-video-${i}.mp4`;
1471
+ adb(['shell', 'screenrecord', '--time-limit', String(length), remote], { timeout: (length + 30) * 1000 });
1472
+ const local = path.join(out, `video-${String(i).padStart(2, '0')}.mp4`);
1473
+ run(t.adb, ['-s', t.serial, 'pull', remote, local], { timeout: 120_000 });
1474
+ adb(['shell', 'rm', '-f', remote]);
1475
+ pieces.push(local);
1476
+ left -= length;
1477
+ }
1478
+ return { video: pieces, seconds, note: pieces.length > 1 ? 'consecutive pieces (screenrecord stops at 180 s), in order' : undefined };
1479
+ }
1480
+
1481
+ /** The simulator's screen: simctl io recordVideo until SIGINT. */
1482
+ function recordSimulatorVideo(udid, seconds, out) {
1483
+ const file = path.join(out, 'video.mp4');
1484
+ const child = spawn('xcrun', ['simctl', 'io', udid, 'recordVideo', '--codec=h264', '--force', file], { stdio: 'ignore' });
1485
+ const until = Date.now() + seconds * 1000;
1486
+ while (Date.now() < until) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
1487
+ child.kill('SIGINT');
1488
+ return { video: [file], seconds, note: 'written when simctl finishes (a second or two after SIGINT)' };
1489
+ }
1490
+
1491
+ function osCommand([what, ...args]) {
1492
+ const named =
1493
+ {
1494
+ permission: { verb: args[0], permission: args[1] },
1495
+ location: { lat: args[0], lon: args[1] },
1496
+ net: { state: args[0] },
1497
+ dialog: { answer: args[0] },
1498
+ video: { seconds: args[0] },
1499
+ doze: { state: args[0] },
1500
+ standby: { bucket: args[0] },
1501
+ appearance: { mode: args[0] },
1502
+ fontscale: { value: args[0] },
1503
+ notify: { payload: args[0] },
1504
+ }[what] ?? {};
1505
+ try {
1506
+ print({ ok: true, ...osAction(what, named) });
1507
+ } catch (error) {
1508
+ fail(error instanceof Error ? error.message : String(error));
1509
+ }
1510
+ }
1511
+
1512
+ /**
1513
+ * What an approval does after writing (plan v2 P1-18): g9pilot.json `tokens.afterApprove` (each [cmd, …args], from the
1514
+ * token root) and `tokens.references` — reference names re-checked on this device (`g9pilot visual`, the OS picture of
1515
+ * --serial / --simulator); a changed one is listed for a person, never approved.
1516
+ */
1517
+ function approveFollowUps() {
1518
+ const after = Array.isArray(config.tokens?.afterApprove) ? config.tokens.afterApprove : [];
1519
+ const names = Array.isArray(config.tokens?.references) ? config.tokens.references : [];
1520
+ const check = names.length && config.visual
1521
+ ? () => {
1522
+ const settings = visualSettings(config, path.dirname(path.resolve(configPath)), flags);
1523
+ let png;
1524
+ try {
1525
+ png = captureOsPng({ serial: flags.serial, simulator: flags.simulator, adb: String(flags.adb ?? process.env.ADB ?? 'adb') });
1526
+ } catch (e) {
1527
+ return [{ name: '*', verdict: 'no picture', note: e.message }];
1528
+ }
1529
+ return names.map((name) => {
1530
+ const r = checkVisual(settings, String(name), png);
1531
+ return { name, verdict: r.verdict, ...(r.verdict === 'differs' ? { next: `look at ${r.diffImage}, then g9pilot visual approve ${name}` } : {}) };
1532
+ });
1533
+ }
1534
+ : null;
1535
+ return { after, check };
1536
+ }
1537
+
1538
+ /** The MCP server (packages/mcp) over this bridge, on stdio. Logs go to stderr: stdout is the protocol. */
1539
+ async function mcp() {
1540
+ const { createTools, serve } = await import('@g9tm/app-pilot-mcp');
1541
+ const k = key({ required: false });
1542
+ const client = clientFor({ privateKey: k?.key });
1543
+ tallyCalls(client, recorder('mcp', url));
1544
+ const runner = config.runner?.path ? config.runner : null;
1545
+ const runFlow = runner
1546
+ ? async (spec, file, { onStep = null, signal = null } = {}) => {
1547
+ // Not spawnSync: the MCP server keeps answering (tasks/get, other tools) while a flow runs; each «· step n/m»
1548
+ // line of the runner's --progress is one progress notification; a cancelled task kills the run.
1549
+ const os = await import('node:os');
1550
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'g9pilot-flow-'));
1551
+ const flowFile = file ?? path.join(dir, `${spec.id ?? 'inline'}.flow.json`);
1552
+ if (!file) fs.writeFileSync(flowFile, JSON.stringify(spec, null, 2));
1553
+ const report = path.join(dir, 'report');
1554
+ const r = await new Promise((resolve) => {
1555
+ const child = spawn(process.execPath, [runner.path, 'run', flowFile, ...(runner.args ?? []), '--progress', '--report', report], { stdio: ['ignore', 'pipe', 'pipe'] });
1556
+ let stdout = '';
1557
+ let stderr = '';
1558
+ let pending = '';
1559
+ const timer = setTimeout(() => child.kill(), 30 * 60_000);
1560
+ const onAbort = () => child.kill();
1561
+ signal?.addEventListener('abort', onAbort, { once: true });
1562
+ child.stdout.on('data', (d) => {
1563
+ stdout += d;
1564
+ pending += d;
1565
+ const lines = pending.split(/\r?\n/);
1566
+ pending = lines.pop() ?? '';
1567
+ for (const line of lines) {
1568
+ const m = /· step (\d+)\/(\d+) \(([^)]*)\)/.exec(line);
1569
+ if (m) onStep?.(Number(m[1]), `step ${m[1]}/${m[2]} (${m[3]})`);
1570
+ }
1571
+ });
1572
+ child.stderr.on('data', (d) => (stderr += d));
1573
+ child.on('close', (status) => {
1574
+ clearTimeout(timer);
1575
+ signal?.removeEventListener('abort', onAbort);
1576
+ resolve({ status: signal?.aborted ? 130 : status, stdout, stderr });
1577
+ });
1578
+ });
1579
+ let verdict = null;
1580
+ try {
1581
+ verdict = JSON.parse(fs.readFileSync(path.join(report, 'run.json'), 'utf8')).flows?.[0]?.verdict ?? null;
1582
+ } catch {
1583
+ verdict = null;
1584
+ }
1585
+ return { ok: r.status === 0, verdict, report, error: r.status === 0 ? null : String(r.stderr || r.stdout).slice(-600) };
1586
+ }
1587
+ : null;
1588
+ const tools = createTools({ client, devices: config.devices ?? [], confirmWrites: config.mcp?.confirmWrites !== false, runFlow, osAction });
1589
+ serve({ tools });
1590
+ process.stderr.write(`g9pilot mcp: ${tools.length} tools over ${url}${k ? ` (key ${k.source})` : ' (no key: an unsigned bridge only)'}
1591
+ `);
1592
+ }
1593
+
1594
+ main().catch((e) => fail(e.stack ?? String(e), 1));