@touchtechclub/simbox 0.2.11 → 0.4.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 +311 -8
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { Command, Option } from "commander";
4
+ import { Argument, Command, Option } from "commander";
5
5
  import pc4 from "picocolors";
6
6
  import { createRequire } from "node:module";
7
7
 
@@ -12,6 +12,77 @@ 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
+ // ../../packages/shared/src/runners.ts
16
+ var DEFAULT_RUNNERS = {
17
+ ios: { labels: ["macos-latest"] },
18
+ android: { labels: ["ubuntu-latest"] }
19
+ };
20
+ var RUNNER_PRESETS = [
21
+ {
22
+ name: "GitHub macOS (ARM64)",
23
+ platform: "ios",
24
+ provider: "github",
25
+ runner: DEFAULT_RUNNERS.ios
26
+ },
27
+ {
28
+ name: "GitHub Ubuntu (x64/KVM)",
29
+ platform: "android",
30
+ provider: "github",
31
+ runner: DEFAULT_RUNNERS.android
32
+ },
33
+ ...[2, 4, 8].map((cpu) => ({
34
+ name: `Blacksmith Ubuntu · ${cpu} vCPU`,
35
+ platform: "android",
36
+ provider: "blacksmith",
37
+ runner: { labels: [`blacksmith-${cpu}vcpu-ubuntu-2404`] }
38
+ })),
39
+ ...[6, 12].map((cpu) => ({
40
+ name: `Blacksmith macOS · ${cpu} vCPU`,
41
+ platform: "ios",
42
+ provider: "blacksmith",
43
+ runner: { labels: [`blacksmith-${cpu}vcpu-macos-latest`] }
44
+ }))
45
+ ];
46
+ function validateRunner(value, platform) {
47
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => key !== "labels")) {
48
+ throw new Error('Runner must be {labels: ["runner-label"]}.');
49
+ }
50
+ const labels = value.labels;
51
+ if (!Array.isArray(labels) || labels.length < 1 || labels.length > 8 || labels.some((label) => typeof label !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_. -]{0,99}$/.test(label) || label !== label.trim())) {
52
+ throw new Error("Use 1–8 runner labels (1–100 letters, numbers, spaces, dots, underscores or hyphens each).");
53
+ }
54
+ if (new Set(labels.map((label) => label.toLowerCase())).size !== labels.length)
55
+ throw new Error("Runner labels must be unique (case-insensitive).");
56
+ for (const label of labels) {
57
+ const lower = label.toLowerCase();
58
+ if (lower.includes("windows") || lower === "ubuntu-slim")
59
+ throw new Error("Windows and container-only ubuntu-slim runners are unsupported.");
60
+ if (platform === "android" && (/macos|darwin/.test(lower) || /(?:^|[-_ ])(?:arm|arm64|aarch64)(?:$|[-_ ])/.test(lower))) {
61
+ throw new Error("Android requires a Linux x64 runner with KVM, not macOS or ARM.");
62
+ }
63
+ if (platform === "ios" && (/ubuntu|linux/.test(lower) || /intel|x64|x86_64/.test(lower))) {
64
+ throw new Error("iOS requires a macOS ARM64 runner with Xcode.");
65
+ }
66
+ }
67
+ return { labels: [...labels] };
68
+ }
69
+ function parseRunnerInput(input, platform) {
70
+ const text = input.trim();
71
+ let labels = [text];
72
+ if (text.startsWith("[")) {
73
+ try {
74
+ labels = JSON.parse(text);
75
+ } catch {
76
+ throw new Error("Custom runner label list must be valid JSON.");
77
+ }
78
+ }
79
+ return validateRunner({ labels }, platform);
80
+ }
81
+ function formatRunner(runner) {
82
+ return runner ? runner.labels.join(" + ") : "unknown (legacy workflow)";
83
+ }
84
+ // ../../packages/shared/src/preview.ts
85
+ var PREVIEW_SESSION_MS = 5 * 60000;
15
86
  // src/config.ts
16
87
  var DEFAULT_WEB_URL = "https://simbox.touchtech.club";
17
88
  function stripTrailingSlash(url) {
@@ -163,7 +234,13 @@ var repoStatus = () => api("/v1/repo/status");
163
234
  var repairRepo = () => api("/v1/repo/repair", {
164
235
  method: "POST"
165
236
  });
166
- var ensureRun = (fresh, platform2) => api("/v1/runs/ensure", { method: "POST", body: { new: fresh, platform: platform2 } });
237
+ var ensureRun = (fresh, platform2, runner) => api("/v1/runs/ensure", {
238
+ method: "POST",
239
+ body: { new: fresh, platform: platform2, runner }
240
+ });
241
+ var runnerSettings = () => api("/v1/runners");
242
+ var runnerDiagnostics = () => api("/v1/runners/doctor");
243
+ var updateRunners = (scope, patch) => api(`/v1/runners/${scope}`, { method: "POST", body: patch });
167
244
  async function currentRun() {
168
245
  try {
169
246
  const res = await api("/v1/runs/current");
@@ -361,7 +438,57 @@ class Spinner {
361
438
 
362
439
  // src/remote.ts
363
440
  import { spawn } from "node:child_process";
441
+ import { join as join3 } from "node:path";
442
+
443
+ // src/device-registration.ts
444
+ import { randomUUID } from "node:crypto";
445
+ import { existsSync, mkdirSync as mkdirSync2, readdirSync, statSync, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
446
+ import { homedir as homedir2 } from "node:os";
364
447
  import { join as join2 } from "node:path";
448
+ var MAX_AGE_MS = 5 * 60000;
449
+ function registerDevice(info, options = {}) {
450
+ if (!process.env.T3CODE_HOME && !process.env.SIMBOX_DEVICE_REGISTRATION_DIR && ![".s5code", ".t3"].some((home) => existsSync(join2(homedir2(), home, "userdata", "server-runtime.json"))))
451
+ return;
452
+ try {
453
+ const directory = process.env.SIMBOX_DEVICE_REGISTRATION_DIR ?? join2(configDir(), "device-registrations");
454
+ mkdirSync2(directory, { recursive: true, mode: 448 });
455
+ for (const file of readdirSync(directory)) {
456
+ if (!/^[a-f0-9-]{36}\.json$/.test(file))
457
+ continue;
458
+ const path = join2(directory, file);
459
+ if (Date.now() - statSync(path).mtimeMs > MAX_AGE_MS)
460
+ unlinkSync(path);
461
+ }
462
+ const args = options.args ?? [];
463
+ const flag = (name) => {
464
+ const end = args.indexOf("--");
465
+ const flags = end < 0 ? args : args.slice(0, end);
466
+ const index = flags.indexOf(name);
467
+ return index >= 0 ? flags[index + 1] : flags.find((arg) => arg.startsWith(`${name}=`))?.slice(name.length + 1);
468
+ };
469
+ const id = randomUUID();
470
+ writeFileSync2(join2(directory, `${id}.json`), JSON.stringify({
471
+ version: 1,
472
+ createdAt: Date.now(),
473
+ ...info,
474
+ platform: flag("--platform") ?? options.platform,
475
+ deviceId: flag("--udid") ?? flag("--serial"),
476
+ deviceName: flag("--device"),
477
+ agentSession: flag("--session"),
478
+ stop: options.stop === true,
479
+ autoBoot: !options.args,
480
+ apiUrl: apiBaseUrl(),
481
+ userToken: loadToken()
482
+ }), { mode: 384, flag: "wx" });
483
+ process.stderr.write(`[simbox-device:${id}]
484
+ `);
485
+ } catch {
486
+ process.stderr.write(`Could not notify S5 Code about this device; Simbox remains connected.
487
+ `);
488
+ }
489
+ }
490
+
491
+ // src/remote.ts
365
492
  var STARTUP_TIMEOUT_MS = 300000;
366
493
  async function waitForAndroid(runId, options = {}) {
367
494
  const deadline = Date.now() + (options.timeoutMs ?? 12 * 60000);
@@ -469,13 +596,15 @@ async function cmdExec(args) {
469
596
  delete env.AGENT_DEVICE_REMOTE_CONFIG;
470
597
  delete env.AGENT_DEVICE_DAEMON_BASE_URL;
471
598
  const code = await new Promise((resolve, reject) => {
472
- const child = spawn(bin, remoteArgs(args, info, join2(configDir(), "agent-device")), {
599
+ const child = spawn(bin, remoteArgs(args, info, join3(configDir(), "agent-device")), {
473
600
  stdio: "inherit",
474
601
  env
475
602
  });
476
603
  child.on("error", reject);
477
604
  child.on("exit", (status) => resolve(status ?? 1));
478
605
  });
606
+ if (code === 0 && !args.some((arg) => ["help", "--help", "-h", "--version", "version"].includes(arg)))
607
+ registerDevice(info, { args });
479
608
  if (code !== 0) {
480
609
  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");
481
610
  }
@@ -560,7 +689,7 @@ function printRepoConnected(repo) {
560
689
  console.log(`Your default branch is protected — merge ${pc3.underline(repo.prUrl)} to activate Simbox.`);
561
690
  }
562
691
  if (repo.private) {
563
- console.log(pc3.yellow("Note: private repos are billed at the 10× macOS minute multiplier by GitHub."));
692
+ console.log(pc3.yellow("Note: private repositories may consume paid runner minutes. Review GitHub or your selected provider's billing."));
564
693
  }
565
694
  console.log(`Run ${pc3.cyan("simbox sim")} to boot a simulator.`);
566
695
  }
@@ -569,7 +698,10 @@ var SIM_POLL_MS = 3000;
569
698
  async function cmdSim(opts) {
570
699
  const json = opts.json === true;
571
700
  const out = json ? process.stderr : process.stdout;
572
- const first = await ensureRun(opts.new === true, opts.platform);
701
+ if (opts.runner !== undefined && !opts.platform)
702
+ throw new CliError("Use --platform ios or android with --runner.");
703
+ const runner = opts.runner === undefined ? undefined : parseRunnerInput(opts.runner, opts.platform);
704
+ const first = await ensureRun(opts.new === true, opts.platform, runner);
573
705
  let info;
574
706
  if (first.state === "live") {
575
707
  info = {
@@ -619,6 +751,7 @@ async function cmdSim(opts) {
619
751
  info = await waitForRemote(info);
620
752
  if (opts.platform === "android")
621
753
  await waitForAndroid(info.runId);
754
+ registerDevice(info, { platform: opts.platform });
622
755
  if (json) {
623
756
  const payload = {
624
757
  tunnel_url: info.tunnelUrl,
@@ -645,6 +778,7 @@ async function cmdPs() {
645
778
  console.log(`${pc3.bold("run")} ${run.id}`);
646
779
  console.log(` state: ${stateColor(run.state)}`);
647
780
  console.log(` platform: ${run.platform}`);
781
+ console.log(` runner: ${formatRunner(run.runner)}`);
648
782
  console.log(` gh_run_id: ${run.ghRunId ?? pc3.dim("pending")}`);
649
783
  console.log(` active_devices: ${run.activeDevices}`);
650
784
  console.log(` android_ready: ${run.androidReady ? "yes" : "no"}`);
@@ -659,6 +793,13 @@ async function cmdStop() {
659
793
  return;
660
794
  }
661
795
  await stopRun(run.id);
796
+ if (run.tunnelUrl && run.daemonToken)
797
+ registerDevice({
798
+ runId: run.id,
799
+ tunnelUrl: run.tunnelUrl,
800
+ daemonToken: run.daemonToken,
801
+ expiresAt: run.expiresAt
802
+ }, { stop: true });
662
803
  console.log(`${pc3.green("✔")} Stop requested for run ${pc3.bold(run.id)}.`);
663
804
  }
664
805
  async function cmdRepair() {
@@ -680,6 +821,140 @@ async function cmdLogout() {
680
821
  }
681
822
  }
682
823
 
824
+ // src/runners.ts
825
+ import { spawnSync as spawnSync2 } from "node:child_process";
826
+ function printRunnerSettings(settings, json = false) {
827
+ if (json) {
828
+ console.log(JSON.stringify(settings));
829
+ return;
830
+ }
831
+ console.log(`Repository: ${settings.repoFullName ?? "not connected"}`);
832
+ for (const platform2 of ["ios", "android"]) {
833
+ console.log(`${platform2}: ${formatRunner(settings.effective[platform2].runner)} (${settings.effective[platform2].source})`);
834
+ console.log(` account: ${settings.account[platform2] ? formatRunner(settings.account[platform2]) : "GitHub default"}`);
835
+ console.log(` repository: ${settings.repository?.[platform2] ? formatRunner(settings.repository[platform2]) : "inherit account"}`);
836
+ }
837
+ console.log("Settings apply to future runs only. Run `simbox repair` once to upgrade older workflows.");
838
+ console.log("Blacksmith requires its GitHub App on the repository and bills separately. Custom runners need Linux x64/KVM or macOS ARM64/Xcode.");
839
+ }
840
+ async function cmdRunnersShow(json = false) {
841
+ printRunnerSettings(await runnerSettings(), json);
842
+ }
843
+ function cmdRunnerPresets() {
844
+ for (const preset of RUNNER_PRESETS)
845
+ console.log(`${preset.platform.padEnd(7)} ${preset.runner.labels[0]} — ${preset.name}`);
846
+ }
847
+ async function cmdRunnersSet(platform2, input, scope, json = false) {
848
+ const runner = input === null ? null : parseRunnerInput(input, platform2);
849
+ printRunnerSettings(await updateRunners(scope, { [platform2]: runner }), json);
850
+ }
851
+ async function cmdDoctor(json = false) {
852
+ const diagnostics = await runnerDiagnostics();
853
+ const tool = (name, required) => {
854
+ const result = spawnSync2(name, ["--version"], {
855
+ encoding: "utf8",
856
+ timeout: 1e4,
857
+ shell: false
858
+ });
859
+ return {
860
+ name,
861
+ required,
862
+ ok: !result.error && result.status === 0,
863
+ message: result.error ? `Missing or failed ${name}; install it locally.` : (result.stdout || result.stderr).trim().slice(0, 200)
864
+ };
865
+ };
866
+ const local = [tool("agent-device", true), tool("bunx", false)];
867
+ if (json)
868
+ console.log(JSON.stringify({ ...diagnostics, local }));
869
+ else {
870
+ for (const check of [...local, ...diagnostics.checks])
871
+ console.log(`${check.ok ? "PASS" : ("required" in check) && !check.required ? "WARN" : "FAIL"} ${check.name}: ${check.message}`);
872
+ printRunnerSettings(diagnostics.settings);
873
+ console.log("No job was started. Actual KVM/Xcode capability is checked on the runner, not this machine.");
874
+ }
875
+ if (diagnostics.checks.some((check) => !check.ok) || local.some((check) => check.required && !check.ok))
876
+ process.exitCode = 1;
877
+ }
878
+
879
+ // src/skills.ts
880
+ import { spawn as spawn2 } from "node:child_process";
881
+ import { createInterface } from "node:readline/promises";
882
+ function skillInstallArgs(opts) {
883
+ if (opts.project && opts.global)
884
+ throw new CliError("Choose either --project or --global, not both.");
885
+ if (!opts.project && !opts.global)
886
+ throw new CliError("Choose --project or --global (scope is never inferred).");
887
+ if (opts.agent?.some((agent) => !/^[a-z][a-z0-9-]*$/.test(agent) && agent !== "*"))
888
+ throw new CliError("Invalid agent ID. Use skills CLI agent names, e.g. claude-code or codex.");
889
+ if (opts.yes && !opts.agent?.length)
890
+ throw new CliError("Non-interactive installs require --agent; Simbox will not choose agents for you.");
891
+ return [
892
+ "skills",
893
+ "add",
894
+ "TouchTechClub/simbox",
895
+ "--skill",
896
+ "simbox",
897
+ ...opts.global ? ["--global"] : [],
898
+ ...opts.agent?.length ? ["--agent", ...opts.agent] : [],
899
+ ...opts.yes ? ["--yes"] : []
900
+ ];
901
+ }
902
+ async function selectSkillScope(opts, interactive, ask) {
903
+ if (opts.project && opts.global)
904
+ throw new CliError("Choose either --project or --global, not both.");
905
+ if (opts.project || opts.global)
906
+ return opts;
907
+ if (!interactive || opts.yes)
908
+ throw new CliError("Specify --project or --global. For automation also provide --agent <id> --yes.");
909
+ while (true) {
910
+ const answer = (await ask(`Install the Simbox skill where?
911
+ 1. Current project (${process.cwd()})
912
+ 2. Global (your user account)
913
+ Choose 1 or 2 (q to cancel): `)).trim().toLowerCase();
914
+ if (answer === "1" || answer === "project")
915
+ return { ...opts, project: true };
916
+ if (answer === "2" || answer === "global")
917
+ return { ...opts, global: true };
918
+ if (answer === "q" || answer === "cancel")
919
+ throw new CliError("Skill installation canceled.");
920
+ }
921
+ }
922
+ async function cmdSkillsInstall(opts) {
923
+ let selected = opts;
924
+ if (!opts.project && !opts.global) {
925
+ if (!process.stdin.isTTY || !process.stdout.isTTY || opts.yes) {
926
+ selected = await selectSkillScope(opts, false, async () => "");
927
+ } else {
928
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
929
+ try {
930
+ selected = await selectSkillScope(opts, true, (q) => prompt.question(q));
931
+ } finally {
932
+ prompt.close();
933
+ }
934
+ }
935
+ }
936
+ const args = skillInstallArgs(selected);
937
+ if (!opts.dryRun && !opts.yes && (!process.stdin.isTTY || !process.stdout.isTTY)) {
938
+ throw new CliError("Interactive skills installation needs a terminal. Use --project/--global --agent <id> --yes for automation.");
939
+ }
940
+ console.log(`Scope: ${selected.global ? "global" : `current project (${process.cwd()})`}`);
941
+ console.log(`Command: bunx ${args.map((arg) => arg === "*" ? "'*'" : arg).join(" ")}`);
942
+ console.log("This runs the third-party skills CLI. It handles agent selection and install confirmations; --yes accepts its installation/overwrite prompts.");
943
+ console.log("Set DISABLE_TELEMETRY=1 to opt out of skills CLI telemetry.");
944
+ if (opts.dryRun)
945
+ return;
946
+ await new Promise((resolve, reject) => {
947
+ const child = spawn2("bunx", args, {
948
+ cwd: process.cwd(),
949
+ env: process.env,
950
+ stdio: "inherit",
951
+ shell: false
952
+ });
953
+ child.once("error", (err) => reject(new CliError(`Could not start bunx. Install Bun or run the printed command manually. (${err.message})`)));
954
+ child.once("exit", (code, signal) => code === 0 ? resolve() : reject(new CliError(`Skills installer ${signal ? `stopped by ${signal}` : `exited with code ${code}`}.`, code ?? 1)));
955
+ });
956
+ }
957
+
683
958
  // src/cli.ts
684
959
  var program = new Command().enablePositionalOptions();
685
960
  program.name("simbox").description("On-demand iOS simulators & Android emulators on your own GitHub Actions minutes.").version(getVersion());
@@ -691,7 +966,7 @@ program.command("init").description("Open onboarding and wait until a repo is co
691
966
  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([
692
967
  "ios",
693
968
  "android"
694
- ])).option("--json", "print {tunnel_url, daemon_token, run_id, expires_at} as JSON only").action((opts) => {
969
+ ])).option("--runner <label-or-json>", "override runner label or JSON label array; requires --platform").option("--json", "print {tunnel_url, daemon_token, run_id, expires_at} as JSON only").action((opts) => {
695
970
  requireToken();
696
971
  return run(cmdSim(opts));
697
972
  });
@@ -712,6 +987,33 @@ program.command("repair").description("Re-commit the workflow, rotate the repo s
712
987
  return run(cmdRepair());
713
988
  });
714
989
  program.command("logout").description("Delete the saved auth token").action(() => run(cmdLogout()));
990
+ var runners2 = program.command("runners").description("Account runner defaults and connected-repository overrides");
991
+ program.command("doctor").description("Check tools, runner settings and workflow without starting a job").option("--json", "print diagnostics as JSON").action((opts) => {
992
+ requireToken();
993
+ return run(cmdDoctor(opts.json));
994
+ });
995
+ runners2.command("show").option("--json", "print settings as JSON").action((opts) => {
996
+ requireToken();
997
+ return run(cmdRunnersShow(opts.json));
998
+ });
999
+ runners2.command("presets").description("List GitHub and Blacksmith presets").action(cmdRunnerPresets);
1000
+ for (const command of ["set", "reset"]) {
1001
+ const cmd = runners2.command(command).addArgument(new Argument("<platform>").choices(["ios", "android"]));
1002
+ if (command === "set")
1003
+ cmd.argument("<label-or-json>", "runner label or JSON label array");
1004
+ cmd.addOption(new Option("--scope <scope>", "account defaults or connected repository override").choices(["account", "repo"]).makeOptionMandatory()).option("--json", "print settings as JSON");
1005
+ if (command === "set")
1006
+ cmd.action((platform2, input, opts) => {
1007
+ requireToken();
1008
+ return run(cmdRunnersSet(platform2, input, opts.scope, opts.json));
1009
+ });
1010
+ else
1011
+ cmd.action((platform2, opts) => {
1012
+ requireToken();
1013
+ return run(cmdRunnersSet(platform2, null, opts.scope, opts.json));
1014
+ });
1015
+ }
1016
+ 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)));
715
1017
  function getVersion() {
716
1018
  try {
717
1019
  const require2 = createRequire(import.meta.url);
@@ -725,9 +1027,10 @@ async function run(p) {
725
1027
  try {
726
1028
  await p;
727
1029
  } catch (err) {
728
- process.stderr.write(`${pc4.red("✖")} ${friendlyError(err).message}
1030
+ const error = friendlyError(err);
1031
+ process.stderr.write(`${pc4.red("✖")} ${error.message}
729
1032
  `);
730
- process.exitCode = 1;
1033
+ process.exitCode = error.exitCode;
731
1034
  }
732
1035
  }
733
1036
  program.parseAsync(process.argv).catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@touchtechclub/simbox",
3
- "version": "0.2.11",
3
+ "version": "0.4.0",
4
4
  "description": "On-demand iOS simulators & Android emulators on your own GitHub Actions minutes.",
5
5
  "repository": {
6
6
  "type": "git",