@touchtechclub/simbox 0.2.0 → 0.2.1

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 +139 -19
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
5
  import pc4 from "picocolors";
6
+ import { createRequire } from "node:module";
6
7
 
7
8
  // src/config.ts
8
9
  import { mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
@@ -223,32 +224,33 @@ function connectFlow(info, quiet = false) {
223
224
  const base = daemonBaseUrl(info.tunnelUrl);
224
225
  const bin = findAgentDevice();
225
226
  if (bin) {
226
- out.write(`${pc.green("▶")} Connecting via ${pc.bold("agent-device")}…
227
+ out.write(`${pc.green("▶")} Ready for ${pc.bold("agent-device")} via Simbox.
227
228
  `);
228
- const res = spawnSync(bin, ["connect", "proxy", "--daemon-base-url", base], {
229
- stdio: quiet ? ["inherit", "ignore", "inherit"] : "inherit",
230
- env: { ...process.env, AGENT_DEVICE_DAEMON_AUTH_TOKEN: info.daemonToken }
231
- });
232
- if (res.error || res.status !== 0) {
233
- out.write(pc.yellow(` agent-device connect failed — use the manual commands below.
234
- `));
235
- } else {
236
- out.write(`
237
- Next: ${pc.cyan("agent-device open <app> --platform ios")} ${pc.dim("(or --platform android)")}
238
-
239
- `);
240
- }
241
229
  } else {
242
230
  out.write(`${pc.yellow("!")} ${pc.bold("agent-device")} not found on PATH — install it, then:
243
231
  `);
244
232
  }
233
+ out.write(`
234
+ ${pc.cyan("simbox exec devices")}
235
+ `);
236
+ out.write(` ${pc.cyan('simbox exec open <app> --platform ios --device "<device from inventory>"')}
237
+ `);
238
+ out.write(` ${pc.cyan("simbox exec snapshot -i")}
239
+ `);
240
+ out.write(` Use --udid for iOS or --serial for Android when selecting a specific device.
241
+ `);
242
+ out.write(` exec supplies auth, refreshes tunnel URLs, and allows 5 minutes for cold startup.
243
+
244
+ `);
245
245
  out.write(pc.dim(` manual connect:
246
246
  `));
247
247
  out.write(` ${pc.cyan(`export AGENT_DEVICE_DAEMON_AUTH_TOKEN=${info.daemonToken}`)}
248
248
  `);
249
- out.write(` ${pc.cyan(`agent-device connect proxy --daemon-base-url ${base}`)}
249
+ out.write(` ${pc.cyan(`export AGENT_DEVICE_DAEMON_BASE_URL=${base}`)}
250
+ `);
251
+ out.write(` ${pc.cyan(`agent-device open <app> --platform ios --udid <udid> --session simbox-${info.runId} --timeout 300000`)}
250
252
  `);
251
- out.write(` ${pc.cyan("agent-device open <app> --platform ios")}
253
+ out.write(` Raw agent-device commands need these exports again if the tunnel changes.
252
254
  `);
253
255
  }
254
256
 
@@ -356,6 +358,110 @@ class Spinner {
356
358
  }
357
359
  }
358
360
 
361
+ // src/remote.ts
362
+ import { spawn } from "node:child_process";
363
+ import { join as join2 } from "node:path";
364
+ var STARTUP_TIMEOUT_MS = 300000;
365
+ async function waitForRemote(initial, options = {}) {
366
+ const probe = options.probe ?? probeRemote;
367
+ const refresh = options.refresh ?? refreshRemote;
368
+ const deadline = Date.now() + (options.timeoutMs ?? 90000);
369
+ let info = initial;
370
+ do {
371
+ if (await probe(info))
372
+ return info;
373
+ const next = await refresh();
374
+ if (!next || next.runId !== initial.runId) {
375
+ throw new CliError("The run ended or was replaced. Run `simbox sim` to connect again.");
376
+ }
377
+ info = next;
378
+ await (options.pause?.() ?? sleep(1000));
379
+ } while (Date.now() < deadline);
380
+ throw new CliError("The run is live but its tunnel/daemon is not ready. Retry `simbox sim` or inspect the Actions logs. No device command was sent.");
381
+ }
382
+ async function probeRemote(info) {
383
+ try {
384
+ const res = await fetch(`${daemonBaseUrl(info.tunnelUrl)}/health`, {
385
+ signal: AbortSignal.timeout(15000)
386
+ });
387
+ if (!res.ok)
388
+ return false;
389
+ const body = await res.json();
390
+ return body.ok === true && body.upstream?.ok !== false;
391
+ } catch {
392
+ return false;
393
+ }
394
+ }
395
+ async function refreshRemote() {
396
+ const run = await currentRun();
397
+ if (run?.state !== "live" || !run.tunnelUrl || !run.daemonToken)
398
+ return null;
399
+ return {
400
+ runId: run.id,
401
+ tunnelUrl: run.tunnelUrl,
402
+ daemonToken: run.daemonToken,
403
+ expiresAt: run.expiresAt
404
+ };
405
+ }
406
+ function remoteArgs(args, info, stateDir) {
407
+ const separator = args.indexOf("--");
408
+ const result = separator < 0 ? [...args] : args.slice(0, separator);
409
+ const tail = separator < 0 ? [] : args.slice(separator);
410
+ if (["open", "boot", "prepare"].includes(args[0] ?? "") && !hasFlag(args, "--timeout")) {
411
+ result.push("--timeout", String(STARTUP_TIMEOUT_MS));
412
+ }
413
+ if (!hasFlag(args, "--session"))
414
+ result.push("--session", `simbox-${info.runId}`);
415
+ result.push("--state-dir", stateDir, "--daemon-base-url", daemonBaseUrl(info.tunnelUrl), "--daemon-transport", "http");
416
+ return [...result, ...tail];
417
+ }
418
+ function hasFlag(args, flag) {
419
+ const end = args.indexOf("--");
420
+ return (end < 0 ? args : args.slice(0, end)).some((arg) => arg === flag || arg.startsWith(`${flag}=`));
421
+ }
422
+ async function cmdExec(args) {
423
+ if (!args.length)
424
+ throw new CliError("Usage: simbox exec <agent-device command> [arguments]");
425
+ const reserved = [
426
+ "--daemon-base-url",
427
+ "--daemon-auth-token",
428
+ "--daemon-transport",
429
+ "--remote-config",
430
+ "--state-dir"
431
+ ];
432
+ if (reserved.some((flag) => hasFlag(args, flag))) {
433
+ throw new CliError("Simbox manages the remote URL, credentials and state directory for `exec`.");
434
+ }
435
+ if (["connect", "disconnect", "proxy", "auth"].includes(args[0])) {
436
+ throw new CliError("Use device commands with `simbox exec`, not connection/auth commands.");
437
+ }
438
+ const bin = findAgentDevice();
439
+ if (!bin)
440
+ throw new CliError("Install agent-device first: npm i -g agent-device@0.21.20");
441
+ const initial = await refreshRemote();
442
+ if (!initial)
443
+ throw new CliError("No live run. Start one with `simbox sim`.");
444
+ const info = await waitForRemote(initial);
445
+ const env = {
446
+ ...process.env,
447
+ AGENT_DEVICE_DAEMON_AUTH_TOKEN: info.daemonToken
448
+ };
449
+ delete env.AGENT_DEVICE_REMOTE_CONFIG;
450
+ delete env.AGENT_DEVICE_DAEMON_BASE_URL;
451
+ const code = await new Promise((resolve, reject) => {
452
+ const child = spawn(bin, remoteArgs(args, info, join2(configDir(), "agent-device")), {
453
+ stdio: "inherit",
454
+ env
455
+ });
456
+ child.on("error", reject);
457
+ child.on("exit", (status) => resolve(status ?? 1));
458
+ });
459
+ if (code !== 0) {
460
+ 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");
461
+ }
462
+ process.exitCode = code;
463
+ }
464
+
359
465
  // src/commands.ts
360
466
  async function cmdLogin() {
361
467
  const flow = await startDeviceFlow();
@@ -484,6 +590,7 @@ async function cmdSim(opts) {
484
590
  expiresAt: live.run.expiresAt
485
591
  };
486
592
  }
593
+ info = await waitForRemote(info);
487
594
  if (json) {
488
595
  const payload = {
489
596
  tunnel_url: info.tunnelUrl,
@@ -545,17 +652,21 @@ async function cmdLogout() {
545
652
  }
546
653
 
547
654
  // src/cli.ts
548
- var program = new Command;
549
- program.name("simbox").description("On-demand iOS simulators & Android emulators on your own GitHub Actions minutes.").version("0.1.0");
655
+ var program = new Command().enablePositionalOptions();
656
+ program.name("simbox").description("On-demand iOS simulators & Android emulators on your own GitHub Actions minutes.").version(getVersion());
550
657
  program.command("login").description("Authenticate via device flow").action(() => run(cmdLogin()));
551
658
  program.command("init").description("Open onboarding and wait until a repo is connected").action(() => {
552
659
  requireToken();
553
660
  return run(cmdInit());
554
661
  });
555
- program.command("sim").description("Ensure a run is live and connect agent-device to it").option("--new", "force a fresh run even if one is live").option("--json", "print {tunnel_url, daemon_token, run_id, expires_at} as JSON only").action((opts) => {
662
+ 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").option("--json", "print {tunnel_url, daemon_token, run_id, expires_at} as JSON only").action((opts) => {
556
663
  requireToken();
557
664
  return run(cmdSim(opts));
558
665
  });
666
+ 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) => {
667
+ requireToken();
668
+ return run(cmdExec(args));
669
+ });
559
670
  program.command("ps").description("Show the current run").action(() => {
560
671
  requireToken();
561
672
  return run(cmdPs());
@@ -569,6 +680,15 @@ program.command("repair").description("Re-commit the workflow, rotate the repo s
569
680
  return run(cmdRepair());
570
681
  });
571
682
  program.command("logout").description("Delete the saved auth token").action(() => run(cmdLogout()));
683
+ function getVersion() {
684
+ try {
685
+ const require2 = createRequire(import.meta.url);
686
+ const pkg = require2("../package.json");
687
+ return pkg.version ?? "0.0.0";
688
+ } catch {
689
+ return "0.0.0";
690
+ }
691
+ }
572
692
  async function run(p) {
573
693
  try {
574
694
  await p;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@touchtechclub/simbox",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "On-demand iOS simulators & Android emulators on your own GitHub Actions minutes.",
5
5
  "repository": {
6
6
  "type": "git",