@touchtechclub/simbox 0.2.10 → 0.3.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 +250 -7
  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");
@@ -560,7 +637,7 @@ function printRepoConnected(repo) {
560
637
  console.log(`Your default branch is protected — merge ${pc3.underline(repo.prUrl)} to activate Simbox.`);
561
638
  }
562
639
  if (repo.private) {
563
- console.log(pc3.yellow("Note: private repos are billed at the 10× macOS minute multiplier by GitHub."));
640
+ console.log(pc3.yellow("Note: private repositories may consume paid runner minutes. Review GitHub or your selected provider's billing."));
564
641
  }
565
642
  console.log(`Run ${pc3.cyan("simbox sim")} to boot a simulator.`);
566
643
  }
@@ -569,7 +646,10 @@ var SIM_POLL_MS = 3000;
569
646
  async function cmdSim(opts) {
570
647
  const json = opts.json === true;
571
648
  const out = json ? process.stderr : process.stdout;
572
- const first = await ensureRun(opts.new === true, opts.platform);
649
+ if (opts.runner !== undefined && !opts.platform)
650
+ throw new CliError("Use --platform ios or android with --runner.");
651
+ const runner = opts.runner === undefined ? undefined : parseRunnerInput(opts.runner, opts.platform);
652
+ const first = await ensureRun(opts.new === true, opts.platform, runner);
573
653
  let info;
574
654
  if (first.state === "live") {
575
655
  info = {
@@ -645,6 +725,7 @@ async function cmdPs() {
645
725
  console.log(`${pc3.bold("run")} ${run.id}`);
646
726
  console.log(` state: ${stateColor(run.state)}`);
647
727
  console.log(` platform: ${run.platform}`);
728
+ console.log(` runner: ${formatRunner(run.runner)}`);
648
729
  console.log(` gh_run_id: ${run.ghRunId ?? pc3.dim("pending")}`);
649
730
  console.log(` active_devices: ${run.activeDevices}`);
650
731
  console.log(` android_ready: ${run.androidReady ? "yes" : "no"}`);
@@ -680,6 +761,140 @@ async function cmdLogout() {
680
761
  }
681
762
  }
682
763
 
764
+ // src/runners.ts
765
+ import { spawnSync as spawnSync2 } from "node:child_process";
766
+ function printRunnerSettings(settings, json = false) {
767
+ if (json) {
768
+ console.log(JSON.stringify(settings));
769
+ return;
770
+ }
771
+ console.log(`Repository: ${settings.repoFullName ?? "not connected"}`);
772
+ for (const platform2 of ["ios", "android"]) {
773
+ console.log(`${platform2}: ${formatRunner(settings.effective[platform2].runner)} (${settings.effective[platform2].source})`);
774
+ console.log(` account: ${settings.account[platform2] ? formatRunner(settings.account[platform2]) : "GitHub default"}`);
775
+ console.log(` repository: ${settings.repository?.[platform2] ? formatRunner(settings.repository[platform2]) : "inherit account"}`);
776
+ }
777
+ console.log("Settings apply to future runs only. Run `simbox repair` once to upgrade older workflows.");
778
+ console.log("Blacksmith requires its GitHub App on the repository and bills separately. Custom runners need Linux x64/KVM or macOS ARM64/Xcode.");
779
+ }
780
+ async function cmdRunnersShow(json = false) {
781
+ printRunnerSettings(await runnerSettings(), json);
782
+ }
783
+ function cmdRunnerPresets() {
784
+ for (const preset of RUNNER_PRESETS)
785
+ console.log(`${preset.platform.padEnd(7)} ${preset.runner.labels[0]} — ${preset.name}`);
786
+ }
787
+ async function cmdRunnersSet(platform2, input, scope, json = false) {
788
+ const runner = input === null ? null : parseRunnerInput(input, platform2);
789
+ printRunnerSettings(await updateRunners(scope, { [platform2]: runner }), json);
790
+ }
791
+ async function cmdDoctor(json = false) {
792
+ const diagnostics = await runnerDiagnostics();
793
+ const tool = (name, required) => {
794
+ const result = spawnSync2(name, ["--version"], {
795
+ encoding: "utf8",
796
+ timeout: 1e4,
797
+ shell: false
798
+ });
799
+ return {
800
+ name,
801
+ required,
802
+ ok: !result.error && result.status === 0,
803
+ message: result.error ? `Missing or failed ${name}; install it locally.` : (result.stdout || result.stderr).trim().slice(0, 200)
804
+ };
805
+ };
806
+ const local = [tool("agent-device", true), tool("bunx", false)];
807
+ if (json)
808
+ console.log(JSON.stringify({ ...diagnostics, local }));
809
+ else {
810
+ for (const check of [...local, ...diagnostics.checks])
811
+ console.log(`${check.ok ? "PASS" : ("required" in check) && !check.required ? "WARN" : "FAIL"} ${check.name}: ${check.message}`);
812
+ printRunnerSettings(diagnostics.settings);
813
+ console.log("No job was started. Actual KVM/Xcode capability is checked on the runner, not this machine.");
814
+ }
815
+ if (diagnostics.checks.some((check) => !check.ok) || local.some((check) => check.required && !check.ok))
816
+ process.exitCode = 1;
817
+ }
818
+
819
+ // src/skills.ts
820
+ import { spawn as spawn2 } from "node:child_process";
821
+ import { createInterface } from "node:readline/promises";
822
+ function skillInstallArgs(opts) {
823
+ if (opts.project && opts.global)
824
+ throw new CliError("Choose either --project or --global, not both.");
825
+ if (!opts.project && !opts.global)
826
+ throw new CliError("Choose --project or --global (scope is never inferred).");
827
+ if (opts.agent?.some((agent) => !/^[a-z][a-z0-9-]*$/.test(agent) && agent !== "*"))
828
+ throw new CliError("Invalid agent ID. Use skills CLI agent names, e.g. claude-code or codex.");
829
+ if (opts.yes && !opts.agent?.length)
830
+ throw new CliError("Non-interactive installs require --agent; Simbox will not choose agents for you.");
831
+ return [
832
+ "skills",
833
+ "add",
834
+ "TouchTechClub/simbox",
835
+ "--skill",
836
+ "simbox",
837
+ ...opts.global ? ["--global"] : [],
838
+ ...opts.agent?.length ? ["--agent", ...opts.agent] : [],
839
+ ...opts.yes ? ["--yes"] : []
840
+ ];
841
+ }
842
+ async function selectSkillScope(opts, interactive, ask) {
843
+ if (opts.project && opts.global)
844
+ throw new CliError("Choose either --project or --global, not both.");
845
+ if (opts.project || opts.global)
846
+ return opts;
847
+ if (!interactive || opts.yes)
848
+ throw new CliError("Specify --project or --global. For automation also provide --agent <id> --yes.");
849
+ while (true) {
850
+ const answer = (await ask(`Install the Simbox skill where?
851
+ 1. Current project (${process.cwd()})
852
+ 2. Global (your user account)
853
+ Choose 1 or 2 (q to cancel): `)).trim().toLowerCase();
854
+ if (answer === "1" || answer === "project")
855
+ return { ...opts, project: true };
856
+ if (answer === "2" || answer === "global")
857
+ return { ...opts, global: true };
858
+ if (answer === "q" || answer === "cancel")
859
+ throw new CliError("Skill installation canceled.");
860
+ }
861
+ }
862
+ async function cmdSkillsInstall(opts) {
863
+ let selected = opts;
864
+ if (!opts.project && !opts.global) {
865
+ if (!process.stdin.isTTY || !process.stdout.isTTY || opts.yes) {
866
+ selected = await selectSkillScope(opts, false, async () => "");
867
+ } else {
868
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
869
+ try {
870
+ selected = await selectSkillScope(opts, true, (q) => prompt.question(q));
871
+ } finally {
872
+ prompt.close();
873
+ }
874
+ }
875
+ }
876
+ const args = skillInstallArgs(selected);
877
+ if (!opts.dryRun && !opts.yes && (!process.stdin.isTTY || !process.stdout.isTTY)) {
878
+ throw new CliError("Interactive skills installation needs a terminal. Use --project/--global --agent <id> --yes for automation.");
879
+ }
880
+ console.log(`Scope: ${selected.global ? "global" : `current project (${process.cwd()})`}`);
881
+ console.log(`Command: bunx ${args.map((arg) => arg === "*" ? "'*'" : arg).join(" ")}`);
882
+ console.log("This runs the third-party skills CLI. It handles agent selection and install confirmations; --yes accepts its installation/overwrite prompts.");
883
+ console.log("Set DISABLE_TELEMETRY=1 to opt out of skills CLI telemetry.");
884
+ if (opts.dryRun)
885
+ return;
886
+ await new Promise((resolve, reject) => {
887
+ const child = spawn2("bunx", args, {
888
+ cwd: process.cwd(),
889
+ env: process.env,
890
+ stdio: "inherit",
891
+ shell: false
892
+ });
893
+ child.once("error", (err) => reject(new CliError(`Could not start bunx. Install Bun or run the printed command manually. (${err.message})`)));
894
+ child.once("exit", (code, signal) => code === 0 ? resolve() : reject(new CliError(`Skills installer ${signal ? `stopped by ${signal}` : `exited with code ${code}`}.`, code ?? 1)));
895
+ });
896
+ }
897
+
683
898
  // src/cli.ts
684
899
  var program = new Command().enablePositionalOptions();
685
900
  program.name("simbox").description("On-demand iOS simulators & Android emulators on your own GitHub Actions minutes.").version(getVersion());
@@ -691,7 +906,7 @@ program.command("init").description("Open onboarding and wait until a repo is co
691
906
  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
907
  "ios",
693
908
  "android"
694
- ])).option("--json", "print {tunnel_url, daemon_token, run_id, expires_at} as JSON only").action((opts) => {
909
+ ])).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
910
  requireToken();
696
911
  return run(cmdSim(opts));
697
912
  });
@@ -712,6 +927,33 @@ program.command("repair").description("Re-commit the workflow, rotate the repo s
712
927
  return run(cmdRepair());
713
928
  });
714
929
  program.command("logout").description("Delete the saved auth token").action(() => run(cmdLogout()));
930
+ var runners2 = program.command("runners").description("Account runner defaults and connected-repository overrides");
931
+ program.command("doctor").description("Check tools, runner settings and workflow without starting a job").option("--json", "print diagnostics as JSON").action((opts) => {
932
+ requireToken();
933
+ return run(cmdDoctor(opts.json));
934
+ });
935
+ runners2.command("show").option("--json", "print settings as JSON").action((opts) => {
936
+ requireToken();
937
+ return run(cmdRunnersShow(opts.json));
938
+ });
939
+ runners2.command("presets").description("List GitHub and Blacksmith presets").action(cmdRunnerPresets);
940
+ for (const command of ["set", "reset"]) {
941
+ const cmd = runners2.command(command).addArgument(new Argument("<platform>").choices(["ios", "android"]));
942
+ if (command === "set")
943
+ cmd.argument("<label-or-json>", "runner label or JSON label array");
944
+ cmd.addOption(new Option("--scope <scope>", "account defaults or connected repository override").choices(["account", "repo"]).makeOptionMandatory()).option("--json", "print settings as JSON");
945
+ if (command === "set")
946
+ cmd.action((platform2, input, opts) => {
947
+ requireToken();
948
+ return run(cmdRunnersSet(platform2, input, opts.scope, opts.json));
949
+ });
950
+ else
951
+ cmd.action((platform2, opts) => {
952
+ requireToken();
953
+ return run(cmdRunnersSet(platform2, null, opts.scope, opts.json));
954
+ });
955
+ }
956
+ 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
957
  function getVersion() {
716
958
  try {
717
959
  const require2 = createRequire(import.meta.url);
@@ -725,9 +967,10 @@ async function run(p) {
725
967
  try {
726
968
  await p;
727
969
  } catch (err) {
728
- process.stderr.write(`${pc4.red("✖")} ${friendlyError(err).message}
970
+ const error = friendlyError(err);
971
+ process.stderr.write(`${pc4.red("✖")} ${error.message}
729
972
  `);
730
- process.exitCode = 1;
973
+ process.exitCode = error.exitCode;
731
974
  }
732
975
  }
733
976
  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.10",
3
+ "version": "0.3.0",
4
4
  "description": "On-demand iOS simulators & Android emulators on your own GitHub Actions minutes.",
5
5
  "repository": {
6
6
  "type": "git",