@sprid/cli 0.1.2

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +14 -0
  3. package/README-post.md +615 -0
  4. package/README.md +249 -0
  5. package/SECURITY.md +18 -0
  6. package/bin.mjs +12 -0
  7. package/package.json +47 -0
  8. package/src/args.mjs +134 -0
  9. package/src/browser.mjs +28 -0
  10. package/src/cli.mjs +257 -0
  11. package/src/commands/auth.mjs +192 -0
  12. package/src/commands/completion.mjs +90 -0
  13. package/src/commands/connect.mjs +586 -0
  14. package/src/commands/docs.mjs +53 -0
  15. package/src/commands/family.mjs +96 -0
  16. package/src/commands/init.mjs +137 -0
  17. package/src/commands/local-tools.mjs +31 -0
  18. package/src/commands/marketing-review.mjs +106 -0
  19. package/src/commands/mcp.mjs +53 -0
  20. package/src/commands/misc.mjs +94 -0
  21. package/src/commands/pinterest.mjs +120 -0
  22. package/src/commands/plan.mjs +230 -0
  23. package/src/commands/post.mjs +41 -0
  24. package/src/commands/reviews.mjs +149 -0
  25. package/src/commands/setup.mjs +220 -0
  26. package/src/commands/status.mjs +217 -0
  27. package/src/commands/studio.mjs +69 -0
  28. package/src/commands/update.mjs +69 -0
  29. package/src/creds.mjs +126 -0
  30. package/src/docs/commands.mjs +152 -0
  31. package/src/docs/guides.generated.mjs +1178 -0
  32. package/src/docs/help.mjs +77 -0
  33. package/src/docs/index.d.mts +18 -0
  34. package/src/docs/index.mjs +45 -0
  35. package/src/docs/queries.d.mts +11 -0
  36. package/src/docs/queries.mjs +77 -0
  37. package/src/endpoint.mjs +17 -0
  38. package/src/evidence.mjs +15 -0
  39. package/src/format.mjs +71 -0
  40. package/src/http.mjs +117 -0
  41. package/src/pending.mjs +27 -0
  42. package/src/post/cli.mjs +2285 -0
  43. package/src/post/json-worker.mjs +12 -0
  44. package/src/post/preview-server.mjs +58 -0
  45. package/src/post/preview.mjs +660 -0
  46. package/src/post/recipes/screen.mjs +290 -0
  47. package/src/post/recipes/stills.mjs +146 -0
  48. package/src/post/rules/platform-rules.d.mts +27 -0
  49. package/src/post/rules/platform-rules.mjs +164 -0
  50. package/src/post/screen/captions.mjs +131 -0
  51. package/src/post/screen/compose.mjs +284 -0
  52. package/src/post/screen/input.mjs +163 -0
  53. package/src/post/screen/sim.mjs +443 -0
  54. package/src/post/screen/simkit.swift +328 -0
  55. package/src/profiles.mjs +61 -0
  56. package/src/release.ts +2 -0
  57. package/src/screenshots.ts +1 -0
  58. package/src/updates.mjs +152 -0
@@ -0,0 +1,443 @@
1
+ // The simulator half of the `screen` recipe: boot it, dress it, drive it,
2
+ // record it, and write down when everything happened.
3
+ //
4
+ // **The timeline is the output that matters**, not the .mov. A caption is
5
+ // written against a MARK in the script ("when the log opens"), never against a
6
+ // second, because the same script run in seven languages produces seven
7
+ // different sets of timings and only the marks survive that. `compose` reads
8
+ // the timeline back and does the arithmetic.
9
+
10
+ import { spawn, spawnSync } from "node:child_process";
11
+ import { existsSync, mkdirSync, rmSync, statSync, writeFileSync, readFileSync } from "node:fs";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { tmpdir } from "node:os";
14
+
15
+ const sh = (cmd, args, opts = {}) => spawnSync(cmd, args, { encoding: "utf8", ...opts });
16
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // simkit, compiled on demand
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * The Swift helper is compiled into a cache keyed by the source's mtime, so a
24
+ * checkout costs one 3-second build and never thinks about it again. Shipping a
25
+ * binary in the package would mean shipping an unsigned Mach-O through npm,
26
+ * which is somebody's security review; shipping 400 lines of Swift does not.
27
+ */
28
+ export function simkit() {
29
+ const src = new URL("./simkit.swift", import.meta.url).pathname;
30
+ const stamp = String(Math.floor(statSync(src).mtimeMs));
31
+ const out = join(tmpdir(), `sprid-simkit-${stamp}`);
32
+ if (!existsSync(out)) {
33
+ const r = sh("swiftc", ["-O", "-o", out, src]);
34
+ if (r.status !== 0) throw new Error(`Could not build simkit:\n${r.stderr}`);
35
+ }
36
+ return out;
37
+ }
38
+
39
+ export function kit(args) {
40
+ const r = sh(simkit(), args);
41
+ if (r.status !== 0) throw new Error(`simkit ${args[0]} failed: ${r.stderr.trim()}`);
42
+ return r.stdout.trim();
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // the device
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * A name resolves to a UDID, and **a booted device wins.**
51
+ *
52
+ * Xcode keeps one simulator per name per runtime, so "iPhone 17" matched three
53
+ * devices here and the first in the list was an iOS 26.3 one with nothing
54
+ * installed on it. Taking the first match is how a run spends four minutes
55
+ * driving an empty springboard. Preferring the booted device makes the
56
+ * behaviour match what the person watching the screen expects; failing that,
57
+ * the newest runtime.
58
+ */
59
+ export function resolveDevice(name) {
60
+ if (/^[0-9A-F-]{36}$/i.test(name)) return name;
61
+ const lines = sh("xcrun", ["simctl", "list", "devices", "available"]).stdout.split("\n");
62
+ const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
63
+ const re = new RegExp(`^\\s+${esc} \\(([0-9A-F-]{36})\\)(.*)$`);
64
+ const hits = lines.map((l) => re.exec(l)).filter(Boolean);
65
+ if (!hits.length) throw new Error(`No simulator called "${name}".`);
66
+ return (hits.find((m) => m[2].includes("Booted")) ?? hits[hits.length - 1])[1];
67
+ }
68
+
69
+ /**
70
+ * A killed run leaves the host recorder registered with CoreSimulator, and the
71
+ * next `recordVideo` gets *"Resource busy … Host recording is already in
72
+ * progress"* and writes nothing. No process is left to kill - the state lives
73
+ * in the service - so the only way out is to cycle the device.
74
+ */
75
+ export async function clearStuckRecorder(udid) {
76
+ sh("xcrun", ["simctl", "shutdown", udid]);
77
+ await sleep(2500);
78
+ sh("xcrun", ["simctl", "boot", udid]);
79
+ sh("xcrun", ["simctl", "bootstatus", udid, "-b"]);
80
+ await sleep(3000);
81
+ }
82
+
83
+ export function boot(udid) {
84
+ const booted = sh("xcrun", ["simctl", "list", "devices"]).stdout
85
+ .split("\n").some((l) => l.includes(udid) && l.includes("Booted"));
86
+ if (!booted) {
87
+ sh("xcrun", ["simctl", "boot", udid]);
88
+ sh("xcrun", ["simctl", "bootstatus", udid, "-b"]);
89
+ }
90
+ sh("open", ["-a", "Simulator"]);
91
+ }
92
+
93
+ /**
94
+ * The status bar, dressed.
95
+ *
96
+ * A real 22:47 with 61% battery and no signal is the single clearest tell that
97
+ * a demo was filmed on a machine rather than a phone, and it costs one command
98
+ * to remove.
99
+ */
100
+ export function dressStatusBar(udid, { time = "now", battery = { level: 76, state: "discharging" } } = {}) {
101
+ // **9:41 is the STORE convention and it is wrong for a reel.** A screenshot
102
+ // is an advertisement and 9:41 reads as one; a reel is meant to look like
103
+ // somebody's phone, and the app's own content gives the real time away
104
+ // anyway - a decision logged during the recording carries the wall clock, so
105
+ // a 9:41 status bar over a row stamped 23:20 is a tell. Default to the host
106
+ // clock; pass a literal "HH:MM" to pin one.
107
+ const clock = time === "now"
108
+ ? new Date().toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })
109
+ : time;
110
+ sh("xcrun", ["simctl", "status_bar", udid, "override",
111
+ "--time", clock,
112
+ "--dataNetwork", "wifi", "--wifiMode", "active", "--wifiBars", "3",
113
+ "--cellularMode", "active", "--cellularBars", "4",
114
+ // A phone at 100% and charging is the store screenshot. Something in the
115
+ // seventies, discharging, is what a phone in a hand looks like.
116
+ "--batteryState", battery.state, "--batteryLevel", String(battery.level)]);
117
+ }
118
+ export const clearStatusBar = (udid) => sh("xcrun", ["simctl", "status_bar", udid, "clear"]);
119
+
120
+ export function screenshot(udid, path, { type = "png" } = {}) {
121
+ mkdirSync(dirname(path), { recursive: true });
122
+ sh("xcrun", ["simctl", "io", udid, "screenshot", `--type=${type}`, path]);
123
+ return path;
124
+ }
125
+
126
+ /**
127
+ * A cheap frame, for comparing against another cheap frame.
128
+ *
129
+ * **Nothing reads these probes but a PSNR**, so they run at a quarter scale
130
+ * through baguette: 302x656 and 49KB against `simctl`'s 1206x2622 and 464KB,
131
+ * which is a twentieth of the pixels for ffmpeg to decode twice a second while
132
+ * the recorder is trying to keep up. The recorder's own clock is the thing that
133
+ * suffers when it cannot - a run that dropped 29% of its elapsed time came back
134
+ * playing 1.4x fast, on top of the speed-up it was asked for.
135
+ */
136
+ export function probe(udid, path) {
137
+ mkdirSync(dirname(path), { recursive: true });
138
+ if (probe.baguette ?? (probe.baguette = spawnSync("baguette", ["--version"]).status === 0)) {
139
+ const r = sh("baguette", ["screenshot", "--udid", udid, "--scale", "4", "--format", "jpeg", "-o", path]);
140
+ if (r.status === 0) return path;
141
+ }
142
+ return screenshot(udid, path, { type: "jpeg" });
143
+ }
144
+
145
+ export function deviceSize(udid, tmp) {
146
+ const p = join(tmp, "probe.png");
147
+ screenshot(udid, p);
148
+ const r = sh("sips", ["-g", "pixelWidth", "-g", "pixelHeight", p]).stdout;
149
+ return { w: Number(/pixelWidth: (\d+)/.exec(r)?.[1]), h: Number(/pixelHeight: (\d+)/.exec(r)?.[1]) };
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // where the screen is on the desktop
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * **Calibrate rather than assume.** The first version of this read the device
158
+ * as 874 points tall and was wrong: a single-distance drag test cannot tell a
159
+ * scale error from UIScrollView's pan slop, because a constant loss fits any
160
+ * scale. Three distances separate them - see the note in simkit.swift - and the
161
+ * numbers came out 910 points of screen and 9.2 points of slop.
162
+ *
163
+ * The default model (aspect-fit below a 21-point title bar) reproduces that on
164
+ * this machine, so calibration is a check rather than a step. It is worth
165
+ * re-running whenever the window is resized or the Simulator is updated.
166
+ */
167
+ export function deviceRect(aspect) {
168
+ const out = kit(["window", "--aspect", String(aspect), "--no-activate"]);
169
+ const line = out.split("\n").find((l) => l.startsWith("device"));
170
+ const [, x, y, w, h] = line.split(/\s+/).map(Number);
171
+ return { x, y, w, h };
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // recording
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /**
179
+ * `simctl io recordVideo` writes a VARIABLE frame rate .mov and stops on
180
+ * SIGINT. Both facts bite later: SIGKILL leaves a file with no moov atom and
181
+ * nothing can read it, and a VFR source dropped into a filtergraph without a
182
+ * `-r` drifts against the caption times by whole seconds over a 20-second reel.
183
+ */
184
+ export function startRecording(udid, path) {
185
+ mkdirSync(dirname(path), { recursive: true });
186
+ rmSync(path, { force: true });
187
+ const p = spawn("xcrun", ["simctl", "io", udid, "recordVideo", "--codec", "h264", "--force", path],
188
+ { stdio: ["ignore", "ignore", "pipe"] });
189
+ let err = "";
190
+ p.stderr.on("data", (d) => { err += d.toString(); });
191
+
192
+ return {
193
+ proc: p,
194
+ path,
195
+ /**
196
+ * **Fail here or fail sixty seconds later.**
197
+ *
198
+ * `simctl io recordVideo` can refuse and keep running, and the first
199
+ * version ignored its stderr: a killed run leaves the host recorder
200
+ * registered with CoreSimulator, the next one gets *"Resource busy … Host
201
+ * recording is already in progress"*, writes no file at all, and the
202
+ * failure surfaces only at the end - after the app has been driven through
203
+ * the whole script for nothing. Waiting for the first bytes costs a second
204
+ * and turns that into an error with the fix in it.
205
+ */
206
+ async ready({ graceMs = 1800, timeoutMs = 6000 } = {}) {
207
+ const t0 = Date.now();
208
+ while (Date.now() - t0 < timeoutMs) {
209
+ if (/error|busy/i.test(err))
210
+ throw new Error(
211
+ `simctl could not start recording:\n ${err.trim().split("\n").filter(Boolean).slice(-2).join("\n ")}\n` +
212
+ (/already in progress/i.test(err)
213
+ ? ` A previous run was killed and left the recorder registered. Clear it with:\n` +
214
+ ` xcrun simctl shutdown ${udid} && xcrun simctl boot ${udid}`
215
+ : ""),
216
+ );
217
+ // **The file stays 0 bytes for the whole recording** - simctl buffers
218
+ // and writes on SIGINT ("Recording completed. Writing to disk"), so
219
+ // waiting for it to GROW never returns. Its existence is the signal
220
+ // that the recorder took the device; a refusal leaves no file at all.
221
+ if (Date.now() - t0 > graceMs && existsSync(path)) return;
222
+ await sleep(120);
223
+ }
224
+ throw new Error(`simctl created no file at ${path} within ${timeoutMs}ms. ${err.trim()}`);
225
+ },
226
+ async stop() {
227
+ p.kill("SIGINT");
228
+ await new Promise((r) => { p.on("exit", r); setTimeout(r, 8000); });
229
+ await sleep(400);
230
+ },
231
+ };
232
+ }
233
+
234
+ /**
235
+ * The clapperboard.
236
+ *
237
+ * Nothing reports when `recordVideo` actually opened the capture stream, and
238
+ * the gap between spawning it and the first frame measured 0.4-1.6s here - more
239
+ * than enough to put a caption on the wrong screen. So the run flashes the
240
+ * screen before it does anything else and `compose` finds the flash in the
241
+ * video.
242
+ *
243
+ * Flash the springboard after terminating the app. Apps with custom themes
244
+ * may ignore system appearance changes, making a full-frame marker unreliable.
245
+ *
246
+ * Terminating costs a relaunch, which is why the launch belongs to the recipe
247
+ * and not to the repo's `prepare`: the app has to come up AFTER the anchor.
248
+ */
249
+ export async function clapper(udid, { bundleId } = {}) {
250
+ if (bundleId) {
251
+ sh("xcrun", ["simctl", "terminate", udid, bundleId]);
252
+ await sleep(1600);
253
+ }
254
+ sh("xcrun", ["simctl", "ui", udid, "appearance", "dark"]);
255
+ const at = Date.now();
256
+ await sleep(900);
257
+ sh("xcrun", ["simctl", "ui", udid, "appearance", "light"]);
258
+ await sleep(700);
259
+ return at;
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // settling
264
+ // ---------------------------------------------------------------------------
265
+
266
+ /**
267
+ * Wait for the screen to become something else, and then to stop changing.
268
+ *
269
+ * A fixed `sleep` is the reason scripted demos look scripted: it is either too
270
+ * short, and the finger arrives during a push transition, or too long, and the
271
+ * reel pays four dead seconds for the worst case.
272
+ *
273
+ * **`since` is the whole thing, and it took three failures to get here.** The
274
+ * first version waited only for quiet, which cannot tell *the transition has
275
+ * finished* from *it has not started*. The second added "wait for any motion
276
+ * first", which is still not enough: a Debug build behind Metro can run SECONDS
277
+ * behind the script, and while it is behind, the screen it is still showing is
278
+ * perfectly quiet. Every settle then returned on a stale frame, every following
279
+ * tap landed on a screen that was about to be replaced, and a reel came back
280
+ * with the app stuck on one screen for its last ten seconds.
281
+ *
282
+ * With `since` - a frame captured before the gesture that caused the change -
283
+ * the question becomes *is this a different screen yet*, which is the one worth
284
+ * asking. `maxMs` can then be generous, because it returns the moment the app
285
+ * catches up rather than sleeping the worst case.
286
+ */
287
+ export async function settle(udid, tmp, { maxMs = 6000, quietMs = 300, threshold = 42, graceMs = 1500, since = null } = {}) {
288
+ // JPEG probes reduce capture overhead while recordVideo is running.
289
+ // Full-resolution PNG probes can starve the recorder and distort timing.
290
+ const a = join(tmp, "settle-a.jpg"), b = join(tmp, "settle-b.jpg");
291
+ const t0 = Date.now();
292
+ if (since && existsSync(since)) sh("cp", [since, a]);
293
+ else probe(udid, a);
294
+
295
+ let quiet = 0;
296
+ let moved = false;
297
+ while (Date.now() - t0 < maxMs) {
298
+ const before = Date.now();
299
+ probe(udid, b);
300
+ const psnr = compare(a, b);
301
+ // With a `since` reference the first phase asks *has the screen become
302
+ // something else yet*, so the reference must NOT advance until it has.
303
+ if (!(since && !moved)) sh("cp", [b, a]);
304
+ const spent = Date.now() - before;
305
+ // Persistent background animation can be quiet without becoming identical.
306
+ const busy = psnr <= threshold;
307
+ if (busy) {
308
+ if (since && !moved) sh("cp", [b, a]);
309
+ moved = true;
310
+ quiet = 0;
311
+ continue;
312
+ }
313
+ if (!moved && Date.now() - t0 < graceMs) continue;
314
+ quiet += spent;
315
+ if (quiet >= quietMs) return Date.now() - t0;
316
+ }
317
+ return Date.now() - t0;
318
+ }
319
+
320
+ /**
321
+ * PSNR between two frames, in dB, and **`Infinity` when they are identical**.
322
+ *
323
+ * ffmpeg prints `average:inf` for identical input, which `/[\d.]+/` does not
324
+ * match. Defaulting that to 0 - which the first version did - inverts the
325
+ * meaning of the number it feeds: two identical frames come back as maximally
326
+ * different, every settle reads as busy, and every settle in a run then sits
327
+ * until its ceiling. The reel came out 39.7s instead of 19s.
328
+ */
329
+ export function compare(a, b, { region = null } = {}) {
330
+ const graph = region
331
+ ? `[0:v]crop=${region}[a];[1:v]crop=${region}[b];[a][b]psnr`
332
+ : "psnr";
333
+ const r = sh("ffmpeg", ["-hide_banner", "-nostats", "-i", a, "-i", b,
334
+ "-filter_complex", graph, "-f", "null", "-"]);
335
+ const m = /average:([\d.]+|inf)/.exec(r.stderr ?? "");
336
+ if (!m || m[1] === "inf") return Infinity;
337
+ return Number(m[1]);
338
+ }
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // running a script
342
+ // ---------------------------------------------------------------------------
343
+
344
+ const nowRel = (t0) => Date.now() - t0;
345
+
346
+ /**
347
+ * Run the gesture script and write down what happened when.
348
+ *
349
+ * **The script carries no words.** It is the finger's path through the app and
350
+ * nothing else, which is exactly what makes the seven-language version cheap:
351
+ * one script, seven caption files, seven runs of the same steps.
352
+ */
353
+ export async function runScript(script, ctx) {
354
+ const { udid, tmp, input, t0, log = () => {} } = ctx;
355
+ const timeline = { marks: {}, gestures: [], steps: [] };
356
+ // The frame as it was before the last gesture. A `settle` uses it to ask
357
+ // *has the screen become something else yet*, which is a different and much
358
+ // more useful question than *has it stopped moving*.
359
+ const pre = join(tmp, "pre-gesture.jpg");
360
+ let sinceGesture = null;
361
+ const note = (kind, extra = {}) => {
362
+ const t = nowRel(t0);
363
+ timeline.steps.push({ t, kind, ...extra });
364
+ return t;
365
+ };
366
+
367
+ for (const step of script.steps) {
368
+ if (step.mark) {
369
+ timeline.marks[step.mark] = nowRel(t0);
370
+ log(` ${String(nowRel(t0)).padStart(6)}ms mark ${step.mark}`);
371
+ continue;
372
+ }
373
+ if (step.wait != null) { note("wait", { ms: step.wait }); await sleep(step.wait); continue; }
374
+ if (step.settle != null) {
375
+ const took = await settle(udid, tmp, {
376
+ maxMs: step.settle === true ? 8000 : step.settle,
377
+ since: sinceGesture,
378
+ });
379
+ sinceGesture = null;
380
+ note("settle", { ms: took });
381
+ log(` ${String(nowRel(t0)).padStart(6)}ms settled after ${took}ms`);
382
+ continue;
383
+ }
384
+ if (step.tap) {
385
+ const [x, y] = step.tap;
386
+ const t = note("tap", { x, y, note: step.note });
387
+ timeline.gestures.push({ kind: "tap", t, points: [[x, y]], durationMs: 240 });
388
+ probe(udid, pre);
389
+ sinceGesture = pre;
390
+ await input.tap(x, y, { holdMs: step.hold });
391
+ log(` ${String(t).padStart(6)}ms tap ${x},${y}${step.note ? ` (${step.note})` : ""}`);
392
+ // Retry can handle a tap swallowed by an unfinished transition.
393
+ // Stop once the target region changes to avoid repeated navigation.
394
+ if (step.retry) {
395
+ // Observe a stable region so continuous background motion does not
396
+ // masquerade as navigation. The default covers the bottom tab-bar area.
397
+ const region = step.retryRegion ?? "iw:ih/10:0:ih*9/10";
398
+ for (let i = 0; i < (step.retry === true ? 4 : step.retry); i++) {
399
+ await sleep(step.retryEveryMs ?? 900);
400
+ probe(udid, post);
401
+ if (compare(pre, post, { region }) <= 42) break;
402
+ log(` tap ignored, trying again (${i + 1})`);
403
+ await input.tap(x, y, { holdMs: step.hold });
404
+ }
405
+ }
406
+ continue;
407
+ }
408
+ if (step.swipe) {
409
+ const [x1, y1, x2, y2] = step.swipe;
410
+ const dur = step.duration ?? 280;
411
+ const t = note("swipe", { x1, y1, x2, y2, durationMs: dur, note: step.note });
412
+ timeline.gestures.push({ kind: "swipe", t, points: [[x1, y1], [x2, y2]], durationMs: dur + 260 });
413
+ probe(udid, pre);
414
+ sinceGesture = pre;
415
+ await input.swipe(x1, y1, x2, y2, { durationMs: dur, flick: !step.precise, ...(step.bow != null ? { bow: step.bow } : {}) });
416
+ log(` ${String(t).padStart(6)}ms swipe ${x1},${y1} -> ${x2},${y2}${step.note ? ` (${step.note})` : ""}`);
417
+ continue;
418
+ }
419
+ if (step.launch) {
420
+ note("launch", { bundleId: step.launch });
421
+ sh("xcrun", ["simctl", "launch", udid, step.launch]);
422
+ continue;
423
+ }
424
+ if (step.terminate) { sh("xcrun", ["simctl", "terminate", udid, step.terminate]); continue; }
425
+ if (step.shell) {
426
+ // **`<locale>` is why a shell step does not break the one-script rule.**
427
+ // An app that picks its language through a deep link needs the locale in
428
+ // the command, and putting it there literally would fork the script per
429
+ // language - which is the whole thing this recipe exists not to do.
430
+ const cmd = Object.entries(ctx.vars ?? {}).reduce((c, [k, v]) => c.replaceAll(`<${k}>`, String(v)), step.shell);
431
+ note("shell", { cmd });
432
+ log(` ${String(nowRel(t0)).padStart(6)}ms sh ${cmd}`);
433
+ sh("/bin/sh", ["-c", cmd], { cwd: ctx.root, stdio: "inherit" });
434
+ continue;
435
+ }
436
+ throw new Error(`Unknown script step: ${JSON.stringify(step)}`);
437
+ }
438
+ timeline.endedAt = nowRel(t0);
439
+ return timeline;
440
+ }
441
+
442
+ export function readJson(p) { return JSON.parse(readFileSync(p, "utf8")); }
443
+ export function writeJson(p, v) { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify(v, null, 2) + "\n"); }