@touchtechclub/simbox 0.4.9 → 0.5.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.
Files changed (2) hide show
  1. package/dist/cli.js +1273 -306
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { Argument, Command, Option } from "commander";
5
- import pc4 from "picocolors";
5
+ import pc6 from "picocolors";
6
6
  import { createRequire } from "node:module";
7
7
 
8
8
  // src/config.ts
@@ -12,7 +12,75 @@ import { join } from "node:path";
12
12
 
13
13
  // ../../packages/shared/src/constants.ts
14
14
  var PROD_API_URL = "https://api.simbox.touchtech.club";
15
+ var WORKFLOW_PATH = ".github/workflows/simbox.yml";
16
+ // ../../packages/shared/src/device-profile.ts
17
+ var XCODE_VERSION = /^\d{1,3}(\.\d{1,3}){0,2}$/;
18
+ var IOS_VERSION = /^\d{1,3}(\.\d{1,3}){0,2}$/;
19
+ var ANDROID_API = /^\d{1,3}(\.\d{1,2})?$/;
20
+ var MODEL = /^[A-Za-z0-9][A-Za-z0-9 ._()+,'-]{0,79}$/;
21
+ var KEYS = new Set(["model", "os", "foldable", "xcode"]);
22
+ function validateDeviceProfile(value, platform) {
23
+ if (value === undefined || value === null)
24
+ return null;
25
+ if (typeof value !== "object" || Array.isArray(value))
26
+ throw new Error("Device must be {model?, os?, foldable?, xcode?}.");
27
+ if (Object.keys(value).some((key) => !KEYS.has(key)))
28
+ throw new Error("Device only accepts model, os, foldable and xcode.");
29
+ const { model, os, foldable, xcode } = value;
30
+ if (foldable !== undefined && typeof foldable !== "boolean")
31
+ throw new Error("foldable must be true or false.");
32
+ if (model !== undefined) {
33
+ if (typeof model !== "string" || !MODEL.test(model) || model !== model.trim())
34
+ throw new Error('Device model must be a simulator or emulator profile name such as "iPhone 16 Pro" or "pixel_8".');
35
+ if (foldable === true)
36
+ throw new Error("Choose either a foldable or a device model, not both.");
37
+ }
38
+ if (os !== undefined) {
39
+ if (typeof os !== "string")
40
+ throw new Error("os must be a version string.");
41
+ const keyword = os === "latest" || os === "oldest";
42
+ if (!keyword && platform === "ios" && !IOS_VERSION.test(os))
43
+ throw new Error('iOS version must look like "18.4", "18", "latest" or "oldest".');
44
+ if (!keyword && platform === "android" && !ANDROID_API.test(os))
45
+ throw new Error('Android version must be an API level such as "35", "latest" or "oldest".');
46
+ }
47
+ if (xcode !== undefined) {
48
+ if (typeof xcode !== "string" || !XCODE_VERSION.test(xcode))
49
+ throw new Error('Xcode must be a version such as "27.1".');
50
+ if (platform !== "ios")
51
+ throw new Error("Xcode selection only applies to iOS runs.");
52
+ }
53
+ const profile = {
54
+ ...model !== undefined ? { model } : {},
55
+ ...os !== undefined ? { os } : {},
56
+ ...foldable === true ? { foldable: true } : {},
57
+ ...xcode !== undefined ? { xcode } : {}
58
+ };
59
+ return Object.keys(profile).length > 0 ? profile : null;
60
+ }
61
+ function parseDevicePatch(value) {
62
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length === 0 || Object.keys(value).some((key) => key !== "ios" && key !== "android")) {
63
+ throw new Error("Expected ios and/or android device settings; use null to inherit.");
64
+ }
65
+ const patch = {};
66
+ for (const platform of ["ios", "android"]) {
67
+ if (!Object.hasOwn(value, platform))
68
+ continue;
69
+ const profile = validateDeviceProfile(value[platform], platform);
70
+ if (profile?.xcode)
71
+ throw new Error("Xcode is chosen per run with --xcode, not as a default.");
72
+ patch[platform] = profile;
73
+ }
74
+ return patch;
75
+ }
76
+ function formatDeviceProfile(profile, platform) {
77
+ const model = profile?.foldable ? platform === "ios" ? "iPhone Duo" : "Android foldable" : profile?.model ?? "default";
78
+ const os = profile?.os ? platform === "ios" ? `iOS ${profile.os}` : /^\d/.test(profile.os) ? `API ${profile.os}` : `Android ${profile.os}` : null;
79
+ return [model, os, profile?.xcode ? `Xcode ${profile.xcode}` : null].filter((part) => part !== null).join(", ");
80
+ }
81
+
15
82
  // ../../packages/shared/src/runners.ts
83
+ var PLATFORMS = ["ios", "android"];
16
84
  var DEFAULT_RUNNERS = {
17
85
  ios: { labels: ["macos-latest"] },
18
86
  android: { labels: ["ubuntu-latest"] }
@@ -81,37 +149,373 @@ function parseRunnerInput(input, platform) {
81
149
  function formatRunner(runner) {
82
150
  return runner ? runner.labels.join(" + ") : "unknown (legacy workflow)";
83
151
  }
84
- // ../../packages/shared/src/device-profile.ts
85
- var XCODE_VERSION = /^\d{1,3}(\.\d{1,3}){0,2}$/;
86
- function validateDeviceProfile(value, platform) {
87
- if (value === undefined || value === null)
88
- return null;
89
- if (typeof value !== "object" || Array.isArray(value))
90
- throw new Error("Device must be {foldable?: boolean, xcode?: string}.");
91
- const keys = Object.keys(value);
92
- if (keys.some((key) => key !== "foldable" && key !== "xcode"))
93
- throw new Error("Device only accepts foldable and xcode.");
94
- const { foldable, xcode } = value;
95
- if (foldable !== undefined && typeof foldable !== "boolean")
96
- throw new Error("foldable must be true or false.");
97
- if (xcode !== undefined) {
98
- if (typeof xcode !== "string" || !XCODE_VERSION.test(xcode))
99
- throw new Error('Xcode must be a version such as "27.1".');
100
- if (platform !== "ios")
101
- throw new Error("Xcode selection only applies to iOS runs.");
152
+ // ../../packages/shared/src/preview.ts
153
+ var PREVIEW_SESSION_MS = 5 * 60000;
154
+ // ../../packages/shared/src/device-catalog.ts
155
+ function compareVersions(a, b) {
156
+ const left = a.split(".").map(Number);
157
+ const right = b.split(".").map(Number);
158
+ for (let i = 0;i < Math.max(left.length, right.length); i++) {
159
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
160
+ if (diff !== 0)
161
+ return diff;
102
162
  }
103
- const profile = {
104
- ...foldable === true ? { foldable: true } : {},
105
- ...xcode !== undefined ? { xcode } : {}
106
- };
107
- return Object.keys(profile).length > 0 ? profile : null;
163
+ return 0;
108
164
  }
109
- function formatDeviceProfile(profile, platform) {
110
- const device = profile?.foldable ? platform === "ios" ? "iPhone Duo" : "Android foldable" : "default";
111
- return profile?.xcode ? `${device}, Xcode ${profile.xcode}` : device;
165
+ // ../../packages/shared/src/workflow.ts
166
+ var WORKFLOW_SCHEMA_VERSION = 2;
167
+ var MARKER = "# simbox-workflow:";
168
+ var RUNNER_ACTION = "TouchTechClub/runner@v1";
169
+ var SIMBOX_STEPS = {
170
+ prepare: {
171
+ name: "Prepare Simbox",
172
+ description: "Checks the runner (OS, architecture, Xcode or KVM and the Android SDK) and downloads the checksum-verified Simbox agent."
173
+ },
174
+ start: {
175
+ name: "Start devices",
176
+ description: "Boots the requested simulator or emulator in the background. Devices are booted but not reachable yet, so steps after this one can install apps or seed data."
177
+ },
178
+ serve: {
179
+ name: "Serve devices",
180
+ description: "Opens the tunnel, marks the run live and serves devices until idle, stop or the time limit. Steps after it run once the session ends."
181
+ }
182
+ };
183
+ var SIMBOX_ORDER = ["prepare", "start", "serve"];
184
+ var isLockedStep = (step) => ("simbox" in step);
185
+ var stepId = (step) => isLockedStep(step) ? step.simbox : step.id;
186
+ function defaultWorkflowSpec() {
187
+ return { steps: SIMBOX_ORDER.map((simbox) => ({ simbox })) };
188
+ }
189
+ var MAX_USER_STEPS = 40;
190
+ var STEP_ID = /^[a-z][a-z0-9_-]{0,39}$/;
191
+ var NAME = /^[^\r\n\t]{1,100}$/;
192
+ var USES = /^(\.\/[\w./-]{1,200}|docker:\/\/[\w./:@-]{1,200}|[\w.-]{1,100}\/[\w./-]{1,200}@[\w./-]{1,100})$/;
193
+ var SHELL = /^[\w {}./-]{1,60}$/;
194
+ var MAP_KEY = /^[A-Za-z_][A-Za-z0-9_-]{0,99}$/;
195
+ var PERMISSION_SCOPES = new Set([
196
+ "actions",
197
+ "attestations",
198
+ "checks",
199
+ "contents",
200
+ "deployments",
201
+ "id-token",
202
+ "issues",
203
+ "packages",
204
+ "pages",
205
+ "pull-requests",
206
+ "security-events",
207
+ "statuses"
208
+ ]);
209
+ var RESERVED_IDS = new Set(SIMBOX_ORDER);
210
+ var STEP_KEYS = new Set([
211
+ "id",
212
+ "name",
213
+ "run",
214
+ "shell",
215
+ "uses",
216
+ "with",
217
+ "env",
218
+ "if",
219
+ "platform",
220
+ "workingDirectory",
221
+ "continueOnError",
222
+ "timeoutMinutes"
223
+ ]);
224
+ function fail(message) {
225
+ throw new Error(message);
226
+ }
227
+ function stringMap(value, label) {
228
+ if (value === undefined)
229
+ return;
230
+ if (!value || typeof value !== "object" || Array.isArray(value))
231
+ fail(`${label} must be an object of strings.`);
232
+ const entries = Object.entries(value);
233
+ if (entries.length > 50)
234
+ fail(`${label} has too many entries.`);
235
+ const out = {};
236
+ for (const [key, raw] of entries) {
237
+ if (!MAP_KEY.test(key))
238
+ fail(`${label} key "${key}" is not a valid name.`);
239
+ if (typeof raw !== "string" && typeof raw !== "number" && typeof raw !== "boolean")
240
+ fail(`${label}.${key} must be a string, number or boolean.`);
241
+ const text = String(raw);
242
+ if (text.length > 4000)
243
+ fail(`${label}.${key} is too long.`);
244
+ out[key] = text;
245
+ }
246
+ return entries.length ? out : undefined;
247
+ }
248
+ function expression(value) {
249
+ const trimmed = value.trim();
250
+ const wrapped = /^\$\{\{([\s\S]*)\}\}$/.exec(trimmed);
251
+ return (wrapped ? wrapped[1] : trimmed).trim();
252
+ }
253
+ function validateUserStep(value) {
254
+ if (!value || typeof value !== "object" || Array.isArray(value))
255
+ fail("A step must be an object.");
256
+ const v = value;
257
+ const unknown = Object.keys(v).find((key) => !STEP_KEYS.has(key));
258
+ if (unknown)
259
+ fail(`Unsupported step field "${unknown}".`);
260
+ if (typeof v.id !== "string" || !STEP_ID.test(v.id))
261
+ fail("Step id must be 1–40 lowercase letters, digits, - or _, starting with a letter.");
262
+ if (RESERVED_IDS.has(v.id) || v.id.startsWith("simbox"))
263
+ fail(`Step id "${v.id}" is reserved for Simbox.`);
264
+ if (typeof v.name !== "string" || !NAME.test(v.name.trim()))
265
+ fail(`Step "${v.id}" needs a single-line name (1–100 characters).`);
266
+ const hasRun = v.run !== undefined;
267
+ const hasUses = v.uses !== undefined;
268
+ if (hasRun === hasUses)
269
+ fail(`Step "${v.id}" needs exactly one of run or uses.`);
270
+ const step = { id: v.id, name: v.name.trim() };
271
+ if (hasRun) {
272
+ if (typeof v.run !== "string" || !v.run.trim() || v.run.length > 20000)
273
+ fail(`Step "${v.id}" run must be a non-empty script up to 20,000 characters.`);
274
+ step.run = v.run;
275
+ if (v.shell !== undefined) {
276
+ if (typeof v.shell !== "string" || !SHELL.test(v.shell))
277
+ fail(`Step "${v.id}" shell is invalid.`);
278
+ step.shell = v.shell;
279
+ }
280
+ if (v.with !== undefined)
281
+ fail(`Step "${v.id}" uses run, so it cannot take with inputs.`);
282
+ } else {
283
+ if (typeof v.uses !== "string" || !USES.test(v.uses))
284
+ fail(`Step "${v.id}" uses must look like owner/repo@ref, ./path or docker://image.`);
285
+ if (v.shell !== undefined)
286
+ fail(`Step "${v.id}" uses an action, so it cannot set shell.`);
287
+ step.uses = v.uses;
288
+ const inputs = stringMap(v.with, `Step "${v.id}" with`);
289
+ if (inputs)
290
+ step.with = inputs;
291
+ }
292
+ const env = stringMap(v.env, `Step "${v.id}" env`);
293
+ if (env)
294
+ step.env = env;
295
+ if (v.if !== undefined) {
296
+ if (typeof v.if !== "string" || !expression(v.if) || v.if.length > 1000)
297
+ fail(`Step "${v.id}" if must be a non-empty expression.`);
298
+ step.if = expression(v.if);
299
+ }
300
+ if (v.platform !== undefined) {
301
+ if (v.platform !== "ios" && v.platform !== "android")
302
+ fail(`Step "${v.id}" platform must be ios or android (omit it for both).`);
303
+ step.platform = v.platform;
304
+ }
305
+ if (v.workingDirectory !== undefined) {
306
+ if (typeof v.workingDirectory !== "string" || !/^[\w./${}\s-]{1,200}$/.test(v.workingDirectory))
307
+ fail(`Step "${v.id}" workingDirectory is invalid.`);
308
+ step.workingDirectory = v.workingDirectory;
309
+ }
310
+ if (v.continueOnError !== undefined) {
311
+ if (typeof v.continueOnError !== "boolean")
312
+ fail(`Step "${v.id}" continueOnError must be boolean.`);
313
+ if (v.continueOnError)
314
+ step.continueOnError = true;
315
+ }
316
+ if (v.timeoutMinutes !== undefined) {
317
+ if (typeof v.timeoutMinutes !== "number" || !Number.isInteger(v.timeoutMinutes) || v.timeoutMinutes < 1 || v.timeoutMinutes > 360)
318
+ fail(`Step "${v.id}" timeoutMinutes must be a whole number from 1 to 360.`);
319
+ step.timeoutMinutes = v.timeoutMinutes;
320
+ }
321
+ return step;
322
+ }
323
+ function validateWorkflowSpec(value) {
324
+ if (!value || typeof value !== "object" || Array.isArray(value))
325
+ fail("Expected {steps: [...]}.");
326
+ const v = value;
327
+ if (Object.keys(v).some((key) => key !== "steps" && key !== "permissions"))
328
+ fail("A workflow only has steps and permissions.");
329
+ if (!Array.isArray(v.steps))
330
+ fail("steps must be an array.");
331
+ const steps = [];
332
+ const seen = new Set;
333
+ const locked = [];
334
+ for (const raw of v.steps) {
335
+ if (raw && typeof raw === "object" && "simbox" in raw) {
336
+ const key = raw.simbox;
337
+ if (Object.keys(raw).length !== 1 || !SIMBOX_ORDER.includes(key))
338
+ fail("Locked steps are {simbox: prepare|start|serve} and cannot be edited.");
339
+ locked.push(key);
340
+ steps.push({ simbox: key });
341
+ continue;
342
+ }
343
+ const step = validateUserStep(raw);
344
+ if (seen.has(step.id))
345
+ fail(`Duplicate step id "${step.id}".`);
346
+ seen.add(step.id);
347
+ steps.push(step);
348
+ }
349
+ if (locked.join() !== SIMBOX_ORDER.join())
350
+ fail("The Simbox steps prepare, start and serve must each appear once, in that order.");
351
+ if (seen.size > MAX_USER_STEPS)
352
+ fail(`Use at most ${MAX_USER_STEPS} custom steps.`);
353
+ const spec = { steps };
354
+ if (v.permissions !== undefined) {
355
+ if (!v.permissions || typeof v.permissions !== "object" || Array.isArray(v.permissions))
356
+ fail("permissions must map scopes to read, write or none.");
357
+ const permissions = {};
358
+ for (const [scope, level] of Object.entries(v.permissions)) {
359
+ if (!PERMISSION_SCOPES.has(scope))
360
+ fail(`Unknown permission scope "${scope}".`);
361
+ if (level !== "read" && level !== "write" && level !== "none")
362
+ fail(`Permission ${scope} must be read, write or none.`);
363
+ permissions[scope] = level;
364
+ }
365
+ if (Object.keys(permissions).length)
366
+ spec.permissions = permissions;
367
+ }
368
+ if (renderWorkflow(spec).length > 256000)
369
+ fail("The workflow is too large.");
370
+ return spec;
371
+ }
372
+ function indexOf(spec, id) {
373
+ const index = spec.steps.findIndex((step) => stepId(step) === id);
374
+ if (index < 0)
375
+ fail(`No step "${id}". Steps: ${spec.steps.map(stepId).join(", ")}.`);
376
+ return index;
377
+ }
378
+ function targetIndex(spec, position) {
379
+ return "before" in position ? indexOf(spec, position.before) : indexOf(spec, position.after) + 1;
380
+ }
381
+ function insertStep(spec, step, position) {
382
+ const steps = [...spec.steps];
383
+ steps.splice(targetIndex(spec, position), 0, step);
384
+ return validateWorkflowSpec({ ...spec, steps });
385
+ }
386
+ function removeStep(spec, id) {
387
+ const index = indexOf(spec, id);
388
+ if (isLockedStep(spec.steps[index]))
389
+ fail(`"${id}" is a locked Simbox step.`);
390
+ return validateWorkflowSpec({ ...spec, steps: spec.steps.filter((_, i) => i !== index) });
391
+ }
392
+ function moveStep(spec, id, position) {
393
+ const index = indexOf(spec, id);
394
+ const step = spec.steps[index];
395
+ if (isLockedStep(step))
396
+ fail(`"${id}" is a locked Simbox step and cannot move.`);
397
+ const without = { ...spec, steps: spec.steps.filter((_, i) => i !== index) };
398
+ return insertStep(without, step, position);
399
+ }
400
+ function scalar(value, indent) {
401
+ const lines = value.replace(/\r\n?/g, `
402
+ `).split(`
403
+ `);
404
+ const blockable = lines.length > 1 && !/^\s/.test(lines[0]) && lines.every((line) => !/[\u0000-\u0008\u000b-\u001f\u007f]/.test(line));
405
+ if (!blockable)
406
+ return JSON.stringify(value);
407
+ let body = value.replace(/\r\n?/g, `
408
+ `);
409
+ const trailing = /\n*$/.exec(body)[0].length;
410
+ const chomp = trailing === 0 ? "-" : trailing === 1 ? "" : "+";
411
+ body = body.replace(/\n+$/, "");
412
+ const rendered = body.split(`
413
+ `).map((line) => line ? `${indent}${line}` : "").join(`
414
+ `);
415
+ return `|${chomp}
416
+ ${rendered}${`
417
+ `.repeat(Math.max(0, trailing - 1))}`;
418
+ }
419
+ function condition(step) {
420
+ const platform = step.platform ? `inputs.platform == '${step.platform}'` : null;
421
+ if (platform && step.if)
422
+ return `\${{ ${platform} && (${step.if}) }}`;
423
+ if (platform)
424
+ return `\${{ ${platform} }}`;
425
+ return step.if ? `\${{ ${step.if} }}` : null;
426
+ }
427
+ function renderUserStep(step) {
428
+ const out = [` - id: ${step.id}`, ` name: ${JSON.stringify(step.name)}`];
429
+ const when = condition(step);
430
+ if (when)
431
+ out.push(` if: ${JSON.stringify(when)}`);
432
+ if (step.workingDirectory)
433
+ out.push(` working-directory: ${JSON.stringify(step.workingDirectory)}`);
434
+ if (step.continueOnError)
435
+ out.push(" continue-on-error: true");
436
+ if (step.timeoutMinutes)
437
+ out.push(` timeout-minutes: ${step.timeoutMinutes}`);
438
+ if (step.uses)
439
+ out.push(` uses: ${JSON.stringify(step.uses)}`);
440
+ if (step.with) {
441
+ out.push(" with:");
442
+ for (const [key, value] of Object.entries(step.with))
443
+ out.push(` ${key}: ${scalar(value, " ")}`);
444
+ }
445
+ if (step.env) {
446
+ out.push(" env:");
447
+ for (const [key, value] of Object.entries(step.env))
448
+ out.push(` ${key}: ${scalar(value, " ")}`);
449
+ }
450
+ if (step.shell)
451
+ out.push(` shell: ${JSON.stringify(step.shell)}`);
452
+ if (step.run !== undefined)
453
+ out.push(` run: ${scalar(step.run, " ")}`);
454
+ return out;
455
+ }
456
+ function renderLockedStep(key) {
457
+ const out = [
458
+ ` - id: simbox_${key}`,
459
+ ` name: ${SIMBOX_STEPS[key].name}`,
460
+ ` uses: ${RUNNER_ACTION}`,
461
+ " with:",
462
+ ` step: ${key}`,
463
+ " platform: ${{ inputs.platform }}"
464
+ ];
465
+ if (key === "start")
466
+ out.push(" token: ${{ secrets.SIMBOX_TOKEN }}", " run_id: ${{ inputs.simbox_run }}");
467
+ return out;
468
+ }
469
+ function renderWorkflow(spec) {
470
+ const permissions = { contents: "read", ...spec.permissions };
471
+ const header = [
472
+ `${MARKER} ${WORKFLOW_SCHEMA_VERSION}`,
473
+ "# Managed by Simbox. Edit steps in the dashboard or with `simbox workflow`;",
474
+ "# direct edits are replaced the next time Simbox saves or repairs this file.",
475
+ "name: simbox",
476
+ 'run-name: "simbox ${{ inputs.platform }} ${{ inputs.simbox_run }}"',
477
+ "",
478
+ "on:",
479
+ " workflow_dispatch:",
480
+ " inputs:",
481
+ " platform:",
482
+ " type: choice",
483
+ " options: [ios, android]",
484
+ " default: ios",
485
+ " description: Device platform (Android uses Linux/KVM)",
486
+ " runner_labels:",
487
+ " type: string",
488
+ ' default: ""',
489
+ " description: JSON array of runner labels (empty uses platform default)",
490
+ " simbox_run:",
491
+ " type: string",
492
+ ' default: ""',
493
+ " description: Run id assigned by Simbox (empty for manual runs)",
494
+ "",
495
+ "permissions:",
496
+ ...Object.entries(permissions).map(([scope, level]) => ` ${scope}: ${level}`),
497
+ "",
498
+ "jobs:",
499
+ " simbox:",
500
+ ` runs-on: \${{ fromJSON(inputs.runner_labels || (inputs.platform == 'android' && '["ubuntu-latest"]' || '["macos-latest"]')) }}`,
501
+ " timeout-minutes: 360",
502
+ " steps:"
503
+ ];
504
+ const steps = spec.steps.flatMap((step) => isLockedStep(step) ? renderLockedStep(step.simbox) : renderUserStep(step));
505
+ return `${[...header, ...steps].join(`
506
+ `)}
507
+ `;
508
+ }
509
+ var WORKFLOW_YAML = renderWorkflow(defaultWorkflowSpec());
510
+ function suggestStepId(spec, name) {
511
+ const base = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/^(?![a-z])/, "step-").slice(0, 34).replace(/^simbox/, "step") || "step";
512
+ const taken = new Set(spec.steps.map(stepId));
513
+ if (!taken.has(base) && !RESERVED_IDS.has(base))
514
+ return base;
515
+ for (let n = 2;; n++)
516
+ if (!taken.has(`${base}-${n}`))
517
+ return `${base}-${n}`;
112
518
  }
113
- // ../../packages/shared/src/preview.ts
114
- var PREVIEW_SESSION_MS = 5 * 60000;
115
519
  // src/config.ts
116
520
  var DEFAULT_WEB_URL = "https://simbox.touchtech.club";
117
521
  function stripTrailingSlash(url) {
@@ -188,6 +592,14 @@ class ApiRequestError extends Error {
188
592
  this.name = "ApiRequestError";
189
593
  }
190
594
  }
595
+ var API_TIMEOUT_MS = 30000;
596
+ var ENSURE_TIMEOUT_MS = 120000;
597
+ function unreachable(err, timeoutMs) {
598
+ const error = err;
599
+ if (error?.name === "TimeoutError" || error?.name === "AbortError")
600
+ return new CliError(`${apiBaseUrl()} did not answer within ${Math.round(timeoutMs / 1000)}s — check your connection and retry.`);
601
+ return new CliError(`Could not reach ${apiBaseUrl()} — check your connection. (${error?.message ?? String(err)})`);
602
+ }
191
603
  async function api(path, opts = {}) {
192
604
  const headers = {
193
605
  accept: "application/json"
@@ -198,15 +610,18 @@ async function api(path, opts = {}) {
198
610
  headers.authorization = `Bearer ${token}`;
199
611
  if (opts.body !== undefined)
200
612
  headers["content-type"] = "application/json";
613
+ const timeoutMs = opts.timeoutMs ?? API_TIMEOUT_MS;
614
+ const signal = AbortSignal.timeout(timeoutMs);
201
615
  let res;
202
616
  try {
203
617
  res = await fetch(`${apiBaseUrl()}${path}`, {
204
618
  method: opts.method ?? "GET",
205
619
  headers,
206
- body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined
620
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
621
+ signal
207
622
  });
208
623
  } catch (err) {
209
- throw new CliError(`Could not reach ${apiBaseUrl()} — check your connection. (${err.message})`);
624
+ throw unreachable(err, timeoutMs);
210
625
  }
211
626
  if (res.status === 401 || res.status === 403) {
212
627
  throw new ApiRequestError(res.status, "unauthorized", "Your session is invalid or expired. Run `simbox login`.");
@@ -219,7 +634,11 @@ async function api(path, opts = {}) {
219
634
  const body = await parseErrorBody(res);
220
635
  throw new ApiRequestError(res.status, body?.error ?? `http_${res.status}`, body?.message ?? `Request failed (HTTP ${res.status}).`);
221
636
  }
222
- return await res.json();
637
+ try {
638
+ return await res.json();
639
+ } catch (err) {
640
+ throw unreachable(err, timeoutMs);
641
+ }
223
642
  }
224
643
  async function parseErrorBody(res) {
225
644
  try {
@@ -244,10 +663,11 @@ async function pollDeviceToken(deviceCode) {
244
663
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
245
664
  device_code: deviceCode,
246
665
  client_id: CLI_CLIENT_ID
247
- })
666
+ }),
667
+ signal: AbortSignal.timeout(API_TIMEOUT_MS)
248
668
  });
249
669
  } catch (err) {
250
- throw new CliError(`Could not reach ${apiBaseUrl()} — check your connection. (${err.message})`);
670
+ throw unreachable(err, API_TIMEOUT_MS);
251
671
  }
252
672
  const body = await res.json().catch(() => null);
253
673
  if (res.ok && body?.access_token)
@@ -260,27 +680,25 @@ async function pollDeviceToken(deviceCode) {
260
680
  }
261
681
  var me = async (token) => (await api("/v1/me", { token })).user;
262
682
  var repoStatus = () => api("/v1/repo/status");
263
- var repairRepo = () => api("/v1/repo/repair", {
264
- method: "POST"
265
- });
683
+ var repairRepo = () => api("/v1/repo/repair", { method: "POST" });
266
684
  var ensureRun = (fresh, platform2, runner, device) => api("/v1/runs/ensure", {
267
685
  method: "POST",
268
- body: { new: fresh, platform: platform2, runner, device }
686
+ body: { new: fresh, platform: platform2, runner, device },
687
+ timeoutMs: ENSURE_TIMEOUT_MS
269
688
  });
270
689
  var runnerSettings = () => api("/v1/runners");
271
690
  var runnerDiagnostics = () => api("/v1/runners/doctor");
272
691
  var updateRunners = (scope, patch) => api(`/v1/runners/${scope}`, { method: "POST", body: patch });
273
- async function currentRun() {
274
- try {
275
- const res = await api("/v1/runs/current");
276
- return res.run;
277
- } catch (err) {
278
- if (err instanceof ApiRequestError && (err.status === 404 || err.code === "no_run")) {
279
- return null;
280
- }
281
- throw err;
282
- }
283
- }
692
+ var updateDevices = (scope, patch) => api(`/v1/devices/${scope}`, { method: "POST", body: patch });
693
+ var inventory = (platform2, runner) => {
694
+ const query = new URLSearchParams({ platform: platform2 });
695
+ if (runner)
696
+ query.set("runner", runner.labels.length === 1 ? runner.labels[0] : JSON.stringify(runner.labels));
697
+ return api(`/v1/inventory?${query}`);
698
+ };
699
+ var getWorkflow = () => api("/v1/workflow");
700
+ var putWorkflow = (spec) => api("/v1/workflow", { method: "PUT", body: { spec } });
701
+ var activeRuns = async () => (await api("/v1/runs/active")).runs;
284
702
  var stopRun = (runId) => api(`/v1/runs/${encodeURIComponent(runId)}/stop`, {
285
703
  method: "POST"
286
704
  });
@@ -300,6 +718,18 @@ That run is full — try \`simbox sim --new\` for a fresh one.`);
300
718
  if (err.code === "needs_repair" || err.code === "repo_needs_repair" || err.code === "repo_not_ready" || err.code === "dispatch_failed") {
301
719
  return new CliError(`${err.message}
302
720
  Run \`simbox repair\` to fix the repo setup.`);
721
+ }
722
+ if (err.code === "workflow_needs_repair") {
723
+ return new CliError(`${err.message}
724
+ Run \`simbox repair\` to upgrade the workflow (merge its PR if the default branch is protected).`);
725
+ }
726
+ if (err.code === "platform_busy") {
727
+ return new CliError(`${err.message}
728
+ See \`simbox ps\`; \`simbox stop --platform <ios|android>\` ends a run and \`simbox sim --new --platform <ios|android>\` replaces one.`);
729
+ }
730
+ if (err.code === "invalid_device") {
731
+ return new CliError(`${err.message}
732
+ Run \`simbox devices list --platform <ios|android>\` to see what the runner offers.`);
303
733
  }
304
734
  return new CliError(err.message);
305
735
  }
@@ -310,41 +740,6 @@ Run \`simbox repair\` to fix the repo setup.`);
310
740
 
311
741
  // src/commands.ts
312
742
  import { rmSync as rmSync2 } from "node:fs";
313
-
314
- // src/run-cache.ts
315
- import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
316
- import { join as join2 } from "node:path";
317
- var cachePath = () => join2(configDir(), "current-run.json");
318
- function readCachedRun(now = Date.now()) {
319
- try {
320
- const info = JSON.parse(readFileSync2(cachePath(), "utf8"));
321
- if (typeof info.runId !== "string" || typeof info.tunnelUrl !== "string" || typeof info.daemonToken !== "string")
322
- return null;
323
- const expiresAt = typeof info.expiresAt === "number" ? info.expiresAt : null;
324
- if (expiresAt !== null && expiresAt <= now)
325
- return null;
326
- return {
327
- runId: info.runId,
328
- tunnelUrl: info.tunnelUrl,
329
- daemonToken: info.daemonToken,
330
- expiresAt
331
- };
332
- } catch {
333
- return null;
334
- }
335
- }
336
- function writeCachedRun(info) {
337
- try {
338
- mkdirSync2(configDir(), { recursive: true, mode: 448 });
339
- writeFileSync2(cachePath(), JSON.stringify(info), { mode: 384 });
340
- chmodSync2(cachePath(), 384);
341
- } catch {}
342
- }
343
- function clearCachedRun() {
344
- rmSync(cachePath(), { force: true });
345
- }
346
-
347
- // src/commands.ts
348
743
  import pc3 from "picocolors";
349
744
 
350
745
  // src/connect.ts
@@ -361,10 +756,14 @@ function findAgentDevice() {
361
756
  const first = (res.stdout ?? "").split(/\r?\n/).find((l) => l.trim().length > 0);
362
757
  return first?.trim() ?? null;
363
758
  }
364
- function connectFlow(info, quiet = false) {
759
+ function connectFlow(info, hints, quiet = false) {
365
760
  const out = quiet ? process.stderr : process.stdout;
366
761
  const base = daemonBaseUrl(info.tunnelUrl);
367
762
  const bin = findAgentDevice();
763
+ const { platform: platform2, bootedDevice } = hints;
764
+ const device = bootedDevice ? JSON.stringify(bootedDevice.name) : '"<device from simbox exec devices>"';
765
+ const selector = platform2 === "ios" ? "--udid" : "--serial";
766
+ const id = bootedDevice?.id ?? (platform2 === "ios" ? "<udid>" : "<serial>");
368
767
  if (bin) {
369
768
  out.write(`${pc.green("▶")} Ready for ${pc.bold("agent-device")} via Simbox.
370
769
  `);
@@ -372,14 +771,17 @@ function connectFlow(info, quiet = false) {
372
771
  out.write(`${pc.yellow("!")} ${pc.bold("agent-device")} not found on PATH — install it, then:
373
772
  `);
374
773
  }
774
+ if (bootedDevice)
775
+ out.write(` Booted: ${pc.bold(bootedDevice.name)} (${platform2 === "ios" ? "iOS" : "API"} ${bootedDevice.os}${bootedDevice.id ? `, ${bootedDevice.id}` : ""})
776
+ `);
375
777
  out.write(`
376
- ${pc.cyan("simbox exec devices")}
778
+ ${pc.cyan(`simbox exec devices --platform ${platform2}`)}
377
779
  `);
378
- out.write(` ${pc.cyan('simbox exec open <app> --platform ios --device "<device from inventory>"')}
780
+ out.write(` ${pc.cyan(`simbox exec open <app> --platform ${platform2} --device ${device}`)}
379
781
  `);
380
- out.write(` ${pc.cyan("simbox exec snapshot -i")}
782
+ out.write(` ${pc.cyan(`simbox exec snapshot -i --platform ${platform2}`)}
381
783
  `);
382
- out.write(` Use --udid for iOS or --serial for Android when selecting a specific device.
784
+ out.write(` Pass --platform ${platform2} whenever both an iOS and an Android run are active; use ${selector} to pick a specific device.
383
785
  `);
384
786
  out.write(` exec supplies auth, refreshes tunnel URLs, and allows 5 minutes for cold startup.
385
787
 
@@ -390,7 +792,7 @@ function connectFlow(info, quiet = false) {
390
792
  `);
391
793
  out.write(` ${pc.cyan(`export AGENT_DEVICE_DAEMON_BASE_URL=${base}`)}
392
794
  `);
393
- out.write(` ${pc.cyan(`agent-device open <app> --platform ios --udid <udid> --session simbox-${info.runId} --timeout 300000`)}
795
+ out.write(` ${pc.cyan(`agent-device open <app> --platform ${platform2} ${selector} ${id} --session simbox-${info.runId} --timeout 300000`)}
394
796
  `);
395
797
  out.write(` Raw agent-device commands need these exports again if the tunnel changes.
396
798
  `);
@@ -433,6 +835,18 @@ function fmtCountdown(expiresAt) {
433
835
  return `${m}m ${sec}s`;
434
836
  return `${sec}s`;
435
837
  }
838
+ function fmtAgo(at, now = Date.now()) {
839
+ const ms = now - (at < 1000000000000 ? at * 1000 : at);
840
+ const s = Math.max(0, Math.floor(ms / 1000));
841
+ const unit = (n, name) => `${n} ${name}${n === 1 ? "" : "s"} ago`;
842
+ if (s < 60)
843
+ return "just now";
844
+ if (s < 3600)
845
+ return unit(Math.floor(s / 60), "minute");
846
+ if (s < 86400)
847
+ return unit(Math.floor(s / 3600), "hour");
848
+ return unit(Math.floor(s / 86400), "day");
849
+ }
436
850
  var FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
437
851
 
438
852
  class Spinner {
@@ -500,26 +914,22 @@ class Spinner {
500
914
  }
501
915
  }
502
916
 
503
- // src/remote.ts
504
- import { spawn } from "node:child_process";
505
- import { join as join4 } from "node:path";
506
-
507
917
  // src/device-registration.ts
508
918
  import { randomUUID } from "node:crypto";
509
- import { existsSync, mkdirSync as mkdirSync3, readdirSync, statSync, unlinkSync, writeFileSync as writeFileSync3 } from "node:fs";
919
+ import { existsSync, mkdirSync as mkdirSync2, readdirSync, statSync, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
510
920
  import { homedir as homedir2 } from "node:os";
511
- import { join as join3 } from "node:path";
921
+ import { join as join2 } from "node:path";
512
922
  var MAX_AGE_MS = 5 * 60000;
513
923
  function registerDevice(info, options = {}) {
514
- if (!process.env.T3CODE_HOME && !process.env.SIMBOX_DEVICE_REGISTRATION_DIR && ![".s5code", ".t3"].some((home) => existsSync(join3(homedir2(), home, "userdata", "server-runtime.json"))))
924
+ if (!process.env.T3CODE_HOME && !process.env.SIMBOX_DEVICE_REGISTRATION_DIR && ![".s5code", ".t3"].some((home) => existsSync(join2(homedir2(), home, "userdata", "server-runtime.json"))))
515
925
  return;
516
926
  try {
517
- const directory = process.env.SIMBOX_DEVICE_REGISTRATION_DIR ?? join3(configDir(), "device-registrations");
518
- mkdirSync3(directory, { recursive: true, mode: 448 });
927
+ const directory = process.env.SIMBOX_DEVICE_REGISTRATION_DIR ?? join2(configDir(), "device-registrations");
928
+ mkdirSync2(directory, { recursive: true, mode: 448 });
519
929
  for (const file of readdirSync(directory)) {
520
930
  if (!/^[a-f0-9-]{36}\.json$/.test(file))
521
931
  continue;
522
- const path = join3(directory, file);
932
+ const path = join2(directory, file);
523
933
  if (Date.now() - statSync(path).mtimeMs > MAX_AGE_MS)
524
934
  unlinkSync(path);
525
935
  }
@@ -531,7 +941,7 @@ function registerDevice(info, options = {}) {
531
941
  return index >= 0 ? flags[index + 1] : flags.find((arg) => arg.startsWith(`${name}=`))?.slice(name.length + 1);
532
942
  };
533
943
  const id = randomUUID();
534
- writeFileSync3(join3(directory, `${id}.json`), JSON.stringify({
944
+ writeFileSync2(join2(directory, `${id}.json`), JSON.stringify({
535
945
  version: 1,
536
946
  createdAt: Date.now(),
537
947
  ...info,
@@ -552,33 +962,148 @@ function registerDevice(info, options = {}) {
552
962
  }
553
963
  }
554
964
 
555
- // src/remote.ts
556
- var STARTUP_TIMEOUT_MS = 300000;
557
- async function waitForAndroid(runId, options = {}) {
558
- const deadline = Date.now() + (options.timeoutMs ?? 12 * 60000);
559
- do {
560
- const run = await (options.read ?? currentRun)();
561
- if (!run || run.id !== runId || run.state !== "live") {
562
- throw new CliError("The run ended or was replaced while preparing Android. Run `simbox sim` again.");
563
- }
564
- if (run.platform === "ios") {
565
- throw new CliError("Android needs a Linux/KVM run. Run `simbox repair`, then `simbox sim --new --platform android`.");
965
+ // src/run-cache.ts
966
+ import { chmodSync as chmodSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
967
+ import { join as join3 } from "node:path";
968
+ var cachePath = (platform2) => join3(configDir(), `current-run-${platform2}.json`);
969
+ var legacyPath = () => join3(configDir(), "current-run.json");
970
+ function removeLegacy() {
971
+ try {
972
+ rmSync(legacyPath(), { force: true });
973
+ } catch {}
974
+ }
975
+ function readCachedRun(platform2, now = Date.now()) {
976
+ removeLegacy();
977
+ try {
978
+ const info = JSON.parse(readFileSync2(cachePath(platform2), "utf8"));
979
+ if (info.platform !== platform2 || typeof info.runId !== "string" || typeof info.tunnelUrl !== "string" || typeof info.daemonToken !== "string")
980
+ return null;
981
+ const expiresAt = typeof info.expiresAt === "number" ? info.expiresAt : null;
982
+ if (expiresAt !== null && expiresAt <= now)
983
+ return null;
984
+ return {
985
+ runId: info.runId,
986
+ tunnelUrl: info.tunnelUrl,
987
+ daemonToken: info.daemonToken,
988
+ expiresAt
989
+ };
990
+ } catch {
991
+ return null;
992
+ }
993
+ }
994
+ function writeCachedRun(platform2, info) {
995
+ removeLegacy();
996
+ const path = cachePath(platform2);
997
+ try {
998
+ mkdirSync3(configDir(), { recursive: true, mode: 448 });
999
+ const { runId, tunnelUrl, daemonToken, expiresAt } = info;
1000
+ writeFileSync3(path, JSON.stringify({ platform: platform2, runId, tunnelUrl, daemonToken, expiresAt }), {
1001
+ mode: 384
1002
+ });
1003
+ chmodSync2(path, 384);
1004
+ } catch {}
1005
+ }
1006
+ function clearCachedRun(platform2) {
1007
+ removeLegacy();
1008
+ rmSync(cachePath(platform2), { force: true });
1009
+ }
1010
+
1011
+ // src/runs.ts
1012
+ var PLATFORM_NAMES = { ios: "iOS", android: "Android" };
1013
+ var defaultRunsDeps = {
1014
+ activeRuns,
1015
+ history: runHistory,
1016
+ probe: probeRemote,
1017
+ readCache: (platform2) => readCachedRun(platform2),
1018
+ writeCache: writeCachedRun,
1019
+ clearCache: clearCachedRun,
1020
+ pause: sleep
1021
+ };
1022
+ var withDefaults = (deps = {}) => ({ ...defaultRunsDeps, ...deps });
1023
+ var DEVICES_TIMEOUT_MS = 12 * 60000;
1024
+ var POLL_MS = 3000;
1025
+ function platformFlag(args) {
1026
+ const end = args.indexOf("--");
1027
+ const flags = end < 0 ? args : args.slice(0, end);
1028
+ let found;
1029
+ for (let i = 0;i < flags.length; i++) {
1030
+ const arg = flags[i];
1031
+ const value = arg === "--platform" ? flags[i + 1] : arg.startsWith("--platform=") ? arg.slice("--platform=".length) : undefined;
1032
+ if (value === undefined)
1033
+ continue;
1034
+ if (value !== "ios" && value !== "android")
1035
+ throw new CliError(`Simbox runs serve --platform ios or android, not "${value}".`);
1036
+ if (found && found !== value)
1037
+ throw new CliError("Pass --platform once: a command goes to one run.");
1038
+ found = value;
1039
+ }
1040
+ return found;
1041
+ }
1042
+ function describe(run) {
1043
+ return `${PLATFORM_NAMES[run.platform]} (run ${run.id}, ${run.state})`;
1044
+ }
1045
+ function targetPlatform(explicit, active, hint = "Pass --platform ios or --platform android.") {
1046
+ if (explicit)
1047
+ return explicit;
1048
+ if (active.length === 1)
1049
+ return active[0].platform;
1050
+ if (active.length === 0)
1051
+ throw new CliError("No active run. Start one with `simbox sim`.");
1052
+ throw new CliError(`Two runs are active: ${active.map(describe).join(" and ")}. ${hint}`);
1053
+ }
1054
+ function devicesReady(run) {
1055
+ return run.devicesReady ?? (run.platform === "android" ? run.androidReady : true);
1056
+ }
1057
+ function connectInfo(run) {
1058
+ if (run.state !== "live" || !run.tunnelUrl || !run.daemonToken)
1059
+ return null;
1060
+ return {
1061
+ runId: run.id,
1062
+ tunnelUrl: run.tunnelUrl,
1063
+ daemonToken: run.daemonToken,
1064
+ expiresAt: run.expiresAt
1065
+ };
1066
+ }
1067
+ var STOPPED = new Set(["closing", "ended", "failed"]);
1068
+ async function waitForRun(runId, options) {
1069
+ const deps = withDefaults(options.deps);
1070
+ const deadline = Date.now() + options.timeoutMs;
1071
+ let last = null;
1072
+ while (true) {
1073
+ const run = (await deps.activeRuns()).find((candidate) => candidate.id === runId);
1074
+ if (!run) {
1075
+ const past = (await deps.history()).find((candidate) => candidate.id === runId);
1076
+ const name = past ? PLATFORM_NAMES[past.platform] : "The";
1077
+ const retry = past ? ` --platform ${past.platform}` : "";
1078
+ throw new CliError(past && STOPPED.has(past.state) ? `The ${name} run ${past.state} before its devices were ready (${past.endReason ?? "check the Actions logs"}). Try \`simbox sim --new${retry}\`.` : `${name} run ${runId} is no longer active (it ended or was replaced). Run \`simbox sim${retry}\` again.`);
566
1079
  }
567
- if (run.androidReady)
568
- return;
569
- await (options.pause?.() ?? sleep(3000));
570
- } while (Date.now() < deadline);
571
- throw new CliError("Android AVD preparation did not complete. Inspect the Actions job's Android emulator prep logs. No device command was sent.");
1080
+ if (STOPPED.has(run.state))
1081
+ throw new CliError(`The ${PLATFORM_NAMES[run.platform]} run ${run.state} before its devices were ready (${run.endReason ?? "check the Actions logs"}). Try \`simbox sim --new --platform ${run.platform}\`.`);
1082
+ last = run;
1083
+ options.onUpdate?.(run);
1084
+ const info = connectInfo(run);
1085
+ if (info && devicesReady(run))
1086
+ return { run, info };
1087
+ if (Date.now() >= deadline)
1088
+ break;
1089
+ await deps.pause(POLL_MS);
1090
+ }
1091
+ if (last?.state === "live")
1092
+ throw new CliError(`The ${PLATFORM_NAMES[last.platform]} run is live but its devices did not finish booting. Inspect the Actions job's device logs. No device command was sent.`);
1093
+ throw new CliError(options.timeoutMessage);
572
1094
  }
573
- async function waitForRemote(initial, options = {}) {
1095
+ async function refreshPlatform(platform2, deps = {}) {
1096
+ const run = (await withDefaults(deps).activeRuns()).find((r) => r.platform === platform2);
1097
+ return run ? connectInfo(run) : null;
1098
+ }
1099
+ async function waitForRemote(initial, options) {
574
1100
  const probe = options.probe ?? probeRemote;
575
- const refresh = options.refresh ?? refreshRemote;
576
1101
  const deadline = Date.now() + (options.timeoutMs ?? 180000);
577
1102
  let info = initial;
578
1103
  do {
579
1104
  if (await probe(info))
580
1105
  return info;
581
- const next = await refresh();
1106
+ const next = await options.refresh();
582
1107
  if (!next || next.runId !== initial.runId) {
583
1108
  throw new CliError("The run ended or was replaced. Run `simbox sim` to connect again.");
584
1109
  }
@@ -600,87 +1125,44 @@ async function probeRemote(info) {
600
1125
  return false;
601
1126
  }
602
1127
  }
603
- async function refreshRemote() {
604
- const run = await currentRun();
605
- if (run?.state !== "live" || !run.tunnelUrl || !run.daemonToken)
606
- return null;
607
- return {
608
- runId: run.id,
609
- tunnelUrl: run.tunnelUrl,
610
- daemonToken: run.daemonToken,
611
- expiresAt: run.expiresAt
612
- };
613
- }
614
- function remoteArgs(args, info, stateDir) {
615
- const separator = args.indexOf("--");
616
- const result = separator < 0 ? [...args] : args.slice(0, separator);
617
- const tail = separator < 0 ? [] : args.slice(separator);
618
- if (["open", "boot", "prepare"].includes(args[0] ?? "") && !hasFlag(args, "--timeout")) {
619
- result.push("--timeout", String(STARTUP_TIMEOUT_MS));
1128
+ async function connect(platform2, options = {}) {
1129
+ const deps = withDefaults(options.deps);
1130
+ const cached = deps.readCache(platform2);
1131
+ if (cached && await deps.probe(cached))
1132
+ return cached;
1133
+ const active = options.active ?? await deps.activeRuns();
1134
+ const run = active.find((candidate) => candidate.platform === platform2);
1135
+ if (!run) {
1136
+ deps.clearCache(platform2);
1137
+ throw new CliError(`No active ${PLATFORM_NAMES[platform2]} run. Start one with \`simbox sim --platform ${platform2}\`.`);
620
1138
  }
621
- if (!hasFlag(args, "--session"))
622
- result.push("--session", `simbox-${info.runId}`);
623
- result.push("--state-dir", stateDir, "--daemon-base-url", daemonBaseUrl(info.tunnelUrl), "--daemon-transport", "http");
624
- return [...result, ...tail];
625
- }
626
- function hasFlag(args, flag) {
627
- const end = args.indexOf("--");
628
- return (end < 0 ? args : args.slice(0, end)).some((arg) => arg === flag || arg.startsWith(`${flag}=`));
1139
+ const ready = await waitForRun(run.id, {
1140
+ timeoutMs: options.timeoutMs ?? DEVICES_TIMEOUT_MS,
1141
+ timeoutMessage: `The ${PLATFORM_NAMES[platform2]} run did not come online in time. Check the workflow run in your repo's Actions tab. No device command was sent.`,
1142
+ deps
1143
+ });
1144
+ const info = await waitForRemote(ready.info, {
1145
+ probe: deps.probe,
1146
+ refresh: () => refreshPlatform(platform2, deps),
1147
+ pause: () => deps.pause(1000)
1148
+ });
1149
+ deps.writeCache(platform2, info);
1150
+ return info;
629
1151
  }
630
- async function cmdExec(args) {
631
- if (!args.length)
632
- throw new CliError("Usage: simbox exec <agent-device command> [arguments]");
633
- const reserved = [
634
- "--daemon-base-url",
635
- "--daemon-auth-token",
636
- "--daemon-transport",
637
- "--remote-config",
638
- "--state-dir"
639
- ];
640
- if (reserved.some((flag) => hasFlag(args, flag))) {
641
- throw new CliError("Simbox manages the remote URL, credentials and state directory for `exec`.");
642
- }
643
- if (["connect", "disconnect", "proxy", "auth"].includes(args[0])) {
644
- throw new CliError("Use device commands with `simbox exec`, not connection/auth commands.");
645
- }
646
- const bin = findAgentDevice();
647
- if (!bin)
648
- throw new CliError("Install agent-device first: npm i -g agent-device@0.21.28");
649
- const android = args.some((arg, i) => arg === "--platform=android" || arg === "--platform" && args[i + 1] === "android");
650
- let info = android ? null : readCachedRun();
651
- if (info && !await probeRemote(info))
652
- info = null;
653
- if (!info) {
654
- const initial = await refreshRemote();
655
- if (!initial) {
656
- clearCachedRun();
657
- throw new CliError("No live run. Start one with `simbox sim`.");
658
- }
659
- if (android)
660
- await waitForAndroid(initial.runId);
661
- info = await waitForRemote(initial);
662
- }
663
- writeCachedRun(info);
664
- const env = {
665
- ...process.env,
666
- AGENT_DEVICE_DAEMON_AUTH_TOKEN: info.daemonToken
667
- };
668
- delete env.AGENT_DEVICE_REMOTE_CONFIG;
669
- delete env.AGENT_DEVICE_DAEMON_BASE_URL;
670
- const code = await new Promise((resolve, reject) => {
671
- const child = spawn(bin, remoteArgs(args, info, join4(configDir(), "agent-device")), {
672
- stdio: "inherit",
673
- env
674
- });
675
- child.on("error", reject);
676
- child.on("exit", (status) => resolve(status ?? 1));
1152
+ async function connectForArgs(args, deps = {}) {
1153
+ const all = withDefaults(deps);
1154
+ const explicit = platformFlag(args);
1155
+ if (explicit)
1156
+ return { platform: explicit, info: await connect(explicit, { deps: all }) };
1157
+ const cached = PLATFORMS.flatMap((platform3) => {
1158
+ const info = all.readCache(platform3);
1159
+ return info ? [{ platform: platform3, info }] : [];
677
1160
  });
678
- if (code === 0 && !args.some((arg) => ["help", "--help", "-h", "--version", "version"].includes(arg)))
679
- registerDevice(info, { args });
680
- if (code !== 0) {
681
- process.stderr.write("Simbox did not retry the device command (it may already have executed). Use `simbox exec session list` to inspect it; the next command refreshes the tunnel automatically.\n");
682
- }
683
- process.exitCode = code;
1161
+ if (cached.length === 1 && await all.probe(cached[0].info))
1162
+ return cached[0];
1163
+ const active = await all.activeRuns();
1164
+ const platform2 = targetPlatform(undefined, active);
1165
+ return { platform: platform2, info: await connect(platform2, { active, deps: all }) };
684
1166
  }
685
1167
 
686
1168
  // src/commands.ts
@@ -765,136 +1247,180 @@ function printRepoConnected(repo) {
765
1247
  }
766
1248
  console.log(`Run ${pc3.cyan("simbox sim")} to boot a simulator.`);
767
1249
  }
768
- var SIM_TIMEOUT_MS = 12 * 60 * 1000;
769
- var SIM_POLL_MS = 3000;
770
- async function cmdSim(opts) {
771
- const json = opts.json === true;
772
- const out = json ? process.stderr : process.stdout;
1250
+ var SIM_TIMEOUT_MS = 25 * 60 * 1000;
1251
+ var SIM_RUNTIME_TIMEOUT_MS = 45 * 60 * 1000;
1252
+ function simRequest(opts) {
773
1253
  if (opts.runner !== undefined && !opts.platform)
774
1254
  throw new CliError("Use --platform ios or android with --runner.");
775
1255
  const runner = opts.runner === undefined ? undefined : parseRunnerInput(opts.runner, opts.platform);
776
- const wantsDevice = opts.foldable === true || opts.xcode !== undefined;
777
- if (wantsDevice && !opts.platform)
778
- throw new CliError("Use --platform ios or android with --foldable or --xcode.");
1256
+ const flags = [
1257
+ opts.device !== undefined && "--device",
1258
+ opts.os !== undefined && "--os",
1259
+ opts.foldable === true && "--foldable",
1260
+ opts.xcode !== undefined && "--xcode"
1261
+ ].filter((flag) => typeof flag === "string");
1262
+ if (flags.length && !opts.platform)
1263
+ throw new CliError(`Use --platform ios or android with ${flags.join(", ")}.`);
779
1264
  let device;
780
1265
  try {
781
- device = wantsDevice ? validateDeviceProfile({ foldable: opts.foldable, xcode: opts.xcode }, opts.platform) ?? undefined : undefined;
1266
+ device = flags.length ? validateDeviceProfile({
1267
+ model: opts.device,
1268
+ os: opts.os,
1269
+ foldable: opts.foldable || undefined,
1270
+ xcode: opts.xcode
1271
+ }, opts.platform) ?? undefined : undefined;
782
1272
  } catch (err) {
783
1273
  throw new CliError(err.message);
784
1274
  }
785
- const first = await ensureRun(opts.new === true, opts.platform, runner, device);
786
- let info;
787
- if (first.state === "live") {
788
- info = {
789
- runId: first.runId,
790
- tunnelUrl: first.tunnelUrl,
791
- daemonToken: first.daemonToken,
792
- expiresAt: first.expiresAt
793
- };
794
- } else {
795
- const spinner = new Spinner(`Run ${first.state}`);
796
- spinner.begin();
797
- const deadline = Date.now() + SIM_TIMEOUT_MS;
798
- let live = null;
799
- while (Date.now() < deadline) {
800
- await sleep(SIM_POLL_MS);
801
- const run = await currentRun();
802
- if (!run) {
803
- const ended = (await runHistory()).find((candidate) => candidate.id === first.runId);
804
- if (ended && (ended.state === "ended" || ended.state === "failed")) {
805
- spinner.fail(`Run ${ended.state}`);
806
- throw new CliError(`The run ${ended.state} during startup (${ended.endReason ?? "check the Actions logs"}). Try \`simbox sim --new\`.`);
807
- }
808
- continue;
809
- }
810
- if (run.state === "live" && run.tunnelUrl && run.daemonToken) {
811
- live = { run, tunnelUrl: run.tunnelUrl, daemonToken: run.daemonToken };
812
- break;
813
- }
814
- if (run.state === "failed" || run.state === "ended" || run.state === "closing") {
815
- spinner.fail(`Run ${run.state}`);
816
- throw new CliError(`The run ${run.state} before coming online (${run.endReason ?? "no reason given"}). Try \`simbox sim --new\`.`);
817
- }
818
- spinner.set(`Run ${run.state}`);
819
- }
820
- if (!live) {
821
- spinner.fail("Timed out");
822
- throw new CliError("Run didn't come online within 12 minutes. Check the workflow run in your repo's Actions tab, then try `simbox sim` again (or `--new`).");
823
- }
824
- spinner.succeed("Run is live");
825
- info = {
826
- runId: live.run.id,
827
- tunnelUrl: live.tunnelUrl,
828
- daemonToken: live.daemonToken,
829
- expiresAt: live.run.expiresAt
830
- };
1275
+ const timeoutMs = opts.platform === "ios" && (device?.foldable || device?.xcode) ? SIM_RUNTIME_TIMEOUT_MS : SIM_TIMEOUT_MS;
1276
+ return { platform: opts.platform, runner, device, timeoutMs };
1277
+ }
1278
+ function simTimeoutMessage(timeoutMs) {
1279
+ return `Run didn't come online within ${Math.round(timeoutMs / 60000)} minutes. Check the workflow run in your repo's Actions tab, then try \`simbox sim\` again (or \`--new\`).`;
1280
+ }
1281
+ async function cmdSim(opts, deps = {}) {
1282
+ const json = opts.json === true;
1283
+ const all = { ...defaultRunsDeps, ...deps };
1284
+ const request = simRequest(opts);
1285
+ const first = await ensureRun(opts.new === true, request.platform, request.runner, request.device);
1286
+ const spinner = new Spinner(`Run ${first.state}`);
1287
+ spinner.begin();
1288
+ let ready;
1289
+ try {
1290
+ ready = await waitForRun(first.runId, {
1291
+ timeoutMs: request.timeoutMs,
1292
+ timeoutMessage: simTimeoutMessage(request.timeoutMs),
1293
+ onUpdate: (run2) => spinner.set(`${PLATFORM_NAMES[run2.platform]} run ${run2.state === "live" ? "live, waiting for devices" : run2.state}`),
1294
+ deps: all
1295
+ });
1296
+ } catch (err) {
1297
+ spinner.fail("Run did not come online");
1298
+ throw err;
831
1299
  }
832
- info = await waitForRemote(info);
833
- if (opts.platform === "android")
834
- await waitForAndroid(info.runId);
835
- writeCachedRun(info);
836
- registerDevice(info, { platform: opts.platform });
1300
+ spinner.succeed(`${PLATFORM_NAMES[ready.run.platform]} run is live`);
1301
+ const { run } = ready;
1302
+ const info = await waitForRemote(ready.info, {
1303
+ probe: all.probe,
1304
+ refresh: () => refreshPlatform(run.platform, all)
1305
+ });
1306
+ all.writeCache(run.platform, info);
1307
+ registerDevice(info, { platform: run.platform });
837
1308
  if (json) {
838
1309
  const payload = {
839
1310
  tunnel_url: info.tunnelUrl,
840
1311
  daemon_token: info.daemonToken,
841
1312
  run_id: info.runId,
842
- expires_at: info.expiresAt
1313
+ expires_at: info.expiresAt,
1314
+ platform: run.platform,
1315
+ booted_device: run.bootedDevice ?? null
843
1316
  };
844
1317
  process.stdout.write(JSON.stringify(payload) + `
845
1318
  `);
846
1319
  return;
847
1320
  }
848
- out.write(`Run ${pc3.bold(info.runId)} is ${pc3.green("live")}` + (info.expiresAt ? ` — expires in ${fmtCountdown(info.expiresAt)}` : "") + `
1321
+ process.stdout.write(`${PLATFORM_NAMES[run.platform]} run ${pc3.bold(info.runId)} is ${pc3.green("live")}` + (info.expiresAt ? ` — expires in ${fmtCountdown(info.expiresAt)}` : "") + `
849
1322
 
850
1323
  `);
851
- connectFlow(info, false);
1324
+ connectFlow(info, { platform: run.platform, bootedDevice: run.bootedDevice });
852
1325
  }
853
- async function cmdPs() {
854
- const run = await currentRun();
855
- if (!run) {
856
- console.log(`No active run. Start one with ${pc3.cyan("simbox sim")}.`);
857
- return;
858
- }
859
- const stateColor = run.state === "live" ? pc3.green : run.state === "failed" ? pc3.red : pc3.yellow;
860
- console.log(`${pc3.bold("run")} ${run.id}`);
861
- console.log(` state: ${stateColor(run.state)}`);
862
- console.log(` platform: ${run.platform}`);
1326
+ function stateColor(state) {
1327
+ return state === "live" ? pc3.green : state === "failed" ? pc3.red : pc3.yellow;
1328
+ }
1329
+ function printRun(run) {
1330
+ const booted = run.bootedDevice;
1331
+ console.log(`${pc3.bold(PLATFORM_NAMES[run.platform])} run ${run.id}`);
1332
+ console.log(` state: ${stateColor(run.state)(run.state)}`);
863
1333
  console.log(` runner: ${formatRunner(run.runner)}`);
864
- console.log(` device: ${formatDeviceProfile(run.device ?? null, run.platform)}`);
865
- console.log(` gh_run_id: ${run.ghRunId ?? pc3.dim("pending")}`);
1334
+ console.log(` requested: ${formatDeviceProfile(run.device ?? null, run.platform)}`);
1335
+ console.log(` booted: ${booted ? `${booted.name} (${run.platform === "ios" ? "iOS" : "API"} ${booted.os}${booted.id ? `, ${booted.id}` : ""})` : pc3.dim(run.devicesReady ? "unknown" : "not yet")}`);
1336
+ console.log(` devices_ready: ${run.devicesReady ? "yes" : "no"}`);
866
1337
  console.log(` active_devices: ${run.activeDevices}`);
867
- console.log(` android_ready: ${run.androidReady ? "yes" : "no"}`);
1338
+ console.log(` gh_run_id: ${run.ghRunId ?? pc3.dim("pending")}`);
868
1339
  console.log(` expires: ${run.expiresAt ? fmtCountdown(run.expiresAt) : pc3.dim("n/a")}`);
869
1340
  if (run.endReason)
870
1341
  console.log(` end_reason: ${run.endReason}`);
871
1342
  }
872
- async function cmdStop() {
873
- const run = await currentRun();
874
- if (!run) {
875
- console.log("No active run to stop.");
1343
+ async function cmdPs(opts = {}) {
1344
+ const runs = await activeRuns();
1345
+ if (opts.json) {
1346
+ console.log(JSON.stringify({ runs }));
1347
+ return;
1348
+ }
1349
+ if (!runs.length) {
1350
+ console.log(`No active runs. Start one with ${pc3.cyan("simbox sim --platform ios|android")}.`);
876
1351
  return;
877
1352
  }
878
- await stopRun(run.id);
879
- clearCachedRun();
880
- if (run.tunnelUrl && run.daemonToken)
881
- registerDevice({
882
- runId: run.id,
883
- tunnelUrl: run.tunnelUrl,
884
- daemonToken: run.daemonToken,
885
- expiresAt: run.expiresAt
886
- }, { stop: true });
887
- console.log(`${pc3.green("✔")} Stop requested for run ${pc3.bold(run.id)}.`);
1353
+ runs.forEach((run, i) => {
1354
+ if (i)
1355
+ console.log("");
1356
+ printRun(run);
1357
+ });
1358
+ }
1359
+ async function cmdStop(opts = {}) {
1360
+ if (opts.all && opts.platform)
1361
+ throw new CliError("Use either --platform or --all, not both.");
1362
+ const active = await activeRuns();
1363
+ let targets;
1364
+ if (opts.all)
1365
+ targets = active;
1366
+ else if (opts.platform)
1367
+ targets = active.filter((run) => run.platform === opts.platform);
1368
+ else if (!active.length)
1369
+ targets = [];
1370
+ else {
1371
+ const platform2 = targetPlatform(undefined, active, "Pass --platform ios|android to stop one, or --all to stop both.");
1372
+ targets = active.filter((run) => run.platform === platform2);
1373
+ }
1374
+ if (!targets.length) {
1375
+ if (opts.platform)
1376
+ clearCachedRun(opts.platform);
1377
+ console.log(opts.platform ? `No active ${PLATFORM_NAMES[opts.platform]} run to stop.` : "No active run to stop.");
1378
+ return;
1379
+ }
1380
+ for (const run of targets) {
1381
+ await stopRun(run.id);
1382
+ clearCachedRun(run.platform);
1383
+ if (run.tunnelUrl && run.daemonToken)
1384
+ registerDevice({
1385
+ runId: run.id,
1386
+ tunnelUrl: run.tunnelUrl,
1387
+ daemonToken: run.daemonToken,
1388
+ expiresAt: run.expiresAt
1389
+ }, { stop: true, platform: run.platform });
1390
+ console.log(`${pc3.green("✔")} Stop requested for the ${PLATFORM_NAMES[run.platform]} run ${pc3.bold(run.id)}.`);
1391
+ }
1392
+ }
1393
+ function printRepoStatus(status) {
1394
+ const repo = status.repo;
1395
+ if (!status.connected || !repo) {
1396
+ console.log(`No repo connected. Run ${pc3.cyan("simbox init")}.`);
1397
+ return;
1398
+ }
1399
+ const color = repo.state === "ok" ? pc3.green : pc3.yellow;
1400
+ console.log(`Repo ${pc3.bold(repo.fullName)}: ${color(repo.state)}${repo.workflowVersion ? pc3.dim(` (workflow v${repo.workflowVersion})`) : ""}`);
1401
+ if (repo.state === "pending_pr")
1402
+ console.log(repo.prUrl ? `The default branch is protected — merge ${pc3.underline(repo.prUrl)} to apply the workflow.` : "The default branch is protected — merge the Simbox pull request to apply the workflow.");
1403
+ else if (repo.state === "ok" && repo.prUrl)
1404
+ console.log(`The default branch is protected — merge ${pc3.underline(repo.prUrl)} to apply the update. The current workflow keeps working meanwhile.`);
1405
+ else if (repo.state === "ok")
1406
+ console.log("Workflow up to date and installation verified (the repo secret rotates only while no run is active).");
1407
+ else if (repo.state === "needs_repair")
1408
+ console.log("The repo still needs repair; check the GitHub App installation and permissions.");
1409
+ else if (repo.state === "uninstalled")
1410
+ console.log(`The GitHub App is not installed. Run ${pc3.cyan("simbox init")}.`);
888
1411
  }
889
1412
  async function cmdRepair() {
890
1413
  const spinner = new Spinner("Repairing repo");
891
1414
  spinner.begin();
892
- const res = await repairRepo();
1415
+ let status;
1416
+ try {
1417
+ status = await repairRepo();
1418
+ } catch (err) {
1419
+ spinner.fail("Repair failed");
1420
+ throw err;
1421
+ }
893
1422
  spinner.succeed("Repair complete");
894
- if (res.message)
895
- console.log(res.message);
896
- else
897
- console.log("Workflow re-committed, secret rotated, installation verified.");
1423
+ printRepoStatus(status);
898
1424
  }
899
1425
  async function cmdLogout() {
900
1426
  try {
@@ -905,6 +1431,114 @@ async function cmdLogout() {
905
1431
  }
906
1432
  }
907
1433
 
1434
+ // src/remote.ts
1435
+ import { spawn } from "node:child_process";
1436
+ import { join as join4 } from "node:path";
1437
+ var STARTUP_TIMEOUT_MS = 300000;
1438
+ function remoteArgs(args, info, stateDir) {
1439
+ const separator = args.indexOf("--");
1440
+ const result = separator < 0 ? [...args] : args.slice(0, separator);
1441
+ const tail = separator < 0 ? [] : args.slice(separator);
1442
+ if (["open", "boot", "prepare"].includes(args[0] ?? "") && !hasFlag(args, "--timeout")) {
1443
+ result.push("--timeout", String(STARTUP_TIMEOUT_MS));
1444
+ }
1445
+ if (!hasFlag(args, "--session"))
1446
+ result.push("--session", `simbox-${info.runId}`);
1447
+ result.push("--state-dir", stateDir, "--daemon-base-url", daemonBaseUrl(info.tunnelUrl), "--daemon-transport", "http");
1448
+ return [...result, ...tail];
1449
+ }
1450
+ var REPLAY_SAFE = new Set([
1451
+ "devices",
1452
+ "snapshot",
1453
+ "screenshot",
1454
+ "fold",
1455
+ "session",
1456
+ "appstate",
1457
+ "apps",
1458
+ "get",
1459
+ "is"
1460
+ ]);
1461
+ var TRANSPORT_ERROR = /Remote daemon is unavailable|Daemon request timed out|Failed to read daemon response|fetch failed|ECONNRESET|socket hang up/;
1462
+ function shouldReplay(args, code, output) {
1463
+ return code !== 0 && REPLAY_SAFE.has(args[0] ?? "") && TRANSPORT_ERROR.test(output);
1464
+ }
1465
+ var REPLAY_ATTEMPTS = 3;
1466
+ function hasFlag(args, flag) {
1467
+ const end = args.indexOf("--");
1468
+ return (end < 0 ? args : args.slice(0, end)).some((arg) => arg === flag || arg.startsWith(`${flag}=`));
1469
+ }
1470
+ async function cmdExec(args) {
1471
+ if (!args.length)
1472
+ throw new CliError("Usage: simbox exec <agent-device command> [arguments]");
1473
+ const reserved = [
1474
+ "--daemon-base-url",
1475
+ "--daemon-auth-token",
1476
+ "--daemon-transport",
1477
+ "--remote-config",
1478
+ "--state-dir"
1479
+ ];
1480
+ if (reserved.some((flag) => hasFlag(args, flag))) {
1481
+ throw new CliError("Simbox manages the remote URL, credentials and state directory for `exec`.");
1482
+ }
1483
+ if (["connect", "disconnect", "proxy", "auth"].includes(args[0])) {
1484
+ throw new CliError("Use device commands with `simbox exec`, not connection/auth commands.");
1485
+ }
1486
+ const bin = findAgentDevice();
1487
+ if (!bin)
1488
+ throw new CliError("Install agent-device first: npm i -g agent-device@0.21.28");
1489
+ const { platform: platform2, info } = await connectForArgs(args);
1490
+ const replaySafe = REPLAY_SAFE.has(args[0]);
1491
+ let connected = info;
1492
+ let result = await runRemote(bin, args, connected, replaySafe);
1493
+ for (let attempt = 1;attempt < REPLAY_ATTEMPTS; attempt++) {
1494
+ if (!shouldReplay(args, result.code, result.output))
1495
+ break;
1496
+ process.stderr.write(`Tunnel dropped during \`${args[0]}\`; retrying (${attempt}/${REPLAY_ATTEMPTS - 1}).
1497
+ `);
1498
+ await sleep(attempt * 5000);
1499
+ if (!await probeRemote(connected)) {
1500
+ const refreshed = await refreshPlatform(platform2);
1501
+ if (!refreshed || refreshed.runId !== connected.runId)
1502
+ break;
1503
+ connected = await waitForRemote(refreshed, { refresh: () => refreshPlatform(platform2) });
1504
+ writeCachedRun(platform2, connected);
1505
+ }
1506
+ result = await runRemote(bin, args, connected, replaySafe);
1507
+ }
1508
+ if (replaySafe) {
1509
+ process.stdout.write(result.stdout);
1510
+ process.stderr.write(result.stderr);
1511
+ }
1512
+ const code = result.code;
1513
+ if (code === 0 && !args.some((arg) => ["help", "--help", "-h", "--version", "version"].includes(arg)))
1514
+ registerDevice(connected, { args, platform: platform2 });
1515
+ if (code !== 0 && !replaySafe) {
1516
+ process.stderr.write("Simbox did not retry the device command (it may already have executed). Use `simbox exec session list` to inspect it; the next command refreshes the tunnel automatically.\n");
1517
+ }
1518
+ process.exitCode = code;
1519
+ }
1520
+ async function runRemote(bin, args, info, capture) {
1521
+ const env = {
1522
+ ...process.env,
1523
+ AGENT_DEVICE_DAEMON_AUTH_TOKEN: info.daemonToken
1524
+ };
1525
+ delete env.AGENT_DEVICE_REMOTE_CONFIG;
1526
+ delete env.AGENT_DEVICE_DAEMON_BASE_URL;
1527
+ let stdout = "";
1528
+ let stderr = "";
1529
+ const code = await new Promise((resolve, reject) => {
1530
+ const child = spawn(bin, remoteArgs(args, info, join4(configDir(), "agent-device")), {
1531
+ stdio: capture ? ["inherit", "pipe", "pipe"] : "inherit",
1532
+ env
1533
+ });
1534
+ child.stdout?.on("data", (chunk) => stdout += chunk.toString());
1535
+ child.stderr?.on("data", (chunk) => stderr += chunk.toString());
1536
+ child.on("error", reject);
1537
+ child.on("close", (status) => resolve(status ?? 1));
1538
+ });
1539
+ return { code, output: stdout + stderr, stdout, stderr };
1540
+ }
1541
+
908
1542
  // src/runners.ts
909
1543
  import { spawnSync as spawnSync2 } from "node:child_process";
910
1544
  function printRunnerSettings(settings, json = false) {
@@ -917,10 +1551,25 @@ function printRunnerSettings(settings, json = false) {
917
1551
  console.log(`${platform2}: ${formatRunner(settings.effective[platform2].runner)} (${settings.effective[platform2].source})`);
918
1552
  console.log(` account: ${settings.account[platform2] ? formatRunner(settings.account[platform2]) : "GitHub default"}`);
919
1553
  console.log(` repository: ${settings.repository?.[platform2] ? formatRunner(settings.repository[platform2]) : "inherit account"}`);
1554
+ const device = settings.devices?.effective?.[platform2];
1555
+ if (device)
1556
+ console.log(` device: ${formatLayeredDevice(device, platform2)}`);
920
1557
  }
921
1558
  console.log("Settings apply to future runs only. Run `simbox repair` once to upgrade older workflows.");
1559
+ console.log("Device defaults: `simbox devices set <platform> --device <model> --os <version> --scope account|repo`.");
922
1560
  console.log("Blacksmith requires its GitHub App on the repository and bills separately. Custom runners need Linux x64/KVM or macOS ARM64/Xcode.");
923
1561
  }
1562
+ function formatLayeredDevice(layered, platform2) {
1563
+ if (!layered.device)
1564
+ return "runner default";
1565
+ const sources = Object.entries(layered.sources).filter(([, source]) => source !== undefined).map(([field, source]) => `${field}: ${source}`);
1566
+ const unset = [
1567
+ layered.device.model === undefined && !layered.device.foldable && "model",
1568
+ layered.device.os === undefined && "os"
1569
+ ].filter((field) => typeof field === "string");
1570
+ const parts = [...sources, ...unset.map((field) => `${field}: runner default`)];
1571
+ return `${formatDeviceProfile(layered.device, platform2)} (${parts.join(", ")})`;
1572
+ }
924
1573
  async function cmdRunnersShow(json = false) {
925
1574
  printRunnerSettings(await runnerSettings(), json);
926
1575
  }
@@ -1039,7 +1688,290 @@ async function cmdSkillsInstall(opts) {
1039
1688
  });
1040
1689
  }
1041
1690
 
1691
+ // src/devices.ts
1692
+ import pc4 from "picocolors";
1693
+ function runnerArg(runner) {
1694
+ return runner.labels.length === 1 && /^[\w.-]+$/.test(runner.labels[0]) ? runner.labels[0] : `'${JSON.stringify(runner.labels)}'`;
1695
+ }
1696
+ var MAX_LISTED_MODELS = 6;
1697
+ function formatInventory(res) {
1698
+ const { platform: platform2, runner } = res;
1699
+ const lines = [`${pc4.bold(PLATFORM_NAMES[platform2])} on ${formatRunner(runner)}`];
1700
+ const inv = res.inventory;
1701
+ if (!inv) {
1702
+ lines.push(" No device catalog yet. A runner reports what it offers on its first run; start one with:", ` ${pc4.cyan(`simbox sim --platform ${platform2} --runner ${runnerArg(runner)}`)}`);
1703
+ return lines;
1704
+ }
1705
+ lines.push(pc4.dim(` reported by ${formatRunner(inv.runner)} ${fmtAgo(inv.reportedAt)}`));
1706
+ const names = new Map(inv.models.map((model) => [model.id, model.name]));
1707
+ lines.push(` ${platform2 === "ios" ? "iOS versions" : "Android API levels"} (newest first):`);
1708
+ for (const os of [...inv.os].sort((a, b) => compareVersions(b.version, a.version))) {
1709
+ const marks = os.version === inv.defaults.os ? pc4.green(" (default)") : "";
1710
+ let runs = "";
1711
+ if (os.models && os.models.length < inv.models.length) {
1712
+ const listed = os.models.map((id) => names.get(id) ?? id);
1713
+ runs = pc4.dim(` — ${listed.slice(0, MAX_LISTED_MODELS).join(", ")}${listed.length > MAX_LISTED_MODELS ? `, +${listed.length - MAX_LISTED_MODELS} more` : ""}`);
1714
+ }
1715
+ lines.push(` ${os.version.padEnd(8)} ${os.name}${marks}${runs}`);
1716
+ }
1717
+ lines.push(" Models:");
1718
+ for (const model of inv.models) {
1719
+ const isDefault = model.id === inv.defaults.model || model.name === inv.defaults.model;
1720
+ lines.push(` ${model.name}${platform2 === "android" && model.id !== model.name ? pc4.dim(` (${model.id})`) : ""}${model.foldable ? " [foldable]" : ""}${isDefault ? pc4.green(" (default)") : ""}`);
1721
+ }
1722
+ if (inv.xcode?.length)
1723
+ lines.push(` Xcode: ${inv.xcode.join(", ")}`);
1724
+ lines.push(pc4.dim(` e.g. simbox sim --platform ${platform2} --device ${JSON.stringify(inv.models.find((m) => !m.foldable)?.name ?? inv.defaults.model)} --os ${inv.defaults.os}`));
1725
+ return lines;
1726
+ }
1727
+ async function cmdDevicesList(opts, read = inventory) {
1728
+ if (opts.runner !== undefined && !opts.platform)
1729
+ throw new CliError("Use --platform ios or android with --runner.");
1730
+ const runner = opts.runner === undefined ? undefined : parseRunnerInput(opts.runner, opts.platform);
1731
+ const platforms = opts.platform ? [opts.platform] : PLATFORMS;
1732
+ const results = await Promise.all(platforms.map((platform2) => read(platform2, runner)));
1733
+ if (opts.json) {
1734
+ console.log(JSON.stringify(Object.fromEntries(results.map((res) => [res.platform, res]))));
1735
+ return;
1736
+ }
1737
+ console.log(results.map((res) => formatInventory(res).join(`
1738
+ `)).join(`
1739
+
1740
+ `));
1741
+ }
1742
+ async function cmdDevicesSet(platform2, opts, scope, json = false) {
1743
+ let profile = null;
1744
+ if (opts) {
1745
+ profile = parseDevicePatch({
1746
+ [platform2]: {
1747
+ ...opts.device !== undefined ? { model: opts.device } : {},
1748
+ ...opts.os !== undefined ? { os: opts.os } : {},
1749
+ ...opts.foldable ? { foldable: true } : {}
1750
+ }
1751
+ })[platform2];
1752
+ if (!profile)
1753
+ throw new CliError(`Pass --device, --os or --foldable. To inherit instead, run \`simbox devices reset ${platform2} --scope ${scope}\`.`);
1754
+ }
1755
+ printRunnerSettings(await updateDevices(scope, { [platform2]: profile }), json);
1756
+ }
1757
+
1758
+ // src/workflow.ts
1759
+ import { spawnSync as spawnSync3 } from "node:child_process";
1760
+ import { mkdtempSync, readFileSync as readFileSync3, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs";
1761
+ import { tmpdir } from "node:os";
1762
+ import { join as join5 } from "node:path";
1763
+ import pc5 from "picocolors";
1764
+ var defaultWorkflowDeps = {
1765
+ get: getWorkflow,
1766
+ put: putWorkflow,
1767
+ openEditor,
1768
+ log: (line) => console.log(line)
1769
+ };
1770
+ var withDefaults2 = (deps = {}) => ({
1771
+ ...defaultWorkflowDeps,
1772
+ ...deps
1773
+ });
1774
+ function edit(fn) {
1775
+ try {
1776
+ return fn();
1777
+ } catch (err) {
1778
+ throw new CliError(err.message);
1779
+ }
1780
+ }
1781
+ var STATUS_TEXT = {
1782
+ current: "current — the committed file matches these steps",
1783
+ outdated: "outdated — run `simbox repair` to upgrade it",
1784
+ drifted: "differs from these steps — a pending pull request or an edit outside Simbox",
1785
+ missing: "missing — run `simbox repair` to commit it"
1786
+ };
1787
+ function oneLine(text, max = 60) {
1788
+ const first = text.trim().split(`
1789
+ `)[0];
1790
+ const more = first.length > max || text.trim().includes(`
1791
+ `);
1792
+ return `${first.slice(0, max)}${more ? "…" : ""}`;
1793
+ }
1794
+ function describeStep(step) {
1795
+ const parts = [];
1796
+ if (step.platform)
1797
+ parts.push(`[${step.platform} only]`);
1798
+ if (step.if)
1799
+ parts.push(`if: ${step.if}`);
1800
+ if (step.uses) {
1801
+ const inputs = step.with ? Object.keys(step.with) : [];
1802
+ parts.push(`uses: ${step.uses}${inputs.length ? ` (with ${inputs.join(", ")})` : ""}`);
1803
+ } else if (step.run !== undefined)
1804
+ parts.push(`run: ${oneLine(step.run)}`);
1805
+ if (step.env)
1806
+ parts.push(`env: ${Object.keys(step.env).join(", ")}`);
1807
+ return parts.join(" ");
1808
+ }
1809
+ function formatWorkflow(res) {
1810
+ const lines = [
1811
+ `${pc5.bold(WORKFLOW_PATH)}: ${res.status ? STATUS_TEXT[res.status] : pc5.yellow("unknown — GitHub could not be read")}`,
1812
+ `Repo state: ${res.repoState === "ok" ? pc5.green("ok") : pc5.yellow(res.repoState)}`
1813
+ ];
1814
+ if (res.prUrl)
1815
+ lines.push(`Merge ${pc5.underline(res.prUrl)} to apply these steps (the default branch is protected).`);
1816
+ else if (res.repoState === "pending_pr")
1817
+ lines.push("Merge the Simbox pull request to apply the workflow (the default branch is protected).");
1818
+ lines.push("", "Steps:");
1819
+ const width = Math.max(...res.spec.steps.map((s) => (isLockedStep(s) ? s.simbox : s.id).length));
1820
+ res.spec.steps.forEach((step, i) => {
1821
+ const n = `${String(i + 1).padStart(2)}.`;
1822
+ if (isLockedStep(step)) {
1823
+ lines.push(`${n} ${pc5.dim("locked")} ${step.simbox.padEnd(width)} ${SIMBOX_STEPS[step.simbox].name}`);
1824
+ } else {
1825
+ const detail = describeStep(step);
1826
+ lines.push(`${n} ${step.id.padEnd(width)} ${step.name}${detail ? pc5.dim(` ${detail}`) : ""}`);
1827
+ }
1828
+ });
1829
+ if (res.spec.permissions)
1830
+ lines.push("", `Permissions: ${Object.entries(res.spec.permissions).map(([scope, level]) => `${scope}: ${level}`).join(", ")}`);
1831
+ return lines;
1832
+ }
1833
+ async function cmdWorkflowShow(opts, deps = {}) {
1834
+ const d = withDefaults2(deps);
1835
+ if (opts.yaml && opts.json)
1836
+ throw new CliError("Choose either --yaml or --json.");
1837
+ const res = await d.get();
1838
+ if (opts.json)
1839
+ d.log(JSON.stringify(res));
1840
+ else if (opts.yaml)
1841
+ d.log(res.yaml.replace(/\n$/, ""));
1842
+ else
1843
+ for (const line of formatWorkflow(res))
1844
+ d.log(line);
1845
+ }
1846
+ async function save(spec, d, json = false) {
1847
+ const res = await d.put(spec);
1848
+ if (json) {
1849
+ d.log(JSON.stringify(res));
1850
+ return res;
1851
+ }
1852
+ d.log(`${pc5.green("✔")} Workflow saved.`);
1853
+ for (const line of formatWorkflow(res))
1854
+ d.log(line);
1855
+ return res;
1856
+ }
1857
+ function stepPosition(opts) {
1858
+ if (opts.before !== undefined && opts.after !== undefined)
1859
+ throw new CliError("Choose either --before or --after.");
1860
+ if (opts.after !== undefined)
1861
+ return { after: opts.after };
1862
+ return { before: opts.before ?? "start" };
1863
+ }
1864
+ function keyValues(pairs, flag) {
1865
+ if (!pairs?.length)
1866
+ return;
1867
+ const out = {};
1868
+ for (const pair of pairs) {
1869
+ const eq = pair.indexOf("=");
1870
+ if (eq <= 0)
1871
+ throw new CliError(`${flag} takes key=value (got "${pair}").`);
1872
+ out[pair.slice(0, eq)] = pair.slice(eq + 1);
1873
+ }
1874
+ return out;
1875
+ }
1876
+ function buildStep(spec, opts) {
1877
+ if (opts.run === undefined === (opts.uses === undefined))
1878
+ throw new CliError("Pass exactly one of --run <script> or --uses <action>.");
1879
+ if (opts.with?.length && opts.uses === undefined)
1880
+ throw new CliError("--with only applies to --uses steps.");
1881
+ const inputs = keyValues(opts.with, "--with");
1882
+ const env = keyValues(opts.env, "--env");
1883
+ return {
1884
+ id: opts.id ?? suggestStepId(spec, opts.name),
1885
+ name: opts.name,
1886
+ ...opts.run !== undefined ? { run: opts.run } : { uses: opts.uses },
1887
+ ...inputs ? { with: inputs } : {},
1888
+ ...env ? { env } : {},
1889
+ ...opts.if !== undefined ? { if: opts.if } : {},
1890
+ ...opts.platform ? { platform: opts.platform } : {}
1891
+ };
1892
+ }
1893
+ async function cmdWorkflowAdd(opts, deps = {}) {
1894
+ const d = withDefaults2(deps);
1895
+ const position = stepPosition(opts);
1896
+ const { spec } = await d.get();
1897
+ const step = buildStep(spec, opts);
1898
+ await save(edit(() => insertStep(spec, step, position)), d, opts.json);
1899
+ }
1900
+ async function cmdWorkflowRemove(id, opts = {}, deps = {}) {
1901
+ const d = withDefaults2(deps);
1902
+ const { spec } = await d.get();
1903
+ await save(edit(() => removeStep(spec, id)), d, opts.json);
1904
+ }
1905
+ async function cmdWorkflowMove(id, opts, deps = {}) {
1906
+ const d = withDefaults2(deps);
1907
+ if (opts.before === undefined && opts.after === undefined)
1908
+ throw new CliError("Pass --before <id> or --after <id>.");
1909
+ const position = stepPosition(opts);
1910
+ const { spec } = await d.get();
1911
+ await save(edit(() => moveStep(spec, id, position)), d, opts.json);
1912
+ }
1913
+ function parseSpecText(text, source) {
1914
+ let value;
1915
+ try {
1916
+ value = JSON.parse(text);
1917
+ } catch (err) {
1918
+ throw new CliError(`${source} is not valid JSON: ${err.message}`);
1919
+ }
1920
+ if (value && typeof value === "object" && "spec" in value && !("steps" in value))
1921
+ value = value.spec;
1922
+ return edit(() => validateWorkflowSpec(value));
1923
+ }
1924
+ async function cmdWorkflowApply(file, opts = {}, deps = {}) {
1925
+ const d = withDefaults2(deps);
1926
+ let text;
1927
+ try {
1928
+ text = readFileSync3(file, "utf8");
1929
+ } catch (err) {
1930
+ throw new CliError(`Could not read ${file}: ${err.message}`);
1931
+ }
1932
+ await save(parseSpecText(text, file), d, opts.json);
1933
+ }
1934
+ async function cmdWorkflowEdit(opts = {}, deps = {}) {
1935
+ const d = withDefaults2(deps);
1936
+ const { spec } = await d.get();
1937
+ const original = `${JSON.stringify(spec, null, 2)}
1938
+ `;
1939
+ const dir = mkdtempSync(join5(tmpdir(), "simbox-workflow-"));
1940
+ const file = join5(dir, "simbox-workflow.json");
1941
+ writeFileSync4(file, original, { mode: 384 });
1942
+ let keep = false;
1943
+ try {
1944
+ d.openEditor(file);
1945
+ const text = readFileSync3(file, "utf8");
1946
+ if (text === original) {
1947
+ d.log("No changes.");
1948
+ return;
1949
+ }
1950
+ let next;
1951
+ try {
1952
+ next = parseSpecText(text, "The edited workflow");
1953
+ } catch (err) {
1954
+ keep = true;
1955
+ throw new CliError(`${err.message}
1956
+ Your edit is kept at ${file}; fix it and run \`simbox workflow apply ${file}\`.`);
1957
+ }
1958
+ await save(next, d, opts.json);
1959
+ } finally {
1960
+ if (!keep)
1961
+ rmSync3(dir, { recursive: true, force: true });
1962
+ }
1963
+ }
1964
+ function openEditor(file) {
1965
+ const editor = process.env.VISUAL || process.env.EDITOR || "vi";
1966
+ const result = process.platform === "win32" ? spawnSync3(editor, [file], { stdio: "inherit", shell: true }) : spawnSync3("sh", ["-c", `${editor} "$1"`, "sh", file], { stdio: "inherit" });
1967
+ if (result.error || result.status !== 0)
1968
+ throw new CliError(`Editor "${editor}" ${result.error ? `failed to start (${result.error.message})` : `exited with code ${result.status}`}; nothing was saved.`);
1969
+ }
1970
+
1042
1971
  // src/cli.ts
1972
+ var platformOption = (description) => new Option("--platform <platform>", description).choices(["ios", "android"]);
1973
+ var scopeOption = () => new Option("--scope <scope>", "account defaults or connected repository override").choices(["account", "repo"]).makeOptionMandatory();
1974
+ var collect = (value, previous = []) => [...previous, value];
1043
1975
  var program = new Command().enablePositionalOptions();
1044
1976
  program.name("simbox").description("On-demand iOS simulators & Android emulators on your own GitHub Actions minutes.").version(getVersion());
1045
1977
  program.command("login").description("Authenticate via device flow").action(() => run(cmdLogin()));
@@ -1047,26 +1979,23 @@ program.command("init").description("Open onboarding and wait until a repo is co
1047
1979
  requireToken();
1048
1980
  return run(cmdInit());
1049
1981
  });
1050
- program.command("sim").description("Ensure a run and its tunnel are ready for device commands").option("--new", "force a fresh run even if one is live").addOption(new Option("--platform <platform>", "ios: macOS runner; android: Linux/KVM runner").choices([
1051
- "ios",
1052
- "android"
1053
- ])).option("--runner <label-or-json>", "override runner label or JSON label array; requires --platform").option("--foldable", "boot a foldable: iPhone Duo (needs Xcode 27.1+, e.g. --runner xcode-27) or a 7.6in Android foldable; requires --platform").option("--xcode <version>", "iOS: select this Xcode (e.g. 27.1) before the device daemon starts").option("--json", "print {tunnel_url, daemon_token, run_id, expires_at} as JSON only").action((opts) => {
1982
+ program.command("sim").description("Ensure a run and its tunnel are ready for device commands").option("--new", "replace this platform's run with a fresh one").addOption(platformOption("ios: macOS runner; android: Linux/KVM runner (omitted: reuse the only active run, else iOS)")).option("--runner <label-or-json>", "override runner label or JSON label array; requires --platform").option("--device <model>", 'simulator/emulator model, e.g. "iPhone 16 Pro" or pixel_8 (see `simbox devices list`); requires --platform').option("--os <version>", "iOS version (18.4, 18), Android API level (35), latest or oldest; requires --platform").option("--foldable", "boot a foldable: iPhone Duo (needs Xcode 27.1+, e.g. --runner xcode-27) or a 7.6in Android foldable; requires --platform").option("--xcode <version>", "iOS: select this Xcode (e.g. 27.1) before the device daemon starts").option("--json", "print {tunnel_url, daemon_token, run_id, expires_at, platform, booted_device} as JSON only").action((opts) => {
1054
1983
  requireToken();
1055
1984
  return run(cmdSim(opts));
1056
1985
  });
1057
- program.command("exec").description("Run agent-device with fresh tunnel credentials and a cold-boot startup budget").argument("[args...]", "agent-device command and arguments").allowUnknownOption().allowExcessArguments().passThroughOptions().action((args) => {
1986
+ program.command("exec").description("Run agent-device with fresh tunnel credentials and a cold-boot startup budget; --platform picks the run when both are active").argument("[args...]", "agent-device command and arguments").allowUnknownOption().allowExcessArguments().passThroughOptions().action((args) => {
1058
1987
  requireToken();
1059
1988
  return run(cmdExec(args));
1060
1989
  });
1061
- program.command("ps").description("Show the current run").action(() => {
1990
+ program.command("ps").description("Show active runs (at most one per platform)").option("--json", "print {runs} as JSON").action((opts) => {
1062
1991
  requireToken();
1063
- return run(cmdPs());
1992
+ return run(cmdPs(opts));
1064
1993
  });
1065
- program.command("stop").description("Cancel the active run").action(() => {
1994
+ program.command("stop").description("Stop the active run (--platform picks one when both are active)").addOption(platformOption("stop this platform's run")).option("--all", "stop every active run").action((opts) => {
1066
1995
  requireToken();
1067
- return run(cmdStop());
1996
+ return run(cmdStop(opts));
1068
1997
  });
1069
- program.command("repair").description("Re-commit the workflow, rotate the repo secret, re-verify the installation").action(() => {
1998
+ program.command("repair").description("Re-commit the workflow, re-verify the installation, and rotate the repo secret when no run is active").action(() => {
1070
1999
  requireToken();
1071
2000
  return run(cmdRepair());
1072
2001
  });
@@ -1085,7 +2014,7 @@ for (const command of ["set", "reset"]) {
1085
2014
  const cmd = runners2.command(command).addArgument(new Argument("<platform>").choices(["ios", "android"]));
1086
2015
  if (command === "set")
1087
2016
  cmd.argument("<label-or-json>", "runner label or JSON label array");
1088
- cmd.addOption(new Option("--scope <scope>", "account defaults or connected repository override").choices(["account", "repo"]).makeOptionMandatory()).option("--json", "print settings as JSON");
2017
+ cmd.addOption(scopeOption()).option("--json", "print settings as JSON");
1089
2018
  if (command === "set")
1090
2019
  cmd.action((platform2, input, opts) => {
1091
2020
  requireToken();
@@ -1097,6 +2026,44 @@ for (const command of ["set", "reset"]) {
1097
2026
  return run(cmdRunnersSet(platform2, null, opts.scope, opts.json));
1098
2027
  });
1099
2028
  }
2029
+ var devices = program.command("devices").description("Device models and OS versions a runner offers, and your device defaults");
2030
+ devices.command("list").description("Show the last catalog a runner reported (models, OS versions, Xcode)").addOption(platformOption("only this platform")).option("--runner <label-or-json>", "a runner other than the effective one; requires --platform").option("--json", "print {ios?, android?} inventory responses as JSON").action((opts) => {
2031
+ requireToken();
2032
+ return run(cmdDevicesList(opts));
2033
+ });
2034
+ devices.command("set").description("Set the default device model and/or OS for future runs").addArgument(new Argument("<platform>").choices(["ios", "android"])).option("--device <model>", 'model, e.g. "iPhone 16 Pro" or pixel_8').option("--os <version>", "iOS version, Android API level, latest or oldest").option("--foldable", "default to a foldable instead of a model").addOption(scopeOption()).option("--json", "print settings as JSON").action((platform2, opts) => {
2035
+ requireToken();
2036
+ return run(cmdDevicesSet(platform2, opts, opts.scope, opts.json));
2037
+ });
2038
+ devices.command("reset").description("Clear a device default so the scope inherits").addArgument(new Argument("<platform>").choices(["ios", "android"])).addOption(scopeOption()).option("--json", "print settings as JSON").action((platform2, opts) => {
2039
+ requireToken();
2040
+ return run(cmdDevicesSet(platform2, null, opts.scope, opts.json));
2041
+ });
2042
+ var workflow2 = program.command("workflow").description("Show and edit the steps of .github/workflows/simbox.yml");
2043
+ workflow2.command("show").description("List steps (prepare/start/serve are locked) and the committed file's status").option("--yaml", "print the rendered YAML").option("--json", "print {spec, yaml, status, repoState, prUrl} as JSON").action((opts) => {
2044
+ requireToken();
2045
+ return run(cmdWorkflowShow(opts));
2046
+ });
2047
+ workflow2.command("add").description("Add a step (default: before the locked `start` step, i.e. before devices boot)").requiredOption("--name <name>", "step name").option("--run <script>", "shell script to run").option("--uses <action>", "action to use, e.g. actions/checkout@v4").option("--with <key=value>", "action input (repeatable)", collect).option("--env <key=value>", "environment variable (repeatable)", collect).option("--if <expression>", "GitHub Actions condition").addOption(platformOption("only run this step for one platform")).option("--id <id>", "step id (default: derived from --name)").option("--before <id>", "insert before this step").option("--after <id>", "insert after this step").option("--json", "print the saved workflow as JSON").action((opts) => {
2048
+ requireToken();
2049
+ return run(cmdWorkflowAdd(opts));
2050
+ });
2051
+ workflow2.command("remove").description("Remove a custom step").argument("<id>", "step id").option("--json", "print the saved workflow as JSON").action((id, opts) => {
2052
+ requireToken();
2053
+ return run(cmdWorkflowRemove(id, opts));
2054
+ });
2055
+ workflow2.command("move").description("Move a custom step before or after another step").argument("<id>", "step id").option("--before <id>", "move before this step").option("--after <id>", "move after this step").option("--json", "print the saved workflow as JSON").action((id, opts) => {
2056
+ requireToken();
2057
+ return run(cmdWorkflowMove(id, opts));
2058
+ });
2059
+ workflow2.command("edit").description("Edit the steps as JSON in $VISUAL/$EDITOR, then save").option("--json", "print the saved workflow as JSON").action((opts) => {
2060
+ requireToken();
2061
+ return run(cmdWorkflowEdit(opts));
2062
+ });
2063
+ workflow2.command("apply").description("Save a workflow spec from a JSON file ({steps, permissions?})").argument("<file>", "JSON file").option("--json", "print the saved workflow as JSON").action((file, opts) => {
2064
+ requireToken();
2065
+ return run(cmdWorkflowApply(file, opts));
2066
+ });
1100
2067
  program.command("skills").description("Set up the small Simbox agent skill").command("install").description("Install via bunx skills; asks current project or global").option("--project", "install to the current project").option("--global", "install to your user account").option("--agent <agents...>", "target skills CLI agent IDs; otherwise let the installer ask").option("--yes", "accept installer confirmations; requires explicit scope and agents").option("--dry-run", "print the command without installing").action((opts) => run(cmdSkillsInstall(opts)));
1101
2068
  function getVersion() {
1102
2069
  try {
@@ -1112,13 +2079,13 @@ async function run(p) {
1112
2079
  await p;
1113
2080
  } catch (err) {
1114
2081
  const error = friendlyError(err);
1115
- process.stderr.write(`${pc4.red("✖")} ${error.message}
2082
+ process.stderr.write(`${pc6.red("✖")} ${error.message}
1116
2083
  `);
1117
2084
  process.exitCode = error.exitCode;
1118
2085
  }
1119
2086
  }
1120
2087
  program.parseAsync(process.argv).catch((err) => {
1121
- process.stderr.write(`${pc4.red("✖")} ${friendlyError(err).message}
2088
+ process.stderr.write(`${pc6.red("✖")} ${friendlyError(err).message}
1122
2089
  `);
1123
2090
  process.exitCode = 1;
1124
2091
  });