@mifune/agro 0.15.1 → 0.17.0-rc.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 (3) hide show
  1. package/README.md +14 -2
  2. package/dist/agro.js +1217 -452
  3. package/package.json +1 -1
package/dist/agro.js CHANGED
@@ -7,8 +7,8 @@ var __export = (target, all) => {
7
7
 
8
8
  // src/cli.ts
9
9
  import { fileURLToPath } from "node:url";
10
- import { dirname as dirname10, join as join18, resolve as resolve11 } from "node:path";
11
- import { existsSync as existsSync22, mkdtempSync as mkdtempSync5, readFileSync as readFileSync12, rmSync as rmSync6 } from "node:fs";
10
+ import { dirname as dirname10, join as join20, resolve as resolve13 } from "node:path";
11
+ import { existsSync as existsSync23, mkdtempSync as mkdtempSync5, readFileSync as readFileSync13, rmSync as rmSync6 } from "node:fs";
12
12
  import { tmpdir as tmpdir4 } from "node:os";
13
13
 
14
14
  // src/commands/update.ts
@@ -835,6 +835,49 @@ import { resolve as resolve3, sep } from "node:path";
835
835
  // src/lib/agro-config.ts
836
836
  import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, renameSync as renameSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
837
837
  import { basename as basename3, dirname as dirname2, resolve as resolve2 } from "node:path";
838
+
839
+ // src/lib/runtimes/catalog.ts
840
+ var CATALOG = [
841
+ {
842
+ id: "docker",
843
+ title: "Docker container",
844
+ tier: "container",
845
+ state: "active",
846
+ provisionable: true,
847
+ docsPath: "docs/runtimes/docker.md"
848
+ },
849
+ {
850
+ id: "microsandbox",
851
+ title: "MicroSandbox",
852
+ tier: "microvm",
853
+ state: "planned",
854
+ provisionable: false,
855
+ notProvisionableReason: (bin) => `microsandbox is not a provisionable runtime yet; see docs/rfcs/rfc-runtime-support.md. Inside a sandbox run \`${bin} tool install microsandbox\`.`,
856
+ docsPath: "docs/runtimes/microsandbox.md"
857
+ },
858
+ {
859
+ id: "openshell",
860
+ title: "NVIDIA OpenShell",
861
+ tier: "container",
862
+ state: "active",
863
+ provisionable: true,
864
+ docsPath: "docs/runtimes/openshell.md"
865
+ }
866
+ ];
867
+ var RUNTIME_CATALOG = Object.freeze(
868
+ CATALOG.map((entry) => Object.freeze({ ...entry }))
869
+ );
870
+ var PROVISIONABLE_RUNTIMES = Object.freeze(
871
+ CATALOG.filter((entry) => entry.provisionable).map((entry) => entry.id)
872
+ );
873
+ function findRuntime(id) {
874
+ return RUNTIME_CATALOG.find((r) => r.id === id);
875
+ }
876
+ function runtimeIds() {
877
+ return RUNTIME_CATALOG.map((r) => r.id);
878
+ }
879
+
880
+ // src/lib/agro-config.ts
838
881
  var AGRO_CONFIG_FILE = NAMES.configFile;
839
882
  var AGRO_CONFIG_MODE = 420;
840
883
  var RESERVED_HOME_PATHS = [
@@ -856,7 +899,7 @@ var RESERVED_HOME_PATHS = [
856
899
  "/usr",
857
900
  "/var"
858
901
  ];
859
- var SANDBOX_RUNTIMES = ["docker"];
902
+ var SANDBOX_RUNTIMES = PROVISIONABLE_RUNTIMES;
860
903
  function configCheckout(config) {
861
904
  const value = config.checkout ?? config.repo;
862
905
  return typeof value === "string" && value !== "" ? value : void 0;
@@ -885,6 +928,13 @@ function defaultAgroConfig(name) {
885
928
  composeOverrides: []
886
929
  };
887
930
  }
931
+ function entryRuntime(root) {
932
+ try {
933
+ return readAgroConfig(agroConfigPath(root)).runtime ?? "docker";
934
+ } catch {
935
+ return "docker";
936
+ }
937
+ }
888
938
  function readAgroConfig(path4) {
889
939
  if (!existsSync4(path4)) return defaultAgroConfig(basename3(dirname2(resolve2(path4))));
890
940
  let raw;
@@ -1274,8 +1324,8 @@ function redact(token) {
1274
1324
  async function ask(question) {
1275
1325
  const rl = createInterface({ input: process.stdin, output: process.stdout });
1276
1326
  try {
1277
- return await new Promise((resolve12) => {
1278
- rl.question(` ${question} `, (answer) => resolve12(answer.trim()));
1327
+ return await new Promise((resolve14) => {
1328
+ rl.question(` ${question} `, (answer) => resolve14(answer.trim()));
1279
1329
  });
1280
1330
  } finally {
1281
1331
  rl.close();
@@ -1287,7 +1337,7 @@ async function askSecret(question) {
1287
1337
  process.stdin.setRawMode(true);
1288
1338
  process.stdin.resume();
1289
1339
  const bytes = [];
1290
- return await new Promise((resolve12) => {
1340
+ return await new Promise((resolve14) => {
1291
1341
  const cleanup = () => {
1292
1342
  try {
1293
1343
  process.stdin.setRawMode(false);
@@ -1305,7 +1355,7 @@ async function askSecret(question) {
1305
1355
  } else if (byte === 13 || byte === 10) {
1306
1356
  cleanup();
1307
1357
  process.stdout.write("\n");
1308
- resolve12(Buffer.from(bytes).toString("utf8").trim());
1358
+ resolve14(Buffer.from(bytes).toString("utf8").trim());
1309
1359
  return;
1310
1360
  } else if (byte === 127 || byte === 8) {
1311
1361
  if (bytes.length > 0) {
@@ -1355,10 +1405,10 @@ async function askSecretPiped(question) {
1355
1405
  };
1356
1406
  process.on("SIGINT", onSigint);
1357
1407
  try {
1358
- return await new Promise((resolve12) => {
1408
+ return await new Promise((resolve14) => {
1359
1409
  rl.question(` ${question} `, (answer) => {
1360
1410
  process.stdout.write("\n");
1361
- resolve12(answer.trim());
1411
+ resolve14(answer.trim());
1362
1412
  });
1363
1413
  });
1364
1414
  } finally {
@@ -1736,6 +1786,9 @@ printf 'free\\n'
1736
1786
  exit 0
1737
1787
  `;
1738
1788
 
1789
+ // agro-asset:/home/runner/work/agro/agro/.devcontainer/openshell-policy.yaml
1790
+ var openshell_policy_default = "version: 1\n\nfilesystem_policy:\n include_workdir: true\n read_only:\n - /bin\n - /usr\n - /lib\n - /etc\n - /opt\n - /proc\n - /var/log\n - /dev/urandom\n read_write:\n - /home/sandbox\n - /tmp\n - /dev/null\n\nprocess:\n run_as_user: sandbox\n run_as_group: sandbox\n\nnetwork_policies:\n github:\n endpoints:\n - host: github.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-write\n - host: api.github.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-write\n - host: release-assets.githubusercontent.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-only\n binaries:\n - path: /usr/bin/git\n - path: /usr/bin/gh\n - path: /usr/bin/curl\n npm:\n endpoints:\n - host: registry.npmjs.org\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-only\n allow_encoded_slash: true\n binaries:\n - path: /usr/local/bin/node\n anthropic:\n endpoints:\n - host: api.anthropic.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-write\n binaries:\n - path: /home/sandbox/.local/lib/node_modules/@anthropic-ai/claude-code/bin/*\n";
1791
+
1739
1792
  // src/lib/registry.ts
1740
1793
  var SANDBOX_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
1741
1794
  var DEFAULT_NAME_PREFIX = "agro-sbx-";
@@ -1792,10 +1845,12 @@ function nextDefaultName(run = spawnRunner) {
1792
1845
  if (!taken.has(candidate)) return candidate;
1793
1846
  }
1794
1847
  }
1795
- function materialize(root, opts = {}) {
1796
- const entry = resolve5(root);
1848
+ function openshellFiles(entry) {
1849
+ return [{ dest: join4(entry, "openshell-policy.yaml"), body: openshell_policy_default, mode: 420 }];
1850
+ }
1851
+ function dockerFiles(entry, opts) {
1797
1852
  const scripts = join4(resolveProjectLayout(entry).controlDir, "scripts");
1798
- const files = [
1853
+ return [
1799
1854
  {
1800
1855
  dest: join4(entry, ".devcontainer", "docker-compose.yml"),
1801
1856
  body: (opts.checkout ?? opts.repo) === void 0 ? docker_compose_image_only_default : docker_compose_default,
@@ -1807,6 +1862,10 @@ function materialize(root, opts = {}) {
1807
1862
  { dest: join4(scripts, "paths.sh"), body: paths_default, mode: 420 },
1808
1863
  { dest: join4(scripts, "check-host-port.sh"), body: check_host_port_default, mode: 493 }
1809
1864
  ];
1865
+ }
1866
+ function materialize(root, opts = {}) {
1867
+ const entry = resolve5(root);
1868
+ const files = entryRuntime(entry) === "openshell" ? openshellFiles(entry) : dockerFiles(entry, opts);
1810
1869
  for (const file of files) {
1811
1870
  const dest = resolve5(file.dest);
1812
1871
  if (!dest.startsWith(entry + sep2)) {
@@ -1965,11 +2024,27 @@ function stripQuotes2(s) {
1965
2024
  // src/commands/lifecycle.ts
1966
2025
  import { existsSync as existsSync10, mkdtempSync, readFileSync as readFileSync7, rmSync, writeFileSync as writeFileSync5 } from "node:fs";
1967
2026
  import { tmpdir } from "node:os";
1968
- import { basename as basename4, join as join5, sep as sep3 } from "node:path";
2027
+ import { basename as basename6, join as join6, sep as sep3 } from "node:path";
2028
+
2029
+ // src/lib/execution/index.ts
2030
+ import { basename as basename4, resolve as resolve7 } from "node:path";
2031
+
2032
+ // src/lib/version.ts
2033
+ var AGRO_VERSION = true ? "0.17.0-rc.1" : "0.0.0-dev";
2034
+ var OFFICIAL_IMAGE = "ghcr.io/mifunedev/agro";
2035
+ var RELEASE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-z][a-z0-9]*\.[0-9]+)?$/;
2036
+ function parseReleaseVersion(value) {
2037
+ const bare = value.startsWith("v") ? value.slice(1) : value;
2038
+ return RELEASE_VERSION.test(bare) ? bare : void 0;
2039
+ }
2040
+ function officialImageRef(version) {
2041
+ return `${OFFICIAL_IMAGE}:${RELEASE_VERSION.test(version) ? version : "latest"}`;
2042
+ }
1969
2043
 
1970
2044
  // src/lib/execution/detect.ts
1971
2045
  import { existsSync as existsSync9 } from "node:fs";
1972
2046
  var SANDBOX_MARKER_FILE = "/.dockerenv";
2047
+ var EXECUTION_TARGET_ENV = "AGRO_EXECUTION_TARGET";
1973
2048
  function runningInsideSandbox(env = process.env, fileExists = existsSync9) {
1974
2049
  const override = agroEnvValue(env, "EXECUTION_TARGET");
1975
2050
  if (override === "local") return true;
@@ -2195,6 +2270,243 @@ function defaultIdentity() {
2195
2270
  return { name: info2.username, uid: info2.uid };
2196
2271
  }
2197
2272
 
2273
+ // src/lib/execution/openshell-target.ts
2274
+ import { join as join5 } from "node:path";
2275
+ var OPENSHELL_BIN = "openshell";
2276
+ var OPENSHELL_POLICY_FILE = "openshell-policy.yaml";
2277
+ var OPENSHELL_MAIN = "/usr/local/bin/openshell-main.sh";
2278
+ var OPENSHELL_WORKDIR = "/home/sandbox/harness";
2279
+ var OPENSHELL_USER = "sandbox";
2280
+ var OPENSHELL_PINNED_VERSION = "v0.1.2";
2281
+ var OPENSHELL_LOCAL_GATEWAY = "https://127.0.0.1:17670";
2282
+ var OPENSHELL_INSTALL_URL = "https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh";
2283
+ var LOGIN_SHELLS = /* @__PURE__ */ new Set(["zsh", "bash"]);
2284
+ var NOT_FOUND = /sandbox (?:'[^']*' )?not found/;
2285
+ var OPENSHELL_PHASES = [
2286
+ "Unspecified",
2287
+ "Provisioning",
2288
+ "Ready",
2289
+ "Error",
2290
+ "Deleting",
2291
+ "Unknown",
2292
+ "Stopping",
2293
+ "Stopped",
2294
+ "Starting",
2295
+ "Completed"
2296
+ ];
2297
+ var OPENSHELL_PHASE_STATUS = Object.freeze({
2298
+ Ready: "ready",
2299
+ Provisioning: "starting",
2300
+ Starting: "starting",
2301
+ Stopping: "stopped",
2302
+ Stopped: "stopped",
2303
+ Deleting: "stopped",
2304
+ Completed: "stopped",
2305
+ Error: "failed",
2306
+ Unknown: "failed",
2307
+ Unspecified: "failed"
2308
+ });
2309
+ function createArgv(opts) {
2310
+ return [
2311
+ "sandbox",
2312
+ "create",
2313
+ "--name",
2314
+ opts.name,
2315
+ "--from",
2316
+ opts.image,
2317
+ "--policy",
2318
+ join5(opts.entryRoot, OPENSHELL_POLICY_FILE),
2319
+ "--env",
2320
+ `${EXECUTION_TARGET_ENV}=local`,
2321
+ "--env",
2322
+ `SANDBOX_NAME=${opts.name}`,
2323
+ "--detach",
2324
+ "--",
2325
+ OPENSHELL_MAIN
2326
+ ];
2327
+ }
2328
+ function attachArgv(name, request = { argv: ["zsh"] }) {
2329
+ return [
2330
+ "sandbox",
2331
+ "exec",
2332
+ "-n",
2333
+ name,
2334
+ "--tty",
2335
+ "--workdir",
2336
+ request.cwd ?? OPENSHELL_WORKDIR,
2337
+ ...envFlags(request.env),
2338
+ "--",
2339
+ ...asLoginShell(request.argv)
2340
+ ];
2341
+ }
2342
+ function execArgv(name, request) {
2343
+ return [
2344
+ "sandbox",
2345
+ "exec",
2346
+ "-n",
2347
+ name,
2348
+ ...request.cwd !== void 0 ? ["--workdir", request.cwd] : [],
2349
+ ...envFlags(request.env),
2350
+ ...request.timeoutMs !== void 0 ? ["--timeout", String(Math.ceil(request.timeoutMs / 1e3))] : [],
2351
+ "--",
2352
+ ...request.argv
2353
+ ];
2354
+ }
2355
+ function getArgv(name) {
2356
+ return ["sandbox", "get", name, "-o", "json"];
2357
+ }
2358
+ function deleteArgv(name) {
2359
+ return ["sandbox", "delete", name];
2360
+ }
2361
+ function parseOpenShellPhase(json) {
2362
+ let parsed;
2363
+ try {
2364
+ parsed = JSON.parse(json);
2365
+ } catch {
2366
+ return "Unknown";
2367
+ }
2368
+ const phase = typeof parsed === "object" && parsed !== null ? parsed.phase : void 0;
2369
+ return OPENSHELL_PHASES.find((known) => known === phase) ?? "Unknown";
2370
+ }
2371
+ function openshellSandboxMissing(output) {
2372
+ return NOT_FOUND.test(output);
2373
+ }
2374
+ var OPENSHELL_VERB_HINTS = Object.freeze({
2375
+ stop: (name) => `openshell sandbox stop ${name}`,
2376
+ restart: (name) => `openshell sandbox stop ${name} && openshell sandbox start ${name}`,
2377
+ logs: (name) => `openshell logs ${name}`,
2378
+ ps: (name) => `openshell sandbox get ${name}`,
2379
+ config: (name) => `openshell policy get ${name}`
2380
+ });
2381
+ function openshellInstallHint() {
2382
+ return [
2383
+ "install the OpenShell CLI on the host, then run the command again:",
2384
+ ` curl -LsSf ${OPENSHELL_INSTALL_URL} | OPENSHELL_VERSION=${OPENSHELL_PINNED_VERSION} sh`,
2385
+ "review-first alternative \u2014 download the script, inspect it, then run it:",
2386
+ ` curl -LsSf -o openshell-install.sh ${OPENSHELL_INSTALL_URL}`,
2387
+ " less openshell-install.sh",
2388
+ ` OPENSHELL_VERSION=${OPENSHELL_PINNED_VERSION} sh openshell-install.sh`
2389
+ ].join("\n");
2390
+ }
2391
+ function openshellGatewayHint() {
2392
+ return [
2393
+ "the OpenShell gateway is not connected; start the local gateway and register it:",
2394
+ " systemctl --user restart openshell-gateway",
2395
+ ` openshell gateway add ${OPENSHELL_LOCAL_GATEWAY} --local --name openshell`,
2396
+ "then confirm with: openshell status"
2397
+ ].join("\n");
2398
+ }
2399
+ function openshellCleanupHint(name) {
2400
+ return `the OpenShell gateway can keep a failed sandbox record in the Error phase; if ${name} is not another sandbox, remove it with: openshell sandbox delete ${name}`;
2401
+ }
2402
+ function openshellPreflight(run) {
2403
+ const version = run(OPENSHELL_BIN, ["--version"], { stdio: "capture" });
2404
+ if (version.error?.code === "ENOENT") {
2405
+ return { ok: false, reason: "missing-binary", hint: openshellInstallHint() };
2406
+ }
2407
+ assertSpawned(version, `${OPENSHELL_BIN} --version`);
2408
+ const status = run(OPENSHELL_BIN, ["status", "-o", "json"], { stdio: "capture" });
2409
+ assertSpawned(status, `${OPENSHELL_BIN} status`);
2410
+ if (status.status !== 0 || !gatewayConnected(status.stdout ?? "")) {
2411
+ return { ok: false, reason: "gateway-unreachable", hint: openshellGatewayHint() };
2412
+ }
2413
+ return { ok: true };
2414
+ }
2415
+ var OpenShellExecutionTarget = class {
2416
+ kind = "openshell";
2417
+ contractVersion = 1;
2418
+ workspace;
2419
+ name;
2420
+ entryRoot;
2421
+ image;
2422
+ run;
2423
+ env;
2424
+ constructor(opts) {
2425
+ this.name = opts.name;
2426
+ this.entryRoot = opts.entryRoot;
2427
+ this.image = opts.image;
2428
+ this.run = opts.run ?? spawnRunner;
2429
+ this.env = opts.env;
2430
+ this.workspace = { hostRoot: opts.entryRoot, targetRoot: OPENSHELL_WORKDIR };
2431
+ }
2432
+ async provision() {
2433
+ const argv = createArgv({ name: this.name, image: this.image, entryRoot: this.entryRoot });
2434
+ this.runInherited(argv, "sandbox create");
2435
+ }
2436
+ async destroy() {
2437
+ this.runInherited(deleteArgv(this.name), "sandbox delete");
2438
+ }
2439
+ async status() {
2440
+ const r = this.run(OPENSHELL_BIN, getArgv(this.name), { stdio: "capture", ...this.childEnv() });
2441
+ assertSpawned(r, this.what("sandbox get"));
2442
+ if (r.status !== 0) {
2443
+ if (openshellSandboxMissing(`${r.stdout ?? ""}
2444
+ ${r.stderr ?? ""}`)) return "absent";
2445
+ throw new ExecutionExitError(this.what("sandbox get"), r.status ?? 1);
2446
+ }
2447
+ return OPENSHELL_PHASE_STATUS[parseOpenShellPhase(r.stdout ?? "")];
2448
+ }
2449
+ async capabilities() {
2450
+ return /* @__PURE__ */ new Set(["exec", "pty"]);
2451
+ }
2452
+ async exec(request) {
2453
+ requireSandboxUser(request);
2454
+ const inherit = request.stdio === "inherit";
2455
+ const r = this.run(OPENSHELL_BIN, execArgv(this.name, request), {
2456
+ stdio: inherit ? "inherit" : "capture",
2457
+ ...this.childEnv()
2458
+ });
2459
+ assertSpawned(r, this.what("sandbox exec"));
2460
+ return {
2461
+ exitCode: r.status ?? 1,
2462
+ stdout: r.stdout ?? "",
2463
+ stderr: r.stderr ?? ""
2464
+ };
2465
+ }
2466
+ attach(request) {
2467
+ requireSandboxUser(request);
2468
+ const r = this.run(OPENSHELL_BIN, attachArgv(this.name, request), { stdio: "inherit", ...this.childEnv() });
2469
+ assertSpawned(r, this.what("sandbox exec"));
2470
+ return r.status ?? 1;
2471
+ }
2472
+ describe() {
2473
+ return `openshell sandbox ${this.name} (entry ${this.entryRoot})`;
2474
+ }
2475
+ runInherited(argv, verb) {
2476
+ const r = this.run(OPENSHELL_BIN, argv, { stdio: "inherit", ...this.childEnv() });
2477
+ assertSpawned(r, this.what(verb));
2478
+ const code = r.status ?? 1;
2479
+ if (code !== 0) throw new ExecutionExitError(this.what(verb), code);
2480
+ }
2481
+ childEnv() {
2482
+ return this.env ? { env: this.env } : {};
2483
+ }
2484
+ what(verb) {
2485
+ return `${OPENSHELL_BIN} ${verb} ${this.name}`;
2486
+ }
2487
+ };
2488
+ function asLoginShell(argv) {
2489
+ return argv.length === 1 && LOGIN_SHELLS.has(argv[0]) ? [argv[0], "-l"] : [...argv];
2490
+ }
2491
+ function envFlags(env) {
2492
+ return Object.entries(env ?? {}).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
2493
+ }
2494
+ function gatewayConnected(json) {
2495
+ try {
2496
+ const parsed = JSON.parse(json);
2497
+ return typeof parsed === "object" && parsed !== null && parsed.status === "connected";
2498
+ } catch {
2499
+ return false;
2500
+ }
2501
+ }
2502
+ function requireSandboxUser(request) {
2503
+ if (request.user !== void 0 && request.user !== OPENSHELL_USER) {
2504
+ throw new Error(
2505
+ `the openshell runtime runs every command as "${OPENSHELL_USER}" and cannot run as "${request.user}"`
2506
+ );
2507
+ }
2508
+ }
2509
+
2198
2510
  // src/lib/execution/status.ts
2199
2511
  var RUNTIME_ABSENT_STATUS = "no container runtime";
2200
2512
  async function resolveTargetStatus(target) {
@@ -2218,19 +2530,23 @@ function resolveExecutionTarget(opts) {
2218
2530
  ...opts.env ? { env: opts.env } : {}
2219
2531
  });
2220
2532
  }
2533
+ if (entryRuntime(opts.projectRoot) === "openshell") {
2534
+ const config = readAgroConfig(agroConfigPath(opts.projectRoot));
2535
+ return new OpenShellExecutionTarget({
2536
+ name: opts.container ?? nonEmpty(config.name) ?? basename4(resolve7(opts.projectRoot)),
2537
+ entryRoot: opts.projectRoot,
2538
+ image: openshellImage(config),
2539
+ ...opts.run ? { run: opts.run } : {},
2540
+ ...opts.env ? { env: opts.env } : {}
2541
+ });
2542
+ }
2221
2543
  return new DockerComposeExecutionTarget(opts);
2222
2544
  }
2223
-
2224
- // src/lib/version.ts
2225
- var AGRO_VERSION = true ? "0.15.1" : "0.0.0-dev";
2226
- var OFFICIAL_IMAGE = "ghcr.io/mifunedev/agro";
2227
- var RELEASE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-z][a-z0-9]*\.[0-9]+)?$/;
2228
- function parseReleaseVersion(value) {
2229
- const bare = value.startsWith("v") ? value.slice(1) : value;
2230
- return RELEASE_VERSION.test(bare) ? bare : void 0;
2545
+ function openshellImage(config) {
2546
+ return nonEmpty(config.image?.ref) ?? officialImageRef(AGRO_VERSION);
2231
2547
  }
2232
- function officialImageRef(version) {
2233
- return `${OFFICIAL_IMAGE}:${RELEASE_VERSION.test(version) ? version : "latest"}`;
2548
+ function nonEmpty(value) {
2549
+ return value === void 0 || value === "" ? void 0 : value;
2234
2550
  }
2235
2551
 
2236
2552
  // src/lib/config-render.ts
@@ -2296,13 +2612,103 @@ function renderComposeEnv(config) {
2296
2612
  `;
2297
2613
  }
2298
2614
 
2615
+ // src/lib/runtimes/verbs.ts
2616
+ import { basename as basename5, resolve as resolve8 } from "node:path";
2617
+ var viaCompose = Object.freeze({ action: "route", via: "compose" });
2618
+ var viaTarget = Object.freeze({ action: "route", via: "target" });
2619
+ function refuse(hint) {
2620
+ return Object.freeze({ action: "refuse", hint });
2621
+ }
2622
+ var LIFECYCLE_COMMANDS = Object.freeze({
2623
+ shell: "shell",
2624
+ stop: "stop",
2625
+ restart: "restart",
2626
+ logs: "logs",
2627
+ ps: "ps",
2628
+ destroy: "destroy",
2629
+ config: "compose config",
2630
+ upgrade: "sandbox upgrade"
2631
+ });
2632
+ var DOCKER_VERBS = Object.freeze({
2633
+ shell: viaTarget,
2634
+ stop: viaCompose,
2635
+ restart: viaCompose,
2636
+ logs: viaCompose,
2637
+ ps: viaCompose,
2638
+ destroy: viaCompose,
2639
+ config: viaCompose,
2640
+ upgrade: viaTarget
2641
+ });
2642
+ var OPENSHELL_VERBS = Object.freeze({
2643
+ shell: viaTarget,
2644
+ stop: refuse(OPENSHELL_VERB_HINTS.stop),
2645
+ restart: refuse(OPENSHELL_VERB_HINTS.restart),
2646
+ logs: refuse(OPENSHELL_VERB_HINTS.logs),
2647
+ ps: refuse(OPENSHELL_VERB_HINTS.ps),
2648
+ destroy: viaTarget,
2649
+ config: refuse(OPENSHELL_VERB_HINTS.config),
2650
+ upgrade: refuse(
2651
+ (name) => `${activeBin()} destroy ${name}, then ${activeBin()} sandbox install openshell --name ${name} --image=<ref>`
2652
+ )
2653
+ });
2654
+ var RUNTIME_VERBS = Object.freeze({
2655
+ docker: DOCKER_VERBS,
2656
+ openshell: OPENSHELL_VERBS
2657
+ });
2658
+ function configuredEntryName(root) {
2659
+ try {
2660
+ const name = readAgroConfig(agroConfigPath(root)).name;
2661
+ return name === "" ? void 0 : name;
2662
+ } catch {
2663
+ return void 0;
2664
+ }
2665
+ }
2666
+ function entryDeclaredName(root) {
2667
+ return configuredEntryName(root) ?? basename5(resolve8(root));
2668
+ }
2669
+ var RUNTIME_NAMING = Object.freeze({
2670
+ docker: Object.freeze({
2671
+ entryName: () => void 0,
2672
+ shellFailure: (bin, name) => `container \`${name}\` not running? start it with \`${bin} sandbox install docker\``
2673
+ }),
2674
+ openshell: Object.freeze({
2675
+ entryName: entryDeclaredName,
2676
+ shellFailure: (bin, name) => `sandbox \`${name}\` did not accept the shell; check its status with \`${bin} sandbox list\``
2677
+ })
2678
+ });
2679
+ function entrySandboxName(root) {
2680
+ return RUNTIME_NAMING[entryRuntime(root)].entryName(root);
2681
+ }
2682
+ function shellFailureHint(runtime, bin, name) {
2683
+ return RUNTIME_NAMING[runtime].shellFailure(bin, name);
2684
+ }
2685
+ var RuntimeUnsupportedError = class extends Error {
2686
+ runtime;
2687
+ verb;
2688
+ hint;
2689
+ constructor(runtime, verb, hint) {
2690
+ const command = LIFECYCLE_COMMANDS[verb];
2691
+ super(`${activeBin()} ${command}: the ${runtime} runtime does not support ${command}; run: ${hint}`);
2692
+ this.name = "RuntimeUnsupportedError";
2693
+ this.runtime = runtime;
2694
+ this.verb = verb;
2695
+ this.hint = hint;
2696
+ }
2697
+ };
2698
+ function routeVerb(root, verb, name) {
2699
+ const runtime = entryRuntime(root);
2700
+ const policy = RUNTIME_VERBS[runtime][verb];
2701
+ if (policy.action === "refuse") throw new RuntimeUnsupportedError(runtime, verb, policy.hint(name));
2702
+ return { runtime, via: policy.via };
2703
+ }
2704
+
2299
2705
  // src/commands/lifecycle.ts
2300
2706
  var COMPOSE_ENV_DIR_PREFIX = "oh-compose-env-";
2301
2707
  function composeEnvDir() {
2302
- return mkdtempSync(join5(tmpdir(), COMPOSE_ENV_DIR_PREFIX));
2708
+ return mkdtempSync(join6(tmpdir(), COMPOSE_ENV_DIR_PREFIX));
2303
2709
  }
2304
2710
  function writeComposeEnv(root, dir) {
2305
- const file = join5(dir, "compose.env");
2711
+ const file = join6(dir, "compose.env");
2306
2712
  writeFileSync5(file, renderComposeEnv(readAgroConfig(agroConfigPath(root))), {
2307
2713
  mode: 384,
2308
2714
  encoding: "utf8"
@@ -2360,7 +2766,7 @@ async function maybePromptDockerSocket(root, io, bin) {
2360
2766
  if (dockerSocketConfigured(root)) return;
2361
2767
  const interactive = process.stdin.isTTY === true || io.ask !== void 0;
2362
2768
  if (!interactive) return;
2363
- const envDir = join5(root, ".devcontainer");
2769
+ const envDir = join6(root, ".devcontainer");
2364
2770
  if (!existsSync10(envDir)) return;
2365
2771
  const askFn = io.ask ?? ask;
2366
2772
  const answer = (await askFn(
@@ -2441,25 +2847,27 @@ async function runSandbox(opts, io) {
2441
2847
  function configuredContainerName(root) {
2442
2848
  return fromProcessEnv("SANDBOX_NAME") ?? configuredString(root, "name");
2443
2849
  }
2850
+ function sandboxName(root) {
2851
+ return entrySandboxName(root) ?? configuredContainerName(root) ?? DEFAULT_CONTAINER_NAME;
2852
+ }
2444
2853
  function runShell(opts, io) {
2445
2854
  const run = opts.run ?? spawnRunner;
2446
2855
  const root = sandboxRoot(opts);
2447
- const name = configuredContainerName(root) ?? DEFAULT_CONTAINER_NAME;
2856
+ const name = sandboxName(root);
2857
+ const { runtime } = routeVerb(root, "shell", name);
2448
2858
  const target = resolveExecutionTarget({ projectRoot: root, container: name, run });
2449
2859
  let code;
2450
2860
  try {
2451
2861
  code = target.attach({ argv: ["zsh"], user: "sandbox" });
2452
2862
  } catch (err) {
2453
2863
  if (err instanceof ExecutionSpawnError && err.code === "ENOENT") {
2454
- throw new Error(`docker is required for \`${opts.bin} shell\` but was not found on PATH`);
2864
+ throw new Error(`${runtime} is required for \`${opts.bin} shell\` but was not found on PATH`);
2455
2865
  }
2456
2866
  throw err;
2457
2867
  }
2458
2868
  if (code !== 0) {
2459
- io.stderr(
2460
- `container \`${name}\` not running? start it with \`${opts.bin} sandbox install docker\`
2461
- `
2462
- );
2869
+ io.stderr(`${shellFailureHint(runtime, opts.bin, name)}
2870
+ `);
2463
2871
  }
2464
2872
  return code;
2465
2873
  }
@@ -2476,6 +2884,7 @@ function composeVerbs() {
2476
2884
  function runComposeVerb(verb, opts, extra = []) {
2477
2885
  const run = opts.run ?? spawnRunner;
2478
2886
  const root = sandboxRoot(opts);
2887
+ routeVerb(root, verb, sandboxName(root));
2479
2888
  const script = requireLifecycleScript(root, "docker-compose.sh");
2480
2889
  return withComposeEnvFile(root, (extraArgs) => {
2481
2890
  const r = run("bash", [script, ...extraArgs, ...COMPOSE_VERBS[verb], ...extra], {
@@ -2488,6 +2897,7 @@ function runComposeVerb(verb, opts, extra = []) {
2488
2897
  function runComposeConfig(opts, extra = []) {
2489
2898
  const run = opts.run ?? spawnRunner;
2490
2899
  const root = sandboxRoot(opts);
2900
+ routeVerb(root, "config", sandboxName(root));
2491
2901
  const script = requireLifecycleScript(root, "docker-compose.sh");
2492
2902
  return withComposeEnvFile(root, (extraArgs) => {
2493
2903
  const r = run("bash", [script, ...extraArgs, "config", ...extra], { stdio: "inherit" });
@@ -2498,7 +2908,7 @@ function runComposeConfig(opts, extra = []) {
2498
2908
  function namedVolumes(root) {
2499
2909
  let text;
2500
2910
  try {
2501
- text = readFileSync7(join5(root, ".devcontainer", "docker-compose.yml"), "utf8");
2911
+ text = readFileSync7(join6(root, ".devcontainer", "docker-compose.yml"), "utf8");
2502
2912
  } catch {
2503
2913
  return [];
2504
2914
  }
@@ -2514,11 +2924,23 @@ function namedVolumes(root) {
2514
2924
  return names;
2515
2925
  }
2516
2926
  function destroyConfirmationPhrase(root) {
2517
- return configuredContainerName(root) ?? DEFAULT_CONTAINER_NAME;
2927
+ return sandboxName(root);
2928
+ }
2929
+ async function destroyThroughTarget(root, name, run) {
2930
+ const target = resolveExecutionTarget({ projectRoot: root, container: name, run });
2931
+ if (target.destroy === void 0) throw new Error(`${target.describe()} cannot destroy its sandbox`);
2932
+ try {
2933
+ await target.destroy();
2934
+ return 0;
2935
+ } catch (err) {
2936
+ if (err instanceof ExecutionExitError) return err.exitCode;
2937
+ throw err;
2938
+ }
2518
2939
  }
2519
2940
  async function runDestroy(opts, io) {
2520
2941
  const root = sandboxRoot(opts);
2521
2942
  const name = destroyConfirmationPhrase(root);
2943
+ const { via } = routeVerb(root, "destroy", name);
2522
2944
  if (opts.yes !== true) {
2523
2945
  const interactive = process.stdin.isTTY === true || io.ask !== void 0;
2524
2946
  if (!interactive) {
@@ -2533,7 +2955,14 @@ async function runDestroy(opts, io) {
2533
2955
  ${bold(`${opts.bin} destroy \u2014 ${name}`)}
2534
2956
 
2535
2957
  `);
2536
- if (homePath !== void 0 && homePath !== "") {
2958
+ if (via === "target") {
2959
+ io.stdout(
2960
+ `This deletes the sandbox \`${name}\` and everything inside it \u2014 every agent CLI
2961
+ login, the gh CLI token, and the SSH keys. Sign-in starts over.
2962
+
2963
+ `
2964
+ );
2965
+ } else if (homePath !== void 0 && homePath !== "") {
2537
2966
  io.stdout("`docker compose down -v` removes the containers.\n");
2538
2967
  io.stdout(
2539
2968
  `This sandbox keeps its home on a host bind at ${homePath}, which \`down -v\`
@@ -2564,7 +2993,7 @@ stay there. Delete that directory yourself if you also want them gone.
2564
2993
  return 1;
2565
2994
  }
2566
2995
  }
2567
- const code = runComposeVerb("destroy", { ...opts, name: basename4(root) });
2996
+ const code = via === "target" ? await destroyThroughTarget(root, name, opts.run ?? spawnRunner) : runComposeVerb("destroy", { ...opts, name: basename6(root) });
2568
2997
  if (code === 0 && root.startsWith(registryRoot() + sep3)) {
2569
2998
  rmSync(root, { recursive: true, force: true });
2570
2999
  io.stdout(`removed the sandbox entry ${root}
@@ -2853,13 +3282,13 @@ function sameRemote(url, owner, name) {
2853
3282
 
2854
3283
  // src/commands/secret.ts
2855
3284
  import { existsSync as existsSync12 } from "node:fs";
2856
- import { join as join7 } from "node:path";
3285
+ import { join as join8 } from "node:path";
2857
3286
 
2858
3287
  // src/lib/gitignore.ts
2859
3288
  import { existsSync as existsSync11, mkdirSync as mkdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "node:fs";
2860
- import { dirname as dirname5, join as join6 } from "node:path";
3289
+ import { dirname as dirname5, join as join7 } from "node:path";
2861
3290
  function appendGitignoreLines(root, lines) {
2862
- const target = join6(root, ".gitignore");
3291
+ const target = join7(root, ".gitignore");
2863
3292
  const existing = existsSync11(target) ? readFileSync8(target, "utf8") : "";
2864
3293
  const present = new Set(existing.split("\n").map((line) => line.trimEnd()));
2865
3294
  const added = [];
@@ -2884,7 +3313,7 @@ function secretsRoot(opts) {
2884
3313
  return opts.sandbox === void 0 ? resolveProjectRoot(opts.cwd) : resolveSandboxRoot({ name: opts.sandbox });
2885
3314
  }
2886
3315
  function ignoreSecretsFile(root) {
2887
- if (!existsSync12(join7(root, ".git"))) return;
3316
+ if (!existsSync12(join8(root, ".git"))) return;
2888
3317
  appendGitignoreLines(root, [".env"]);
2889
3318
  }
2890
3319
  function secretKeyList() {
@@ -2942,10 +3371,10 @@ async function runSecretList(opts, io) {
2942
3371
  // src/commands/langfuse.ts
2943
3372
  import { copyFileSync as copyFileSync2, existsSync as existsSync14, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync10, rmSync as rmSync2, unlinkSync as unlinkSync4 } from "node:fs";
2944
3373
  import { homedir as homedir2, tmpdir as tmpdir2 } from "node:os";
2945
- import { dirname as dirname7, join as join10, relative } from "node:path";
3374
+ import { dirname as dirname7, join as join11, relative } from "node:path";
2946
3375
 
2947
3376
  // src/lib/tracing/harness-writers.ts
2948
- import { join as join8 } from "node:path";
3377
+ import { join as join9 } from "node:path";
2949
3378
 
2950
3379
  // src/lib/tracing/writer.ts
2951
3380
  import { chmodSync, existsSync as existsSync13, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync7 } from "node:fs";
@@ -2990,13 +3419,13 @@ function isPlainObject(value) {
2990
3419
  // src/lib/tracing/harness-writers.ts
2991
3420
  var CODEX_TRACING_TAGS = ["codex"];
2992
3421
  function claudeCodeSettingsPath(home) {
2993
- return join8(home, ".claude", "settings.json");
3422
+ return join9(home, ".claude", "settings.json");
2994
3423
  }
2995
3424
  function piTracingConfigPath(home) {
2996
- return join8(home, ".pi", "agent", "langfuse.json");
3425
+ return join9(home, ".pi", "agent", "langfuse.json");
2997
3426
  }
2998
3427
  function codexTracingConfigPath(home) {
2999
- return join8(home, ".codex", "langfuse.json");
3428
+ return join9(home, ".codex", "langfuse.json");
3000
3429
  }
3001
3430
  var claudeCodeTracingWriter = (home, settings) => {
3002
3431
  const path4 = claudeCodeSettingsPath(home);
@@ -3129,7 +3558,7 @@ var HARNESS_CATALOG = [
3129
3558
  installArgv: [
3130
3559
  "bash",
3131
3560
  "-lc",
3132
- `curl -fsSL https://hermes-agent.nousresearch.com/install.sh | HERMES_INSTALL_DIR="${HARNESS_PREFIX_TOKEN}/lib/hermes-agent" bash -s -- --skip-setup --skip-browser && uv pip install --python "${HARNESS_PREFIX_TOKEN}/lib/hermes-agent/venv/bin/python" 'hermes-agent[slack,teams,web,pty]'`
3561
+ `curl -fsSL https://hermes-agent.nousresearch.com/install.sh | HERMES_INSTALL_DIR="${HARNESS_PREFIX_TOKEN}/lib/hermes-agent" bash -s -- --skip-setup --skip-browser && "${HARNESS_PREFIX_TOKEN}/bin/hermes" pm install --extra slack --extra teams`
3133
3562
  ],
3134
3563
  installUser: "sandbox",
3135
3564
  verifyArgv: ["hermes", "--version"],
@@ -3206,7 +3635,7 @@ function harnessBinPath(prefix) {
3206
3635
  }
3207
3636
 
3208
3637
  // src/lib/tracing/providers/langfuse.ts
3209
- import { join as join9 } from "node:path";
3638
+ import { join as join10 } from "node:path";
3210
3639
  var LANGFUSE_FRAGMENT_KEYS = [
3211
3640
  "LANGFUSE_PUBLIC_KEY",
3212
3641
  "LANGFUSE_SECRET_KEY",
@@ -3214,7 +3643,7 @@ var LANGFUSE_FRAGMENT_KEYS = [
3214
3643
  "LANGFUSE_TRACING_ENVIRONMENT"
3215
3644
  ];
3216
3645
  function langfuseFragmentPath(home) {
3217
- return join9(home, ".config", "agro", "langfuse.env");
3646
+ return join10(home, ".config", "agro", "langfuse.env");
3218
3647
  }
3219
3648
  function loadLangfuseCredentials(root) {
3220
3649
  const publicKey = readSecret(root, "LANGFUSE_PUBLIC_KEY");
@@ -3378,7 +3807,7 @@ function displayPath(home, path4) {
3378
3807
  return rel.startsWith("..") ? path4 : `~/${rel}`;
3379
3808
  }
3380
3809
  function withTempHome(fn) {
3381
- const dir = mkdtempSync2(join10(tmpdir2(), "agro-langfuse-"));
3810
+ const dir = mkdtempSync2(join11(tmpdir2(), "agro-langfuse-"));
3382
3811
  try {
3383
3812
  return fn(dir);
3384
3813
  } finally {
@@ -3387,10 +3816,10 @@ function withTempHome(fn) {
3387
3816
  }
3388
3817
  function probeGeneratedFile(home, render) {
3389
3818
  const rel = withTempHome((scratch) => relative(scratch, render(scratch).path));
3390
- const path4 = join10(home, rel);
3819
+ const path4 = join11(home, rel);
3391
3820
  if (!existsSync14(path4)) return { path: path4, state: "missing" };
3392
3821
  const outcome = withTempHome((scratch) => {
3393
- const copy = join10(scratch, rel);
3822
+ const copy = join11(scratch, rel);
3394
3823
  mkdirSync6(dirname7(copy), { recursive: true });
3395
3824
  copyFileSync2(path4, copy);
3396
3825
  return render(scratch).outcome;
@@ -3453,7 +3882,7 @@ async function runLangfuseApply(opts, io) {
3453
3882
  return renderAll(ctx, resolved, io, opts.bin, "apply");
3454
3883
  }
3455
3884
  function zshenvWarning(home) {
3456
- const zshenv = join10(home, ".zshenv");
3885
+ const zshenv = join11(home, ".zshenv");
3457
3886
  if (!existsSync14(zshenv)) return void 0;
3458
3887
  if (readFileSync10(zshenv, "utf8").includes("langfuse.env")) return void 0;
3459
3888
  return `~/.zshenv exists but does not source ${LANGFUSE_FRAGMENT_LABEL} \u2014 non-interactive shells will carry no Langfuse credentials; add the source block from .agro/install/.zshenv
@@ -3734,45 +4163,18 @@ async function runLangfuseSetup(opts, io) {
3734
4163
  // src/commands/sandbox.ts
3735
4164
  import { existsSync as existsSync16, mkdirSync as mkdirSync8, mkdtempSync as mkdtempSync3, rmSync as rmSync3, statSync as statSync4 } from "node:fs";
3736
4165
  import { tmpdir as tmpdir3 } from "node:os";
3737
- import { join as join12, resolve as resolve7 } from "node:path";
3738
-
3739
- // src/lib/runtimes/catalog.ts
3740
- var RUNTIME_CATALOG = Object.freeze([
3741
- Object.freeze({
3742
- id: "docker",
3743
- title: "Docker container",
3744
- tier: "container",
3745
- state: "active",
3746
- provisionable: true,
3747
- docsPath: "docs/runtimes/docker.md"
3748
- }),
3749
- Object.freeze({
3750
- id: "microsandbox",
3751
- title: "MicroSandbox",
3752
- tier: "microvm",
3753
- state: "planned",
3754
- provisionable: false,
3755
- notProvisionableReason: (bin) => `microsandbox is not a provisionable runtime yet; see docs/rfcs/rfc-runtime-support.md. Inside a sandbox run \`${bin} tool install microsandbox\`.`,
3756
- docsPath: "docs/runtimes/microsandbox.md"
3757
- })
3758
- ]);
3759
- function findRuntime(id) {
3760
- return RUNTIME_CATALOG.find((r) => r.id === id);
3761
- }
3762
- function runtimeIds() {
3763
- return RUNTIME_CATALOG.map((r) => r.id);
3764
- }
4166
+ import { join as join13, resolve as resolve9 } from "node:path";
3765
4167
 
3766
4168
  // src/lib/star-prompt.ts
3767
4169
  import { existsSync as existsSync15, mkdirSync as mkdirSync7, writeFileSync as writeFileSync8 } from "node:fs";
3768
- import { join as join11 } from "node:path";
4170
+ import { join as join12 } from "node:path";
3769
4171
  var STAR_PROMPT_LINE = "\u2B50 If AGRO helps, star https://github.com/mifunedev/agro";
3770
4172
  var MARKER = "star-prompt-shown";
3771
4173
  function maybePrintStarPrompt(io, env = process.env, isTTY = process.stdout.isTTY === true, stateHome = resolveUserStateHome(env)) {
3772
4174
  if (env.AGRO_NO_STAR_PROMPT === "1") return;
3773
4175
  if (env.CI !== void 0 && env.CI !== "") return;
3774
4176
  if (!isTTY) return;
3775
- const marker = join11(stateHome, MARKER);
4177
+ const marker = join12(stateHome, MARKER);
3776
4178
  if (existsSync15(marker)) return;
3777
4179
  io.stdout(`${STAR_PROMPT_LINE}
3778
4180
  `);
@@ -3784,39 +4186,88 @@ function maybePrintStarPrompt(io, env = process.env, isTTY = process.stdout.isTT
3784
4186
  }
3785
4187
  }
3786
4188
 
3787
- // src/commands/sandbox.ts
3788
- function hostTimezone() {
3789
- const tz = process.env.TZ;
3790
- return tz !== void 0 && tz !== "" ? tz : "UTC";
3791
- }
3792
- function gitIdentity(run, key) {
3793
- let result;
4189
+ // src/commands/sandbox-list.ts
4190
+ async function entryStatus(root, name, run) {
3794
4191
  try {
3795
- result = run("git", ["config", "--global", key], { stdio: "capture" });
4192
+ const target = resolveExecutionTarget({ projectRoot: root, container: entrySandboxName(root) ?? name, run });
4193
+ return await target.status();
3796
4194
  } catch {
3797
- return "";
4195
+ return "unknown";
3798
4196
  }
3799
- if (result.error || result.status !== 0) return "";
3800
- return (result.stdout ?? "").trim();
3801
- }
3802
- function isBuildCapable(repo) {
3803
- return repo !== void 0 && existsSync16(join12(repo, ".devcontainer", "Dockerfile"));
3804
4197
  }
3805
- function prepareHomeMount(value) {
3806
- const path4 = resolve7(value);
3807
- if (existsSync16(path4)) {
3808
- if (!statSync4(path4).isDirectory()) {
3809
- throw new Error(`--home-mount path exists and is not a directory: ${path4}`);
3810
- }
3811
- return path4;
4198
+ async function runSandboxList(opts, io) {
4199
+ const run = opts.run ?? spawnRunner;
4200
+ const rows = [];
4201
+ for (const name of listEntries()) {
4202
+ const root = entryRoot(name);
4203
+ const config = readAgroConfig(agroConfigPath(root));
4204
+ const checkout = configCheckout(config) ?? "-";
4205
+ rows.push({
4206
+ name,
4207
+ runtime: config.runtime ?? "docker",
4208
+ checkout,
4209
+ repo: checkout,
4210
+ status: await entryStatus(root, name, run)
4211
+ });
3812
4212
  }
3813
- mkdirSync8(path4, { recursive: true });
3814
- return path4;
3815
- }
3816
- function readSeedConfig(repo) {
3817
- if (repo === void 0) return void 0;
3818
- const file = agroConfigPath(repo);
3819
- return existsSync16(file) ? readAgroConfig(file) : void 0;
4213
+ if (opts.json === true) {
4214
+ io.stdout(`${JSON.stringify(rows, null, 2)}
4215
+ `);
4216
+ return 0;
4217
+ }
4218
+ if (rows.length === 0) {
4219
+ io.stdout(
4220
+ `no sandbox is registered in ${registryRoot()} \u2014 create one with \`${opts.bin} sandbox install docker\`
4221
+ `
4222
+ );
4223
+ return 0;
4224
+ }
4225
+ const width = (pick) => Math.max(...rows.map((row) => pick(row).length));
4226
+ const nameWidth = width((row) => row.name);
4227
+ const runtimeWidth = width((row) => row.runtime);
4228
+ const statusWidth = width((row) => row.status);
4229
+ for (const row of rows) {
4230
+ io.stdout(
4231
+ `${row.name.padEnd(nameWidth)} ${row.runtime.padEnd(runtimeWidth)} ${row.status.padEnd(statusWidth)} ${row.checkout}
4232
+ `
4233
+ );
4234
+ }
4235
+ return 0;
4236
+ }
4237
+
4238
+ // src/commands/sandbox.ts
4239
+ function hostTimezone() {
4240
+ const tz = process.env.TZ;
4241
+ return tz !== void 0 && tz !== "" ? tz : "UTC";
4242
+ }
4243
+ function gitIdentity(run, key) {
4244
+ let result;
4245
+ try {
4246
+ result = run("git", ["config", "--global", key], { stdio: "capture" });
4247
+ } catch {
4248
+ return "";
4249
+ }
4250
+ if (result.error || result.status !== 0) return "";
4251
+ return (result.stdout ?? "").trim();
4252
+ }
4253
+ function isBuildCapable(repo) {
4254
+ return repo !== void 0 && existsSync16(join13(repo, ".devcontainer", "Dockerfile"));
4255
+ }
4256
+ function prepareHomeMount(value) {
4257
+ const path4 = resolve9(value);
4258
+ if (existsSync16(path4)) {
4259
+ if (!statSync4(path4).isDirectory()) {
4260
+ throw new Error(`--home-mount path exists and is not a directory: ${path4}`);
4261
+ }
4262
+ return path4;
4263
+ }
4264
+ mkdirSync8(path4, { recursive: true });
4265
+ return path4;
4266
+ }
4267
+ function readSeedConfig(repo) {
4268
+ if (repo === void 0) return void 0;
4269
+ const file = agroConfigPath(repo);
4270
+ return existsSync16(file) ? readAgroConfig(file) : void 0;
3820
4271
  }
3821
4272
  function readEntryConfig(name) {
3822
4273
  const file = agroConfigPath(entryRoot(name));
@@ -3847,18 +4298,18 @@ function mergeSettings(...sources) {
3847
4298
  for (const source of present) overlaySettings(merged, source);
3848
4299
  return merged;
3849
4300
  }
3850
- function nonEmpty(value) {
4301
+ function nonEmpty2(value) {
3851
4302
  return value === void 0 || value === "" ? void 0 : value;
3852
4303
  }
3853
- function seedConfig(name, checkout, seed, run) {
4304
+ function seedConfig(name, checkout, seed, run, runtime = "docker") {
3854
4305
  const config = overlaySettings(defaultAgroConfig(name), seed);
3855
4306
  config.name = name;
3856
- config.runtime = "docker";
4307
+ config.runtime = runtime;
3857
4308
  if (checkout !== void 0) config.checkout = checkout;
3858
- config.timezone = nonEmpty(seed?.timezone) ?? hostTimezone();
4309
+ config.timezone = nonEmpty2(seed?.timezone) ?? hostTimezone();
3859
4310
  config.git = {
3860
- userName: nonEmpty(seed?.git?.userName) ?? gitIdentity(run, "user.name"),
3861
- userEmail: nonEmpty(seed?.git?.userEmail) ?? gitIdentity(run, "user.email")
4311
+ userName: nonEmpty2(seed?.git?.userName) ?? gitIdentity(run, "user.name"),
4312
+ userEmail: nonEmpty2(seed?.git?.userEmail) ?? gitIdentity(run, "user.email")
3862
4313
  };
3863
4314
  config.image = {
3864
4315
  ...config.image,
@@ -3899,7 +4350,7 @@ async function runWizard(config, io) {
3899
4350
  "Host path for /home/sandbox (blank keeps the Docker-managed volume)",
3900
4351
  config.storage?.homePath ?? ""
3901
4352
  );
3902
- if (homePath !== "") config.storage = { ...config.storage, homePath: resolve7(homePath) };
4353
+ if (homePath !== "") config.storage = { ...config.storage, homePath: resolve9(homePath) };
3903
4354
  }
3904
4355
  async function runSandboxInstall(opts, io) {
3905
4356
  const run = opts.run ?? spawnRunner;
@@ -3916,7 +4367,8 @@ async function runSandboxInstall(opts, io) {
3916
4367
  `);
3917
4368
  return 1;
3918
4369
  }
3919
- const checkout = opts.checkout === void 0 ? void 0 : resolve7(opts.checkout);
4370
+ if (runtime.id === "openshell") return await installOpenShell(opts, io, run);
4371
+ const checkout = opts.checkout === void 0 ? void 0 : resolve9(opts.checkout);
3920
4372
  if (checkout !== void 0 && !existsSync16(checkout)) {
3921
4373
  io.stderr(`${opts.bin} sandbox install: --checkout directory does not exist: ${checkout}
3922
4374
  `);
@@ -3934,9 +4386,9 @@ async function runSandboxInstall(opts, io) {
3934
4386
  const config = seedConfig(name, checkout, mergeSettings(readEntryConfig(name), repoSeed), run);
3935
4387
  const buildRequested = config.image?.mode === "build" && opts.noBuild !== true && opts.image !== true && opts.imageRef === void 0;
3936
4388
  if (buildRequested && !isBuildCapable(configCheckout(config))) {
3937
- const target = configCheckout(config) ?? resolve7(opts.checkout ?? ".");
4389
+ const target = configCheckout(config) ?? resolve9(opts.checkout ?? ".");
3938
4390
  io.stderr(
3939
- `${opts.bin} sandbox install: image.mode is "build" but ${join12(target, ".devcontainer", "Dockerfile")} does not exist \u2014 point --checkout <dir> at a harness checkout that has .devcontainer/Dockerfile, or set image.mode to "image" to run the prebuilt image
4391
+ `${opts.bin} sandbox install: image.mode is "build" but ${join13(target, ".devcontainer", "Dockerfile")} does not exist \u2014 point --checkout <dir> at a harness checkout that has .devcontainer/Dockerfile, or set image.mode to "image" to run the prebuilt image
3940
4392
  `
3941
4393
  );
3942
4394
  return 1;
@@ -3980,7 +4432,7 @@ async function runSandboxInstall(opts, io) {
3980
4432
  ...useNoBuild ? { noBuild: true } : {}
3981
4433
  };
3982
4434
  if (opts.printArgv === true) {
3983
- const preview = mkdtempSync3(join12(tmpdir3(), "oh-sandbox-preview-"));
4435
+ const preview = mkdtempSync3(join13(tmpdir3(), "oh-sandbox-preview-"));
3984
4436
  try {
3985
4437
  writeAgroConfig(preview, config);
3986
4438
  materialize(preview, { ...configCheckout(config) !== void 0 ? { checkout: configCheckout(config) } : {} });
@@ -4001,58 +4453,450 @@ async function runSandboxInstall(opts, io) {
4001
4453
  }
4002
4454
  return code;
4003
4455
  }
4004
- async function entryStatus(root, name, run) {
4456
+ async function installOpenShell(opts, io, run) {
4457
+ const fail2 = (message) => {
4458
+ io.stderr(`${opts.bin} sandbox install: ${message}
4459
+ `);
4460
+ return 1;
4461
+ };
4462
+ const unsupported = (what) => fail2(`${what} is not supported on the openshell runtime`);
4463
+ if (opts.checkout !== void 0) return unsupported("--checkout");
4464
+ if (opts.homeMount !== void 0) return unsupported("--home-mount");
4465
+ const name = opts.name ?? nextDefaultName(run);
4005
4466
  try {
4006
- const target = resolveExecutionTarget({ projectRoot: root, container: name, run });
4007
- return await target.status();
4008
- } catch {
4009
- return "unknown";
4467
+ assertSandboxName(name);
4468
+ } catch (error) {
4469
+ return fail2(error instanceof Error ? error.message : String(error));
4010
4470
  }
4011
- }
4012
- async function runSandboxList(opts, io) {
4013
- const run = opts.run ?? spawnRunner;
4014
- const rows = [];
4015
- for (const name of listEntries()) {
4016
- const root = entryRoot(name);
4017
- const config = readAgroConfig(agroConfigPath(root));
4018
- const checkout = configCheckout(config) ?? "-";
4019
- rows.push({
4020
- name,
4021
- runtime: config.runtime ?? "docker",
4022
- checkout,
4023
- repo: checkout,
4024
- status: await entryStatus(root, name, run)
4025
- });
4471
+ const config = seedConfig(name, void 0, readEntryConfig(name), run, "openshell");
4472
+ if (configCheckout(config) !== void 0) return unsupported("checkout");
4473
+ if (nonEmpty2(config.storage?.homePath) !== void 0) return unsupported("storage.homePath");
4474
+ const imageChosen = opts.image === true || opts.imageRef !== void 0;
4475
+ if (config.image?.mode === "build" && !imageChosen) {
4476
+ return unsupported('image.mode "build"');
4026
4477
  }
4027
- if (opts.json === true) {
4028
- io.stdout(`${JSON.stringify(rows, null, 2)}
4478
+ config.image = {
4479
+ ...config.image,
4480
+ mode: "image",
4481
+ ...opts.imageRef !== void 0 ? { ref: opts.imageRef } : {}
4482
+ };
4483
+ const root = entryRoot(name);
4484
+ const image = openshellImage(config);
4485
+ if (opts.printArgv === true) {
4486
+ io.stdout(`${[OPENSHELL_BIN, ...createArgv({ name, image, entryRoot: root })].join(" ")}
4029
4487
  `);
4030
4488
  return 0;
4031
4489
  }
4032
- if (rows.length === 0) {
4033
- io.stdout(
4034
- `no sandbox is registered in ${registryRoot()} \u2014 create one with \`${opts.bin} sandbox install docker\`
4035
- `
4036
- );
4037
- return 0;
4490
+ if (runningInsideSandbox()) return fail2(new HostOnlyError(`\`${opts.bin} sandbox\``).message);
4491
+ try {
4492
+ const preflight = openshellPreflight(run);
4493
+ if (!preflight.ok) return fail2(preflight.hint);
4494
+ } catch (error) {
4495
+ return fail2(error instanceof Error ? error.message : String(error));
4038
4496
  }
4039
- const width = (pick) => Math.max(...rows.map((row) => pick(row).length));
4040
- const nameWidth = width((row) => row.name);
4041
- const runtimeWidth = width((row) => row.runtime);
4042
- const statusWidth = width((row) => row.status);
4043
- for (const row of rows) {
4044
- io.stdout(
4045
- `${row.name.padEnd(nameWidth)} ${row.runtime.padEnd(runtimeWidth)} ${row.status.padEnd(statusWidth)} ${row.checkout}
4046
- `
4047
- );
4497
+ const created = !existsSync16(root);
4498
+ mkdirSync8(root, { recursive: true });
4499
+ writeAgroConfig(root, config);
4500
+ materialize(root);
4501
+ try {
4502
+ await new OpenShellExecutionTarget({ name, entryRoot: root, image, run }).provision();
4503
+ } catch (error) {
4504
+ if (created) rmSync3(root, { recursive: true, force: true });
4505
+ io.stderr(`${opts.bin} sandbox install: ${error instanceof Error ? error.message : String(error)}
4506
+ `);
4507
+ if (!(error instanceof ExecutionExitError)) return 1;
4508
+ io.stderr(`${opts.bin} sandbox install: ${openshellCleanupHint(name)}
4509
+ `);
4510
+ return error.exitCode;
4048
4511
  }
4512
+ io.stdout(`next: ${opts.bin} shell ${name}
4513
+ `);
4514
+ maybePrintStarPrompt(io);
4049
4515
  return 0;
4050
4516
  }
4051
4517
 
4518
+ // src/services/sandbox-upgrade.ts
4519
+ import { closeSync as closeSync2, existsSync as existsSync17, openSync as openSync2, readFileSync as readFileSync11, unlinkSync as unlinkSync5, writeSync } from "node:fs";
4520
+ import { join as join14 } from "node:path";
4521
+ function errorMessage(error) {
4522
+ return error instanceof Error ? error.message : String(error);
4523
+ }
4524
+ function conflictingDotenvImage(root, imageRef) {
4525
+ const rootEnv = join14(root, ".env");
4526
+ const fallbackEnv = join14(root, ".devcontainer", ".env");
4527
+ const file = existsSync17(rootEnv) ? rootEnv : fallbackEnv;
4528
+ if (!existsSync17(file)) return void 0;
4529
+ for (const line of readFileSync11(file, "utf8").split(/\r?\n/)) {
4530
+ const match = /^AGRO_SANDBOX_IMAGE=(.*)$/.exec(line);
4531
+ if (!match) continue;
4532
+ const value = match[1].trim().replace(/^(?:"(.*)"|'(.*)')$/, (_whole, double, single) => double ?? single ?? "");
4533
+ if (value !== imageRef) return file;
4534
+ }
4535
+ return void 0;
4536
+ }
4537
+ async function runSandboxUpgrade(opts, io) {
4538
+ const prefix = `${opts.bin} sandbox upgrade:`;
4539
+ const version = parseReleaseVersion(opts.version);
4540
+ if (version === void 0) {
4541
+ io.stderr(`${prefix} invalid release version "${opts.version}"
4542
+ `);
4543
+ return 1;
4544
+ }
4545
+ if (runningInsideSandbox()) {
4546
+ io.stderr(`${prefix} host-only \u2014 run this command on the host
4547
+ `);
4548
+ return 1;
4549
+ }
4550
+ let root;
4551
+ try {
4552
+ root = entryRoot(opts.name);
4553
+ } catch (error) {
4554
+ io.stderr(`${prefix} ${errorMessage(error)}
4555
+ `);
4556
+ return 1;
4557
+ }
4558
+ const configFile = agroConfigPath(root);
4559
+ if (!existsSync17(configFile)) {
4560
+ io.stderr(`${prefix} no sandbox entry named "${opts.name}"
4561
+ `);
4562
+ return 1;
4563
+ }
4564
+ routeVerb(root, "upgrade", entrySandboxName(root) ?? opts.name);
4565
+ const lock = join14(root, ".sandbox-upgrade.lock");
4566
+ let fd;
4567
+ try {
4568
+ fd = openSync2(lock, "wx", 384);
4569
+ } catch (error) {
4570
+ const message = error instanceof Error && "code" in error && error.code === "EEXIST" ? `upgrade already in progress for ${opts.name}; lock: ${lock}. Confirm no upgrade process owns this entry before removing that lock and retrying` : errorMessage(error);
4571
+ io.stderr(`${prefix} ${message}
4572
+ `);
4573
+ return 1;
4574
+ }
4575
+ try {
4576
+ writeSync(fd, `${process.pid}
4577
+ `);
4578
+ const config = readAgroConfig(configFile);
4579
+ if (config.image?.mode !== "image") {
4580
+ io.stderr(`${prefix} ${opts.name} requires image.mode "image"
4581
+ `);
4582
+ return 1;
4583
+ }
4584
+ const imageRef = officialImageRef(version);
4585
+ if (process.env.AGRO_SANDBOX_IMAGE !== void 0 && process.env.AGRO_SANDBOX_IMAGE !== "" && process.env.AGRO_SANDBOX_IMAGE !== imageRef) {
4586
+ io.stderr(`${prefix} conflicting AGRO_SANDBOX_IMAGE in the host environment
4587
+ `);
4588
+ return 1;
4589
+ }
4590
+ const dotenv = conflictingDotenvImage(root, imageRef);
4591
+ if (dotenv !== void 0) {
4592
+ io.stderr(`${prefix} conflicting AGRO_SANDBOX_IMAGE in ${dotenv}
4593
+ `);
4594
+ return 1;
4595
+ }
4596
+ const run = opts.run ?? spawnRunner;
4597
+ let priorRef = config.image.ref;
4598
+ if (!priorRef) {
4599
+ const name = configuredContainerName(root) ?? DEFAULT_CONTAINER_NAME;
4600
+ const inspected = run("docker", ["inspect", "-f", "{{.Config.Image}}", name], { stdio: "capture" });
4601
+ priorRef = inspected.status === 0 && !inspected.error ? inspected.stdout?.trim() : void 0;
4602
+ if (!priorRef) {
4603
+ io.stderr(`${prefix} cannot determine previous image for ${name}; no changes made
4604
+ `);
4605
+ return 1;
4606
+ }
4607
+ }
4608
+ const apply = (ref) => runSandbox({ bin: opts.bin, cwd: root, imageRef: ref, run }, io);
4609
+ let failure;
4610
+ try {
4611
+ const code = await apply(imageRef);
4612
+ if (code !== 0) failure = `exit ${code}`;
4613
+ } catch (error) {
4614
+ failure = errorMessage(error);
4615
+ }
4616
+ if (failure === void 0) {
4617
+ try {
4618
+ (opts.writeConfig ?? writeAgroConfig)(root, { ...config, image: { ...config.image, ref: imageRef } });
4619
+ return 0;
4620
+ } catch (error) {
4621
+ failure = `config persistence failed (${errorMessage(error)})`;
4622
+ }
4623
+ } else {
4624
+ failure = `provisioning failed (${failure})`;
4625
+ }
4626
+ io.stderr(`${prefix} ${failure}; restoring previous image
4627
+ `);
4628
+ try {
4629
+ const code = await apply(priorRef);
4630
+ if (code !== 0) io.stderr(`${prefix} restoration failed (exit ${code})
4631
+ `);
4632
+ } catch (error) {
4633
+ io.stderr(`${prefix} restoration failed (${errorMessage(error)})
4634
+ `);
4635
+ }
4636
+ return 1;
4637
+ } catch (error) {
4638
+ io.stderr(`${prefix} ${errorMessage(error)}
4639
+ `);
4640
+ return 1;
4641
+ } finally {
4642
+ closeSync2(fd);
4643
+ unlinkSync5(lock);
4644
+ }
4645
+ }
4646
+
4647
+ // src/controllers/sandbox.ts
4648
+ function isHelpFlag(arg) {
4649
+ return arg === "--help" || arg === "-h" || arg === "help";
4650
+ }
4651
+ function runtimeLines() {
4652
+ const width = Math.max(...RUNTIME_CATALOG.map((r) => r.id.length));
4653
+ return RUNTIME_CATALOG.map(
4654
+ (r) => ` ${r.id.padEnd(width)} ${r.provisionable ? "provisionable" : "planned"}`
4655
+ ).join("\n");
4656
+ }
4657
+ function printSandboxHelp(bin = AGRO_PRODUCT.bin) {
4658
+ process.stdout.write(`${bin} sandbox \u2014 Create and list sandboxes
4659
+
4660
+ Usage:
4661
+ ${bin} sandbox install <runtime> [--name <name>] [--checkout <dir>]
4662
+ [--home-mount <dir>] [--yes]
4663
+ [--version <X.Y.Z> | --image[=<ref>]]
4664
+ [--no-build] [--print-argv]
4665
+ ${bin} sandbox list [--json]
4666
+ ${bin} sandbox upgrade <name> --version <X.Y.Z>
4667
+
4668
+ \`install\` writes a sandbox entry under \${${stateNames(bin).envPrefix}HOME:-~/${stateNames(bin).userStateDir}}/sandboxes/<name>/,
4669
+ materialises the compose files and the compose wrapper into it, then starts the
4670
+ container. It needs no project checkout and runs from any directory. Without
4671
+ --checkout the sandbox runs the prebuilt image; with --checkout <dir> that
4672
+ checkout is bound at /home/sandbox/harness; the sandbox builds locally only
4673
+ when that directory holds .devcontainer/Dockerfile.
4674
+
4675
+ On a terminal without --yes it asks for the sandbox name, the timezone, the git
4676
+ identity, SSH (with its host port), the host Docker socket, and the host path
4677
+ for /home/sandbox. With --checkout it seeds those answers from that
4678
+ checkout's ${stateNames(bin).configFile}.
4679
+
4680
+ \`install openshell\` never prompts. The command needs the host \`openshell\` CLI
4681
+ and a connected gateway, and runs the prebuilt image. The command refuses
4682
+ --checkout, --home-mount, and image.mode "build".
4683
+
4684
+ Flags:
4685
+ --name <name> Registry entry name (default: the lowest free ${DEFAULT_NAME_PREFIX}<n>)
4686
+ --checkout <dir> Bind this checkout into the sandbox and seed the defaults
4687
+ from its ${stateNames(bin).configFile}. The sandbox builds
4688
+ locally only when that directory holds
4689
+ .devcontainer/Dockerfile
4690
+ --repo <dir> Deprecated alias for --checkout
4691
+ --home-mount <dir>
4692
+ Bind this host directory at /home/sandbox instead of the
4693
+ Docker-managed volume. Relative paths resolve against the
4694
+ working directory. The directory is created when absent
4695
+ --yes Non-interactive: keep every default and ask nothing
4696
+ --version <X.Y.Z>
4697
+ Run the official release image ghcr.io/mifunedev/agro:<X.Y.Z>
4698
+ (implies --image). A leading v and a -<channel>.<n>
4699
+ pre-release suffix are accepted
4700
+ --image Run the prebuilt image instead of building (implies
4701
+ --no-build). The ref resolves first-match: --version or
4702
+ --image=<ref> > ${stateNames(bin).envPrefix}SANDBOX_IMAGE >
4703
+ ${stateNames(bin).configFile} image.ref > ${officialImageRef(AGRO_VERSION)}
4704
+ --image=<ref> Run a custom image (implies --image); conflicts with --version
4705
+ --no-build Suppress the local build and reuse an existing image
4706
+ --print-argv Print the docker compose or openshell argv that would run, then exit
4707
+ without writing an entry
4708
+ --json Machine-readable output (list)
4709
+
4710
+ Runtimes:
4711
+ ${runtimeLines()}
4712
+
4713
+ Next: ${bin} shell <name>
4714
+ `);
4715
+ }
4716
+ var SANDBOX_VALUE_FLAGS = {
4717
+ "--name": "name",
4718
+ "--checkout": "checkout",
4719
+ "--repo": "checkout",
4720
+ "--home-mount": "homeMount"
4721
+ };
4722
+ function parseSandboxArgs(rest, bin = AGRO_PRODUCT.bin) {
4723
+ const args = {
4724
+ help: false,
4725
+ yes: false,
4726
+ image: false,
4727
+ noBuild: false,
4728
+ printArgv: false,
4729
+ json: false
4730
+ };
4731
+ if (rest.length === 0 || isHelpFlag(rest[0])) {
4732
+ return { ok: true, args: { ...args, help: true } };
4733
+ }
4734
+ const [head, ...tail] = rest;
4735
+ if (head === "upgrade") {
4736
+ if (tail.length === 1 && isHelpFlag(tail[0])) {
4737
+ return { ok: true, args: { ...args, subcommand: "upgrade", help: true } };
4738
+ }
4739
+ const [name, flag, value, ...extra] = tail;
4740
+ if (name === void 0 || name === "" || name.startsWith("-")) {
4741
+ return { ok: false, error: `${bin} sandbox upgrade: a name is required` };
4742
+ }
4743
+ if (flag === void 0) {
4744
+ return { ok: false, error: `${bin} sandbox upgrade: --version is required` };
4745
+ }
4746
+ if (flag !== "--version") {
4747
+ return { ok: false, error: `${bin} sandbox upgrade: ${flag.startsWith("-") ? "unknown flag" : "unexpected argument"} "${flag}"` };
4748
+ }
4749
+ if (value === void 0 || value === "") {
4750
+ return { ok: false, error: `${bin} sandbox upgrade: --version requires a value` };
4751
+ }
4752
+ if (value.startsWith("-")) {
4753
+ return { ok: false, error: `${bin} sandbox upgrade: unknown flag "${value}"` };
4754
+ }
4755
+ const version2 = parseReleaseVersion(value);
4756
+ if (version2 === void 0) {
4757
+ return { ok: false, error: `${bin} sandbox upgrade: --version "${value}" is not a release version \u2014 expected X.Y.Z` };
4758
+ }
4759
+ if (extra.length > 0) {
4760
+ const token = extra[0];
4761
+ return { ok: false, error: `${bin} sandbox upgrade: ${token.startsWith("-") && token !== "--version" ? "unknown flag" : "unexpected argument"} "${token}"` };
4762
+ }
4763
+ return { ok: true, args: { ...args, subcommand: "upgrade", name, version: version2 } };
4764
+ }
4765
+ if (head !== "install" && head !== "list") {
4766
+ return {
4767
+ ok: false,
4768
+ error: `${bin} sandbox: unknown subcommand "${head}" \u2014 expected install or list`,
4769
+ showHelp: true
4770
+ };
4771
+ }
4772
+ args.subcommand = head;
4773
+ if (isHelpFlag(tail[0])) return { ok: true, args: { ...args, help: true } };
4774
+ const positionals = [];
4775
+ const checkoutSpellings = /* @__PURE__ */ new Set();
4776
+ let version;
4777
+ for (let i = 0; i < tail.length; i++) {
4778
+ const token = tail[i];
4779
+ const valueFlag = SANDBOX_VALUE_FLAGS[token];
4780
+ if (valueFlag !== void 0) {
4781
+ const value = tail[i + 1];
4782
+ if (value === void 0) {
4783
+ return { ok: false, error: `${bin} sandbox ${head}: ${token} requires a value` };
4784
+ }
4785
+ if (valueFlag === "checkout") checkoutSpellings.add(token);
4786
+ args[valueFlag] = value;
4787
+ i++;
4788
+ } else if (token === "--yes") {
4789
+ args.yes = true;
4790
+ } else if (token === "--no-build") {
4791
+ args.noBuild = true;
4792
+ } else if (token === "--print-argv") {
4793
+ args.printArgv = true;
4794
+ } else if (token === "--image") {
4795
+ args.image = true;
4796
+ } else if (token.startsWith("--image=")) {
4797
+ const ref = token.slice("--image=".length);
4798
+ if (ref === "") {
4799
+ return { ok: false, error: `${bin} sandbox: --image=<ref> requires a non-empty image ref` };
4800
+ }
4801
+ args.image = true;
4802
+ args.imageRef = ref;
4803
+ } else if (token === "--version" || token.startsWith("--version=")) {
4804
+ const value = token === "--version" ? tail[++i] : token.slice("--version=".length);
4805
+ if (value === void 0 || value === "") {
4806
+ return { ok: false, error: `${bin} sandbox ${head}: --version requires a value` };
4807
+ }
4808
+ version = parseReleaseVersion(value);
4809
+ if (version === void 0) {
4810
+ return {
4811
+ ok: false,
4812
+ error: `${bin} sandbox ${head}: --version "${value}" is not a release version \u2014 expected X.Y.Z`
4813
+ };
4814
+ }
4815
+ } else if (token === "--json") {
4816
+ args.json = true;
4817
+ } else if (token.startsWith("-")) {
4818
+ return { ok: false, error: `${bin} sandbox ${head}: unknown flag "${token}"` };
4819
+ } else {
4820
+ positionals.push(token);
4821
+ }
4822
+ }
4823
+ if (checkoutSpellings.has("--checkout") && checkoutSpellings.has("--repo")) {
4824
+ return {
4825
+ ok: false,
4826
+ error: `${bin} sandbox ${head}: --checkout conflicts with --repo \u2014 pass exactly one, and prefer --checkout`
4827
+ };
4828
+ }
4829
+ if (version !== void 0) {
4830
+ if (args.imageRef !== void 0) {
4831
+ return {
4832
+ ok: false,
4833
+ error: `${bin} sandbox ${head}: --version conflicts with --image=<ref> \u2014 pass --version for an official release or --image=<ref> for a custom image`
4834
+ };
4835
+ }
4836
+ args.image = true;
4837
+ args.imageRef = officialImageRef(version);
4838
+ }
4839
+ if (head === "list") {
4840
+ if (positionals.length > 0) {
4841
+ return { ok: false, error: `${bin} sandbox list: unexpected argument "${positionals[0]}"` };
4842
+ }
4843
+ return { ok: true, args };
4844
+ }
4845
+ if (positionals.length === 0) {
4846
+ return {
4847
+ ok: false,
4848
+ error: `${bin} sandbox install: a runtime is required, e.g. \`${bin} sandbox install docker\``,
4849
+ showHelp: true
4850
+ };
4851
+ }
4852
+ if (positionals.length > 1) {
4853
+ return { ok: false, error: `${bin} sandbox install: unexpected argument "${positionals[1]}"` };
4854
+ }
4855
+ args.runtime = positionals[0];
4856
+ return { ok: true, args };
4857
+ }
4858
+ async function runSandboxCommand(rest, bin) {
4859
+ const parsed = parseSandboxArgs(rest, bin);
4860
+ if (!parsed.ok) {
4861
+ process.stderr.write(`${parsed.error}
4862
+ `);
4863
+ if (parsed.showHelp) printSandboxHelp(bin);
4864
+ return 1;
4865
+ }
4866
+ const a = parsed.args;
4867
+ if (a.help) {
4868
+ printSandboxHelp(bin);
4869
+ return a.subcommand === void 0 ? 1 : 0;
4870
+ }
4871
+ const io = {
4872
+ stdout: (s) => process.stdout.write(s),
4873
+ stderr: (s) => process.stderr.write(s)
4874
+ };
4875
+ if (a.subcommand === "list") return await runSandboxList({ bin, json: a.json }, io);
4876
+ if (a.subcommand === "upgrade") {
4877
+ return await runSandboxUpgrade({ bin, name: a.name, version: a.version }, io);
4878
+ }
4879
+ return await runSandboxInstall(
4880
+ {
4881
+ bin,
4882
+ runtime: a.runtime,
4883
+ ...a.name !== void 0 ? { name: a.name } : {},
4884
+ ...a.checkout !== void 0 ? { checkout: a.checkout } : {},
4885
+ ...a.homeMount !== void 0 ? { homeMount: a.homeMount } : {},
4886
+ yes: a.yes,
4887
+ image: a.image,
4888
+ ...a.imageRef !== void 0 ? { imageRef: a.imageRef } : {},
4889
+ noBuild: a.noBuild,
4890
+ printArgv: a.printArgv
4891
+ },
4892
+ io
4893
+ );
4894
+ }
4895
+
4052
4896
  // src/commands/harness.ts
4053
- import { existsSync as existsSync19 } from "node:fs";
4897
+ import { existsSync as existsSync20 } from "node:fs";
4054
4898
  import { homedir as homedir3 } from "node:os";
4055
- import { delimiter as delimiter2, join as join15 } from "node:path";
4899
+ import { delimiter as delimiter2, join as join17 } from "node:path";
4056
4900
 
4057
4901
  // src/lib/docs.ts
4058
4902
  var SOURCE_DOCS_BASE = "https://github.com/mifunedev/agro/blob/main/";
@@ -4065,17 +4909,20 @@ function sourceDocsUrl(docsPath) {
4065
4909
  }
4066
4910
 
4067
4911
  // src/lib/host-config.ts
4068
- import { existsSync as existsSync17, mkdirSync as mkdirSync9, readFileSync as readFileSync11, renameSync as renameSync4, unlinkSync as unlinkSync5, writeFileSync as writeFileSync9 } from "node:fs";
4069
- import { basename as basename5, dirname as dirname8, isAbsolute, join as join13, resolve as resolve8 } from "node:path";
4912
+ import { existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync12, renameSync as renameSync4, unlinkSync as unlinkSync6, writeFileSync as writeFileSync9 } from "node:fs";
4913
+ import { basename as basename7, dirname as dirname8, isAbsolute, join as join15, resolve as resolve10 } from "node:path";
4070
4914
  var HOST_CONFIG_MODE = 420;
4071
4915
  var HOST_CONFIG_FILE = "config.json";
4072
4916
  var WORKSPACES_SUBDIR = "workspaces";
4073
4917
  var DEFAULT_WORKSPACE_NAME = "harness";
4918
+ function isRootToolReceipt(receipt) {
4919
+ return receipt.scope === "root";
4920
+ }
4074
4921
  function hostStateHome(env, home) {
4075
4922
  return resolveUserStateHome(env, home);
4076
4923
  }
4077
4924
  function hostConfigPath(env, home) {
4078
- return join13(hostStateHome(env, home), HOST_CONFIG_FILE);
4925
+ return join15(hostStateHome(env, home), HOST_CONFIG_FILE);
4079
4926
  }
4080
4927
  function assertWorkspaceName(name) {
4081
4928
  if (!SANDBOX_NAME_PATTERN.test(name)) {
@@ -4085,14 +4932,14 @@ function assertWorkspaceName(name) {
4085
4932
  }
4086
4933
  }
4087
4934
  function workspacesRoot(env, home) {
4088
- return join13(hostStateHome(env, home), WORKSPACES_SUBDIR);
4935
+ return join15(hostStateHome(env, home), WORKSPACES_SUBDIR);
4089
4936
  }
4090
4937
  function workspaceRoot(name, env, home) {
4091
4938
  assertWorkspaceName(name);
4092
- return join13(workspacesRoot(env, home), name);
4939
+ return join15(workspacesRoot(env, home), name);
4093
4940
  }
4094
4941
  function defaultHarnessRoot(env, home) {
4095
- return join13(workspacesRoot(env, home), DEFAULT_WORKSPACE_NAME);
4942
+ return join15(workspacesRoot(env, home), DEFAULT_WORKSPACE_NAME);
4096
4943
  }
4097
4944
  function validateHostConfig(value) {
4098
4945
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -4114,7 +4961,7 @@ function validateHostConfig(value) {
4114
4961
  const hostHarnesses = record.hostHarnesses;
4115
4962
  if (hostHarnesses !== void 0) validateReceipts(hostHarnesses, "hostHarnesses", "harness");
4116
4963
  const hostTools = record.hostTools;
4117
- if (hostTools !== void 0) validateReceipts(hostTools, "hostTools", "tool");
4964
+ if (hostTools !== void 0) validateReceipts(hostTools, "hostTools", "tool", true);
4118
4965
  return { ...record, version: 1 };
4119
4966
  }
4120
4967
  function absoluteField(value, path4) {
@@ -4124,7 +4971,7 @@ function absoluteField(value, path4) {
4124
4971
  if (!isAbsolute(value)) throw fieldError2(path4, "must be an absolute path");
4125
4972
  return value;
4126
4973
  }
4127
- function validateReceipts(value, field, noun) {
4974
+ function validateReceipts(value, field, noun, allowRootScope = false) {
4128
4975
  if (!value || typeof value !== "object" || Array.isArray(value)) {
4129
4976
  throw fieldError2(field, `must be a JSON object keyed by ${noun} id`);
4130
4977
  }
@@ -4135,8 +4982,15 @@ function validateReceipts(value, field, noun) {
4135
4982
  throw fieldError2(at, "must be a JSON object");
4136
4983
  }
4137
4984
  const entry = receipt;
4138
- absoluteField(entry.prefix, `${at}.prefix`);
4139
- absoluteField(entry.binPath, `${at}.binPath`);
4985
+ if (allowRootScope && entry.scope !== void 0) {
4986
+ if (entry.scope !== "root") throw fieldError2(`${at}.scope`, 'must be "root" when present');
4987
+ for (const key of ["prefix", "binPath"]) {
4988
+ if (entry[key] !== void 0) throw fieldError2(`${at}.${key}`, 'must be absent when scope is "root"');
4989
+ }
4990
+ } else {
4991
+ absoluteField(entry.prefix, `${at}.prefix`);
4992
+ absoluteField(entry.binPath, `${at}.binPath`);
4993
+ }
4140
4994
  if (typeof entry.binary !== "string" || entry.binary === "") {
4141
4995
  throw fieldError2(`${at}.binary`, "must be a non-empty string");
4142
4996
  }
@@ -4150,10 +5004,10 @@ function validateReceipts(value, field, noun) {
4150
5004
  }
4151
5005
  function readHostConfig(env, home) {
4152
5006
  const path4 = hostConfigPath(env, home);
4153
- if (!existsSync17(path4)) return { version: 1 };
5007
+ if (!existsSync18(path4)) return { version: 1 };
4154
5008
  let raw;
4155
5009
  try {
4156
- raw = readFileSync11(path4, "utf8");
5010
+ raw = readFileSync12(path4, "utf8");
4157
5011
  } catch (error) {
4158
5012
  const detail = error instanceof Error ? error.message : String(error);
4159
5013
  throw new Error(`could not read ${path4}: ${detail}`);
@@ -4162,7 +5016,7 @@ function readHostConfig(env, home) {
4162
5016
  try {
4163
5017
  parsed = JSON.parse(raw);
4164
5018
  } catch {
4165
- throw new Error(`${basename5(path4)} is not valid JSON: ${path4}`);
5019
+ throw new Error(`${basename7(path4)} is not valid JSON: ${path4}`);
4166
5020
  }
4167
5021
  return validateHostConfig(parsed);
4168
5022
  }
@@ -4178,22 +5032,22 @@ function writeHostConfig(config, env, home) {
4178
5032
  renameSync4(tmp, path4);
4179
5033
  } catch (error) {
4180
5034
  try {
4181
- unlinkSync5(tmp);
5035
+ unlinkSync6(tmp);
4182
5036
  } catch {
4183
5037
  }
4184
5038
  throw error;
4185
5039
  }
4186
5040
  }
4187
5041
  function resolveHarnessRoot(explicit, env, home) {
4188
- if (typeof explicit === "string" && explicit !== "") return resolve8(explicit);
5042
+ if (typeof explicit === "string" && explicit !== "") return resolve10(explicit);
4189
5043
  const configured = readHostConfig(env, home).harnessRoot;
4190
- if (typeof configured === "string" && configured !== "") return resolve8(configured);
5044
+ if (typeof configured === "string" && configured !== "") return resolve10(configured);
4191
5045
  return defaultHarnessRoot(env, home);
4192
5046
  }
4193
5047
  function recordHarnessRoot(root, env, home) {
4194
5048
  const config = readHostConfig(env, home);
4195
5049
  const recorded = config.harnessRoot;
4196
- if (typeof recorded === "string" && recorded !== "" && resolve8(recorded) === root) return;
5050
+ if (typeof recorded === "string" && recorded !== "" && resolve10(recorded) === root) return;
4197
5051
  writeHostConfig({ ...config, harnessRoot: root }, env, home);
4198
5052
  }
4199
5053
  function fieldError2(path4, requirement) {
@@ -4201,8 +5055,8 @@ function fieldError2(path4, requirement) {
4201
5055
  }
4202
5056
 
4203
5057
  // src/lib/host-workspace.ts
4204
- import { existsSync as existsSync18, mkdirSync as mkdirSync10, mkdtempSync as mkdtempSync4, readdirSync as readdirSync3, renameSync as renameSync5, rmSync as rmSync4, statSync as statSync5 } from "node:fs";
4205
- import { dirname as dirname9, join as join14, resolve as resolve9 } from "node:path";
5058
+ import { existsSync as existsSync19, mkdirSync as mkdirSync10, mkdtempSync as mkdtempSync4, readdirSync as readdirSync3, renameSync as renameSync5, rmSync as rmSync4, statSync as statSync5 } from "node:fs";
5059
+ import { dirname as dirname9, join as join16, resolve as resolve11 } from "node:path";
4206
5060
  var AGRO_REPO_URL = "https://github.com/mifunedev/agro.git";
4207
5061
  var IGNORABLE_ENTRIES = [
4208
5062
  REGISTRY_SUBDIR,
@@ -4214,15 +5068,15 @@ function isDirectory3(path4) {
4214
5068
  return statSync5(path4, { throwIfNoEntry: false })?.isDirectory() === true;
4215
5069
  }
4216
5070
  function stateHomes(home) {
4217
- return STATE_HOME_DIRS.map((name) => join14(home, name));
5071
+ return STATE_HOME_DIRS.map((name) => join16(home, name));
4218
5072
  }
4219
5073
  function stateHomeRootRefusal(bin, root, home, verb = "harness") {
4220
- const target = resolve9(root);
5074
+ const target = resolve11(root);
4221
5075
  const enclosing = stateHomes(home).find((state) => target === state);
4222
5076
  if (enclosing === void 0) return void 0;
4223
5077
  return `${bin} ${verb}: the ${verb} root ${target} is the state home ${enclosing} itself.
4224
5078
  A workspace there collides with AGRO state and blocks every command run from ${home}.
4225
- Move it out \u2014 for example \`mv ${enclosing} ${join14(home, "agro")}\` \u2014 then re-run this command.
5079
+ Move it out \u2014 for example \`mv ${enclosing} ${join16(home, "agro")}\` \u2014 then re-run this command.
4226
5080
  `;
4227
5081
  }
4228
5082
  function isIgnorableEntry(name) {
@@ -4239,24 +5093,24 @@ function cloneInto(target, run, root, ref) {
4239
5093
  assertCloned(run("git", ["clone", ...branch, AGRO_REPO_URL, target], { stdio: "inherit" }), root);
4240
5094
  }
4241
5095
  function cloneThroughStaging(root, run, ref) {
4242
- const staging = mkdtempSync4(join14(dirname9(root), "agro-clone-"));
5096
+ const staging = mkdtempSync4(join16(dirname9(root), "agro-clone-"));
4243
5097
  try {
4244
- const checkout = join14(staging, "agro");
5098
+ const checkout = join16(staging, "agro");
4245
5099
  cloneInto(checkout, run, root, ref);
4246
5100
  for (const name of readdirSync3(checkout)) {
4247
- const destination = join14(root, name);
4248
- if (existsSync18(destination)) {
5101
+ const destination = join16(root, name);
5102
+ if (existsSync19(destination)) {
4249
5103
  throw new Error(`could not move ${name} into ${root} \u2014 the path already exists`);
4250
5104
  }
4251
- renameSync5(join14(checkout, name), destination);
5105
+ renameSync5(join16(checkout, name), destination);
4252
5106
  }
4253
5107
  } finally {
4254
5108
  rmSync4(staging, { recursive: true, force: true });
4255
5109
  }
4256
5110
  }
4257
5111
  function ensureHostWorkspace(path4, run = spawnRunner, ref) {
4258
- const root = resolve9(path4);
4259
- if (existsSync18(join14(root, ".git"))) return { root, action: "reused" };
5112
+ const root = resolve11(path4);
5113
+ if (existsSync19(join16(root, ".git"))) return { root, action: "reused" };
4260
5114
  const stats = statSync5(root, { throwIfNoEntry: false });
4261
5115
  if (stats === void 0) {
4262
5116
  mkdirSync10(dirname9(root), { recursive: true });
@@ -4278,11 +5132,11 @@ function ensureHostWorkspace(path4, run = spawnRunner, ref) {
4278
5132
  function listHostWorkspaces(env, home) {
4279
5133
  const root = workspacesRoot(env, home);
4280
5134
  if (!isDirectory3(root)) return [];
4281
- return readdirSync3(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).filter((name) => SANDBOX_NAME_PATTERN.test(name)).filter((name) => existsSync18(join14(root, name, ".git"))).sort();
5135
+ return readdirSync3(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).filter((name) => SANDBOX_NAME_PATTERN.test(name)).filter((name) => existsSync19(join16(root, name, ".git"))).sort();
4282
5136
  }
4283
5137
  function resolveExistingWorkspace(bin, verb, path4, env, home) {
4284
- const root = resolve9(path4);
4285
- if (existsSync18(join14(root, ".git"))) return { ok: true, root };
5138
+ const root = resolve11(path4);
5139
+ if (existsSync19(join16(root, ".git"))) return { ok: true, root };
4286
5140
  const existing = listHostWorkspaces(env, home);
4287
5141
  const inventory = existing.length === 0 ? "No host workspace exists yet.\n" : `Workspaces that exist: ${existing.join(", ")}
4288
5142
  `;
@@ -4312,7 +5166,7 @@ function targetFor(root, run, env) {
4312
5166
  });
4313
5167
  }
4314
5168
  function hostPrefix(home) {
4315
- return join15(home, ".local");
5169
+ return join17(home, ".local");
4316
5170
  }
4317
5171
  function pathEntries(env) {
4318
5172
  return (env.PATH ?? "").split(delimiter2).filter((part) => part !== "");
@@ -4346,6 +5200,21 @@ async function probeInstalled(target, entry, prefix, user, env) {
4346
5200
  throw err;
4347
5201
  }
4348
5202
  }
5203
+ function sandboxMarkerPath(entry) {
5204
+ return `${SANDBOX_HARNESS_PREFIX}/share/agro/harnesses/${entry.id}.installed`;
5205
+ }
5206
+ async function sandboxMarkerExists(target, entry) {
5207
+ const r = await target.exec({
5208
+ argv: ["test", "-f", sandboxMarkerPath(entry)],
5209
+ user: "sandbox",
5210
+ stdio: "capture",
5211
+ timeoutMs: PROBE_TIMEOUT_MS
5212
+ });
5213
+ return r.exitCode === 0;
5214
+ }
5215
+ function hostBinaryUnderPrefix(entry, prefix) {
5216
+ return existsSync20(join17(harnessBinPath(prefix), entry.binary));
5217
+ }
4349
5218
  function stateOf(entry, installed, location) {
4350
5219
  return {
4351
5220
  id: entry.id,
@@ -4365,7 +5234,7 @@ function probeableHost(env, home) {
4365
5234
  return void 0;
4366
5235
  }
4367
5236
  const prefix = hostPrefix(home);
4368
- if (!existsSync19(join15(root, ".git")) && !existsSync19(prefix)) return void 0;
5237
+ if (!existsSync20(join17(root, ".git")) && !existsSync20(prefix)) return void 0;
4369
5238
  return { root, prefix };
4370
5239
  }
4371
5240
  async function collectStates(root, run, home, env, only) {
@@ -4571,7 +5440,15 @@ Run: bash ${root}/.agro/scripts/link-providers.sh --init
4571
5440
  const code = await reconcileHermes(target, io, bin, void 0, root);
4572
5441
  if (code !== 0) return code;
4573
5442
  }
4574
- if (await probeInstalled(target, entry, prefix, void 0, installEnv) === true) {
5443
+ let hasReceipt;
5444
+ try {
5445
+ hasReceipt = readHostConfig(env, home).hostHarnesses?.[entry.id] !== void 0;
5446
+ } catch (err) {
5447
+ io.stderr(`${bin} harness: ${messageOf(err)}
5448
+ `);
5449
+ return 1;
5450
+ }
5451
+ if (await probeInstalled(target, entry, prefix, void 0, installEnv) === true && (hasReceipt || !hostBinaryUnderPrefix(entry, prefix))) {
4575
5452
  io.stdout(`${entry.id}: already installed (${entry.binary})
4576
5453
  `);
4577
5454
  try {
@@ -4735,7 +5612,20 @@ async function runHarnessUninstall(name, opts, io) {
4735
5612
  if (!isReachable(await resolveTargetStatus(target))) {
4736
5613
  return await uninstallOnHost(entry, opts, io, run);
4737
5614
  }
4738
- return (await removeHarness(entry, target, SANDBOX_HARNESS_PREFIX, "sandbox", opts, io)).code;
5615
+ const outcome = await removeHarness(entry, target, SANDBOX_HARNESS_PREFIX, "sandbox", opts, io);
5616
+ if (outcome.dropReceipt) {
5617
+ const cleared = await target.exec({
5618
+ argv: ["rm", "-f", sandboxMarkerPath(entry)],
5619
+ user: "sandbox",
5620
+ stdio: "capture"
5621
+ });
5622
+ if (cleared.exitCode !== 0) {
5623
+ io.stderr(`${opts.bin} harness: could not clear the install record (exit ${cleared.exitCode}).
5624
+ `);
5625
+ return 1;
5626
+ }
5627
+ }
5628
+ return outcome.code;
4739
5629
  }
4740
5630
  async function runHarnessInstall(name, opts, io) {
4741
5631
  const run = opts.run ?? spawnRunner;
@@ -4761,7 +5651,7 @@ async function runHarnessInstall(name, opts, io) {
4761
5651
  if (code !== 0) return code;
4762
5652
  }
4763
5653
  const already = await probeInstalled(target, entry, SANDBOX_HARNESS_PREFIX, "sandbox", installEnv);
4764
- if (already === true) {
5654
+ if (already === true && await sandboxMarkerExists(target, entry)) {
4765
5655
  io.stdout(`${entry.id}: already installed (${entry.binary})
4766
5656
  `);
4767
5657
  return 0;
@@ -4792,15 +5682,26 @@ async function runHarnessInstall(name, opts, io) {
4792
5682
  const code = await reconcileHermes(target, io, opts.bin, "sandbox");
4793
5683
  if (code !== 0) return code;
4794
5684
  }
5685
+ const marker = sandboxMarkerPath(entry);
5686
+ const marked = await target.exec({
5687
+ argv: ["sh", "-c", `mkdir -p "$(dirname "$1")" && : > "$1"`, "sh", marker],
5688
+ user: "sandbox",
5689
+ stdio: "capture"
5690
+ });
5691
+ if (marked.exitCode !== 0) {
5692
+ io.stderr(`${opts.bin} harness: could not record the install at ${marker} (exit ${marked.exitCode}).
5693
+ `);
5694
+ return 1;
5695
+ }
4795
5696
  io.stdout(`${entry.id}: installed \u2014 see ${sourceDocsUrl(entry.docsPath)} for authentication
4796
5697
  `);
4797
5698
  return 0;
4798
5699
  }
4799
5700
 
4800
5701
  // src/commands/workspace.ts
4801
- import { existsSync as existsSync20, rmSync as rmSync5 } from "node:fs";
5702
+ import { existsSync as existsSync21, rmSync as rmSync5 } from "node:fs";
4802
5703
  import { homedir as homedir4 } from "node:os";
4803
- import { join as join16, resolve as resolve10 } from "node:path";
5704
+ import { join as join18, resolve as resolve12 } from "node:path";
4804
5705
  function homeOf2(opts) {
4805
5706
  return (opts.homedir ?? homedir4)();
4806
5707
  }
@@ -4809,7 +5710,7 @@ function messageOf2(error) {
4809
5710
  }
4810
5711
  function recordedRoot(env, home) {
4811
5712
  const configured = readHostConfig(env, home).harnessRoot;
4812
- return typeof configured === "string" && configured !== "" ? resolve10(configured) : void 0;
5713
+ return typeof configured === "string" && configured !== "" ? resolve12(configured) : void 0;
4813
5714
  }
4814
5715
  function renderTable2(rows, io) {
4815
5716
  const header2 = ["WORKSPACE", "DEFAULT", "PATH"];
@@ -4834,7 +5735,7 @@ async function runWorkspaceCreate(name, opts, io) {
4834
5735
  let root;
4835
5736
  const chosen = name ?? DEFAULT_WORKSPACE_NAME;
4836
5737
  try {
4837
- root = opts.path !== void 0 ? resolve10(opts.path) : workspaceRoot(chosen, env, home);
5738
+ root = opts.path !== void 0 ? resolve12(opts.path) : workspaceRoot(chosen, env, home);
4838
5739
  } catch (err) {
4839
5740
  io.stderr(`${bin} workspace: ${messageOf2(err)}
4840
5741
  `);
@@ -4846,9 +5747,9 @@ async function runWorkspaceCreate(name, opts, io) {
4846
5747
  return 1;
4847
5748
  }
4848
5749
  let action;
4849
- const preexisting = existsSync20(root);
5750
+ const preexisting = existsSync21(root);
4850
5751
  try {
4851
- if (!opts.json && !existsSync20(join16(root, ".git"))) {
5752
+ if (!opts.json && !existsSync21(join18(root, ".git"))) {
4852
5753
  const at = opts.ref !== void 0 ? ` at ${opts.ref}` : "";
4853
5754
  io.stdout(`cloning ${AGRO_REPO_URL}${at} into ${root}\u2026
4854
5755
  `);
@@ -4893,7 +5794,7 @@ async function runWorkspaceList(opts, io) {
4893
5794
  const recorded = recordedRoot(env, home);
4894
5795
  const registry = workspacesRoot(env, home);
4895
5796
  rows = listHostWorkspaces(env, home).map((name) => {
4896
- const root = join16(registry, name);
5797
+ const root = join18(registry, name);
4897
5798
  return { name, root, default: recorded === root };
4898
5799
  });
4899
5800
  } catch (err) {
@@ -4918,9 +5819,9 @@ async function runWorkspaceList(opts, io) {
4918
5819
  }
4919
5820
 
4920
5821
  // src/commands/tool.ts
4921
- import { existsSync as existsSync21 } from "node:fs";
5822
+ import { existsSync as existsSync22 } from "node:fs";
4922
5823
  import { homedir as homedir5, userInfo as userInfo2 } from "node:os";
4923
- import { delimiter as delimiter3, join as join17 } from "node:path";
5824
+ import { delimiter as delimiter3, join as join19 } from "node:path";
4924
5825
 
4925
5826
  // src/lib/tools/catalog.ts
4926
5827
  var TOOLS_DOC = "docs/installation.md";
@@ -5337,6 +6238,7 @@ function resolveToolInstallArgv(entry, host) {
5337
6238
  }
5338
6239
 
5339
6240
  // src/commands/tool.ts
6241
+ var ROOT_TOOL_REMOVAL_DOCS = `${sourceDocsUrl("docs/installation.md")}#remove-a-root-level-tool`;
5340
6242
  function isReachable2(status) {
5341
6243
  return status === "ready" || status === "starting";
5342
6244
  }
@@ -5350,7 +6252,21 @@ function targetFor2(root, run, env) {
5350
6252
  });
5351
6253
  }
5352
6254
  function hostPrefix2(home) {
5353
- return join17(home, ".local");
6255
+ return join19(home, ".local");
6256
+ }
6257
+ function sandboxMarkerPath2(entry) {
6258
+ return `${SANDBOX_HARNESS_PREFIX}/share/agro/tools/${entry.id}.installed`;
6259
+ }
6260
+ async function sandboxMarkerExists2(target, entry) {
6261
+ const r = await target.exec({
6262
+ argv: ["test", "-f", sandboxMarkerPath2(entry)],
6263
+ user: "sandbox",
6264
+ stdio: "capture"
6265
+ });
6266
+ return r.exitCode === 0;
6267
+ }
6268
+ function hostBinaryUnderPrefix2(entry, prefix) {
6269
+ return existsSync22(join19(harnessBinPath(prefix), entry.binary));
5354
6270
  }
5355
6271
  function pathEntries2(env) {
5356
6272
  return (env.PATH ?? "").split(delimiter3).filter((part) => part !== "");
@@ -5430,7 +6346,7 @@ function probeableHost2(env, home) {
5430
6346
  return void 0;
5431
6347
  }
5432
6348
  const prefix = hostPrefix2(home);
5433
- if (!existsSync21(join17(root, ".git")) && !existsSync21(prefix)) return void 0;
6349
+ if (!existsSync22(join19(root, ".git")) && !existsSync22(prefix)) return void 0;
5434
6350
  return { root, prefix };
5435
6351
  }
5436
6352
  async function collectRows(root, run, home, env, only) {
@@ -5646,7 +6562,15 @@ Every tool installer in the catalog is Debian-specific. Install ${entry.id} with
5646
6562
  const prefix = hostPrefix2(home);
5647
6563
  const installEnv = { NPM_USER_PREFIX: prefix };
5648
6564
  const target = hostTargetFor2(root, prefix, run, env);
5649
- if (await probeInstalled2(target, entry, void 0, installEnv) === true) {
6565
+ let hasReceipt;
6566
+ try {
6567
+ hasReceipt = readHostConfig(env, home).hostTools?.[entry.id] !== void 0;
6568
+ } catch (err) {
6569
+ io.stderr(`${bin} tool: ${messageOf3(err)}
6570
+ `);
6571
+ return 1;
6572
+ }
6573
+ if (await probeInstalled2(target, entry, void 0, installEnv) === true && (hasReceipt || entry.hostInstallUser === "root" || !hostBinaryUnderPrefix2(entry, prefix))) {
5650
6574
  io.stdout(`${entry.id}: already installed (${entry.binary})
5651
6575
  `);
5652
6576
  try {
@@ -5681,13 +6605,8 @@ Every tool installer in the catalog is Debian-specific. Install ${entry.id} with
5681
6605
  `);
5682
6606
  return r.exitCode;
5683
6607
  }
5684
- const receipt = {
5685
- prefix,
5686
- binary: entry.binary,
5687
- binPath: harnessBinPath(prefix),
5688
- installedAt: (/* @__PURE__ */ new Date()).toISOString(),
5689
- workspaceRoot: root
5690
- };
6608
+ const installedAt = (/* @__PURE__ */ new Date()).toISOString();
6609
+ const receipt = entry.hostInstallUser === "root" ? { scope: "root", binary: entry.binary, installedAt, workspaceRoot: root } : { prefix, binary: entry.binary, binPath: harnessBinPath(prefix), installedAt, workspaceRoot: root };
5691
6610
  try {
5692
6611
  const config = readHostConfig(env, home);
5693
6612
  writeHostConfig(
@@ -5747,7 +6666,7 @@ ${installableToolIds().map((t) => ` ${t}`).join("\n")}
5747
6666
  }
5748
6667
  if (entry.installArgv === void 0) return refuseContainer();
5749
6668
  const already = await probeInstalled2(target, entry, "sandbox");
5750
- if (already === true) {
6669
+ if (already === true && await sandboxMarkerExists2(target, entry)) {
5751
6670
  io.stdout(`${entry.id}: already installed (${entry.binary})
5752
6671
  `);
5753
6672
  return 0;
@@ -5769,6 +6688,17 @@ ${installableToolIds().map((t) => ` ${t}`).join("\n")}
5769
6688
  `);
5770
6689
  return r.exitCode;
5771
6690
  }
6691
+ const marker = sandboxMarkerPath2(entry);
6692
+ const marked = await target.exec({
6693
+ argv: ["sh", "-c", `mkdir -p "$(dirname "$1")" && : > "$1"`, "sh", marker],
6694
+ user: "sandbox",
6695
+ stdio: "capture"
6696
+ });
6697
+ if (marked.exitCode !== 0) {
6698
+ io.stderr(`${opts.bin} tool: could not record the install at ${marker} (exit ${marked.exitCode}).
6699
+ `);
6700
+ return 1;
6701
+ }
5772
6702
  io.stdout(`${entry.id}: installed \u2014 see ${sourceDocsUrl(entry.docsPath)}
5773
6703
  `);
5774
6704
  return 0;
@@ -5829,7 +6759,7 @@ Removing it could delete a tool you installed yourself. Re-run with \`--force\`
5829
6759
  );
5830
6760
  return 1;
5831
6761
  }
5832
- const prefix = receipt?.prefix ?? computed;
6762
+ const prefix = receipt === void 0 || isRootToolReceipt(receipt) ? computed : receipt.prefix;
5833
6763
  const target = hostTargetFor2(receipt?.workspaceRoot ?? workspace, prefix, run, env);
5834
6764
  const outcome = await removeTool(entry, target, prefix, void 0, opts, io, true);
5835
6765
  if (outcome.dropReceipt && receipt !== void 0) {
@@ -5852,6 +6782,15 @@ async function runToolUninstall(name, opts, io) {
5852
6782
  const root = resolveProjectRoot(opts.cwd);
5853
6783
  const entry = findTool(name);
5854
6784
  if (!entry) return unknownTool(name, io, opts.bin);
6785
+ if (entry.hostInstallUser === "root") {
6786
+ io.stderr(
6787
+ `${opts.bin} tool: ${entry.id} cannot be removed by this command.
6788
+ ${opts.bin} does not remove system packages that it installed as root.
6789
+ Remove ${entry.id} by hand: ${ROOT_TOOL_REMOVAL_DOCS}
6790
+ `
6791
+ );
6792
+ return 1;
6793
+ }
5855
6794
  if (entry.uninstallArgv === null) {
5856
6795
  io.stderr(`${opts.bin} tool: ${entry.id} cannot be removed by this command.
5857
6796
 
@@ -5864,7 +6803,20 @@ async function runToolUninstall(name, opts, io) {
5864
6803
  if (!isReachable2(await resolveTargetStatus(target))) {
5865
6804
  return await uninstallOnHost2(entry, opts, io, run);
5866
6805
  }
5867
- return (await removeTool(entry, target, SANDBOX_HARNESS_PREFIX, "sandbox", opts, io)).code;
6806
+ const outcome = await removeTool(entry, target, SANDBOX_HARNESS_PREFIX, "sandbox", opts, io);
6807
+ if (outcome.dropReceipt) {
6808
+ const cleared = await target.exec({
6809
+ argv: ["rm", "-f", sandboxMarkerPath2(entry)],
6810
+ user: "sandbox",
6811
+ stdio: "capture"
6812
+ });
6813
+ if (cleared.exitCode !== 0) {
6814
+ io.stderr(`${opts.bin} tool: could not clear the install record (exit ${cleared.exitCode}).
6815
+ `);
6816
+ return 1;
6817
+ }
6818
+ }
6819
+ return outcome.code;
5868
6820
  }
5869
6821
 
5870
6822
  // src/lib/remote.ts
@@ -5931,7 +6883,7 @@ function fetchRemoteSource(opts) {
5931
6883
  }
5932
6884
 
5933
6885
  // src/cli.ts
5934
- var DEFAULT_SOURCE_CONTROL_DIR = resolve11(
6886
+ var DEFAULT_SOURCE_CONTROL_DIR = resolve13(
5935
6887
  dirname10(fileURLToPath(import.meta.url)),
5936
6888
  "../.."
5937
6889
  );
@@ -5944,7 +6896,7 @@ var INTEGRATIONS = {
5944
6896
  )
5945
6897
  }
5946
6898
  };
5947
- function isHelpFlag(arg) {
6899
+ function isHelpFlag2(arg) {
5948
6900
  return arg === "--help" || arg === "-h" || arg === "help";
5949
6901
  }
5950
6902
  function isVersionFlag(arg) {
@@ -5961,7 +6913,7 @@ function printAgroHelp(product = AGRO_PRODUCT) {
5961
6913
  process.stdout.write(`${bin} \u2014 ${title} (v${AGRO_VERSION})
5962
6914
 
5963
6915
  Usage:
5964
- ${bin} sandbox <args...> Create and list sandboxes (install|list)
6916
+ ${bin} sandbox <args...> Create, list, and upgrade sandboxes (install|list|upgrade)
5965
6917
  ${bin} shell [name] Open a zsh shell in the running sandbox container
5966
6918
  ${bin} config <args...> Read and write ${stateNames(bin).configFile} (show|set), or run a wizard
5967
6919
  ${bin} secret <args...> Read and write the gitignored root .env (set|list)
@@ -6111,66 +7063,6 @@ Flags:
6111
7063
  --force Override the up-to-date / downgrade gate.
6112
7064
  `);
6113
7065
  }
6114
- function runtimeLines() {
6115
- const width = Math.max(...RUNTIME_CATALOG.map((r) => r.id.length));
6116
- return RUNTIME_CATALOG.map(
6117
- (r) => ` ${r.id.padEnd(width)} ${r.provisionable ? "provisionable" : "planned"}`
6118
- ).join("\n");
6119
- }
6120
- function printSandboxHelp(bin = AGRO_PRODUCT.bin) {
6121
- process.stdout.write(`${bin} sandbox \u2014 Create and list sandboxes
6122
-
6123
- Usage:
6124
- ${bin} sandbox install <runtime> [--name <name>] [--checkout <dir>]
6125
- [--home-mount <dir>] [--yes]
6126
- [--version <X.Y.Z> | --image[=<ref>]]
6127
- [--no-build] [--print-argv]
6128
- ${bin} sandbox list [--json]
6129
-
6130
- \`install\` writes a sandbox entry under \${${stateNames(bin).envPrefix}HOME:-~/${stateNames(bin).userStateDir}}/sandboxes/<name>/,
6131
- materialises the compose files and the compose wrapper into it, then starts the
6132
- container. It needs no project checkout and runs from any directory. Without
6133
- --checkout the sandbox runs the prebuilt image; with --checkout <dir> that
6134
- checkout is bound at /home/sandbox/harness; the sandbox builds locally only
6135
- when that directory holds .devcontainer/Dockerfile.
6136
-
6137
- On a terminal without --yes it asks for the sandbox name, the timezone, the git
6138
- identity, SSH (with its host port), the host Docker socket, and the host path
6139
- for /home/sandbox. With --checkout it seeds those answers from that
6140
- checkout's ${stateNames(bin).configFile}.
6141
-
6142
- Flags:
6143
- --name <name> Registry entry name (default: the lowest free ${DEFAULT_NAME_PREFIX}<n>)
6144
- --checkout <dir> Bind this checkout into the sandbox and seed the defaults
6145
- from its ${stateNames(bin).configFile}. The sandbox builds
6146
- locally only when that directory holds
6147
- .devcontainer/Dockerfile
6148
- --repo <dir> Deprecated alias for --checkout
6149
- --home-mount <dir>
6150
- Bind this host directory at /home/sandbox instead of the
6151
- Docker-managed volume. Relative paths resolve against the
6152
- working directory. The directory is created when absent
6153
- --yes Non-interactive: keep every default and ask nothing
6154
- --version <X.Y.Z>
6155
- Run the official release image ghcr.io/mifunedev/agro:<X.Y.Z>
6156
- (implies --image). A leading v and a -<channel>.<n>
6157
- pre-release suffix are accepted
6158
- --image Run the prebuilt image instead of building (implies
6159
- --no-build). The ref resolves first-match: --version or
6160
- --image=<ref> > ${stateNames(bin).envPrefix}SANDBOX_IMAGE >
6161
- ${stateNames(bin).configFile} image.ref > ${officialImageRef(AGRO_VERSION)}
6162
- --image=<ref> Run a custom image (implies --image); conflicts with --version
6163
- --no-build Suppress the local build and reuse an existing image
6164
- --print-argv Print the docker compose argv that would run, then exit
6165
- without writing an entry
6166
- --json Machine-readable output (list)
6167
-
6168
- Runtimes:
6169
- ${runtimeLines()}
6170
-
6171
- Next: ${bin} shell <name>
6172
- `);
6173
- }
6174
7066
  function printShellHelp(bin = AGRO_PRODUCT.bin) {
6175
7067
  process.stdout.write(`${bin} shell \u2014 Open a shell in the running sandbox container
6176
7068
 
@@ -6295,6 +7187,8 @@ no workspace: run \`${bin} workspace create\` first. It installs into
6295
7187
  \`~/.local\` and records the install as \`hostTools\` in that same file. On the host,
6296
7188
  \`uninstall\` removes only what \`install\` recorded, from the prefix in that record.
6297
7189
  Without a record it refuses, and \`--force\` overrides.
7190
+ \`uninstall\` refuses each root-level tool, marked \`(root)\` in \`${bin} tool list\`,
7191
+ with and without \`--force\`. The refusal links the manual removal steps.
6298
7192
 
6299
7193
  Flags:
6300
7194
  --yes Accept a large download without prompting
@@ -6384,7 +7278,7 @@ function extractSandboxFlag(command, rest) {
6384
7278
  }
6385
7279
  var LANGFUSE_VERBS = ["apply", "status", "disable"];
6386
7280
  function parseLangfuseArgs(rest, bin = AGRO_PRODUCT.bin) {
6387
- if (rest.length === 0 || isHelpFlag(rest[0])) return { ok: true, args: { help: true } };
7281
+ if (rest.length === 0 || isHelpFlag2(rest[0])) return { ok: true, args: { help: true } };
6388
7282
  const [head, ...tail] = rest;
6389
7283
  if (!LANGFUSE_VERBS.includes(head)) {
6390
7284
  return {
@@ -6393,7 +7287,7 @@ function parseLangfuseArgs(rest, bin = AGRO_PRODUCT.bin) {
6393
7287
  showHelp: true
6394
7288
  };
6395
7289
  }
6396
- if (isHelpFlag(tail[0])) return { ok: true, args: { help: true } };
7290
+ if (isHelpFlag2(tail[0])) return { ok: true, args: { help: true } };
6397
7291
  if (tail.length > 0) {
6398
7292
  return { ok: false, error: `${bin} langfuse ${head}: unexpected argument "${tail[0]}"` };
6399
7293
  }
@@ -6411,12 +7305,12 @@ function parseConfigArgs(input, bin = AGRO_PRODUCT.bin) {
6411
7305
  ...scoped.args.sandbox === void 0 ? {} : { sandbox: scoped.args.sandbox },
6412
7306
  ...forced ? { force: true } : {}
6413
7307
  };
6414
- if (rest.length === 0 || isHelpFlag(rest[0])) {
7308
+ if (rest.length === 0 || isHelpFlag2(rest[0])) {
6415
7309
  return { ok: true, args: { ...args, help: true } };
6416
7310
  }
6417
7311
  const [head, ...tail] = rest;
6418
7312
  if (!CONFIG_VERBS.includes(head)) {
6419
- if (tail.length > 0 && isHelpFlag(tail[0])) {
7313
+ if (tail.length > 0 && isHelpFlag2(tail[0])) {
6420
7314
  return { ok: true, args: { ...args, integration: head, integrationHelp: true } };
6421
7315
  }
6422
7316
  if (tail.length > 0) {
@@ -6428,7 +7322,7 @@ function parseConfigArgs(input, bin = AGRO_PRODUCT.bin) {
6428
7322
  return { ok: true, args: { ...args, integration: head } };
6429
7323
  }
6430
7324
  const verb = head;
6431
- if (isHelpFlag(tail[0])) return { ok: true, args: { ...args, help: true } };
7325
+ if (isHelpFlag2(tail[0])) return { ok: true, args: { ...args, help: true } };
6432
7326
  if (verb === "show" || verb === "repo") {
6433
7327
  if (tail.length > 0) {
6434
7328
  return { ok: false, error: `${bin} config ${verb}: unexpected argument "${tail[0]}"` };
@@ -6460,7 +7354,7 @@ function parseSecretArgs(input, bin = AGRO_PRODUCT.bin) {
6460
7354
  help: false,
6461
7355
  ...scoped.args.sandbox === void 0 ? {} : { sandbox: scoped.args.sandbox }
6462
7356
  };
6463
- if (rest.length === 0 || isHelpFlag(rest[0])) {
7357
+ if (rest.length === 0 || isHelpFlag2(rest[0])) {
6464
7358
  return { ok: true, args: { ...args, help: true } };
6465
7359
  }
6466
7360
  const [head, ...tail] = rest;
@@ -6472,7 +7366,7 @@ function parseSecretArgs(input, bin = AGRO_PRODUCT.bin) {
6472
7366
  };
6473
7367
  }
6474
7368
  const verb = head;
6475
- if (isHelpFlag(tail[0])) return { ok: true, args: { ...args, help: true } };
7369
+ if (isHelpFlag2(tail[0])) return { ok: true, args: { ...args, help: true } };
6476
7370
  if (verb === "list") {
6477
7371
  if (tail.length > 0) {
6478
7372
  return { ok: false, error: `${bin} secret list: unexpected argument "${tail[0]}"` };
@@ -6493,7 +7387,7 @@ function parseSecretArgs(input, bin = AGRO_PRODUCT.bin) {
6493
7387
  }
6494
7388
  function parseDestroyArgs(rest, bin = AGRO_PRODUCT.bin) {
6495
7389
  const args = { help: false, yes: false };
6496
- if (isHelpFlag(rest[0])) return { ok: true, args: { ...args, help: true } };
7390
+ if (isHelpFlag2(rest[0])) return { ok: true, args: { ...args, help: true } };
6497
7391
  for (const token of rest) {
6498
7392
  if (token === "--yes") {
6499
7393
  args.yes = true;
@@ -6512,7 +7406,7 @@ function parseDestroyArgs(rest, bin = AGRO_PRODUCT.bin) {
6512
7406
  }
6513
7407
  function parseComposeArgs(rest, bin = AGRO_PRODUCT.bin) {
6514
7408
  const args = { help: false, passthrough: [] };
6515
- if (rest.length === 0 || isHelpFlag(rest[0])) {
7409
+ if (rest.length === 0 || isHelpFlag2(rest[0])) {
6516
7410
  return { ok: true, args: { ...args, help: true } };
6517
7411
  }
6518
7412
  if (rest[0] !== "config") {
@@ -6524,7 +7418,7 @@ function parseComposeArgs(rest, bin = AGRO_PRODUCT.bin) {
6524
7418
  }
6525
7419
  args.subcommand = "config";
6526
7420
  const tail = rest.slice(1);
6527
- if (isHelpFlag(tail[0])) return { ok: true, args: { ...args, help: true } };
7421
+ if (isHelpFlag2(tail[0])) return { ok: true, args: { ...args, help: true } };
6528
7422
  const sep4 = tail.indexOf("--");
6529
7423
  if (sep4 === -1 && tail.length > 0) {
6530
7424
  return {
@@ -6550,7 +7444,7 @@ function parseSelfUpgradeArgs(rest, bin = AGRO_PRODUCT.bin) {
6550
7444
  args.dryRun = true;
6551
7445
  continue;
6552
7446
  }
6553
- if (isHelpFlag(arg)) {
7447
+ if (isHelpFlag2(arg)) {
6554
7448
  args.help = true;
6555
7449
  return { ok: true, args };
6556
7450
  }
@@ -6592,7 +7486,7 @@ function parseVendorArgs(rest, bin = AGRO_PRODUCT.bin) {
6592
7486
  args.force = true;
6593
7487
  continue;
6594
7488
  }
6595
- if (isHelpFlag(arg)) {
7489
+ if (isHelpFlag2(arg)) {
6596
7490
  args.help = true;
6597
7491
  return { ok: true, args };
6598
7492
  }
@@ -6609,121 +7503,9 @@ function parseVendorArgs(rest, bin = AGRO_PRODUCT.bin) {
6609
7503
  }
6610
7504
  return { ok: true, args };
6611
7505
  }
6612
- var SANDBOX_VALUE_FLAGS = {
6613
- "--name": "name",
6614
- "--checkout": "checkout",
6615
- "--repo": "checkout",
6616
- "--home-mount": "homeMount"
6617
- };
6618
- function parseSandboxArgs(rest, bin = AGRO_PRODUCT.bin) {
6619
- const args = {
6620
- help: false,
6621
- yes: false,
6622
- image: false,
6623
- noBuild: false,
6624
- printArgv: false,
6625
- json: false
6626
- };
6627
- if (rest.length === 0 || isHelpFlag(rest[0])) {
6628
- return { ok: true, args: { ...args, help: true } };
6629
- }
6630
- const [head, ...tail] = rest;
6631
- if (head !== "install" && head !== "list") {
6632
- return {
6633
- ok: false,
6634
- error: `${bin} sandbox: unknown subcommand "${head}" \u2014 expected install or list`,
6635
- showHelp: true
6636
- };
6637
- }
6638
- args.subcommand = head;
6639
- if (isHelpFlag(tail[0])) return { ok: true, args: { ...args, help: true } };
6640
- const positionals = [];
6641
- const checkoutSpellings = /* @__PURE__ */ new Set();
6642
- let version;
6643
- for (let i = 0; i < tail.length; i++) {
6644
- const token = tail[i];
6645
- const valueFlag = SANDBOX_VALUE_FLAGS[token];
6646
- if (valueFlag !== void 0) {
6647
- const value = tail[i + 1];
6648
- if (value === void 0) {
6649
- return { ok: false, error: `${bin} sandbox ${head}: ${token} requires a value` };
6650
- }
6651
- if (valueFlag === "checkout") checkoutSpellings.add(token);
6652
- args[valueFlag] = value;
6653
- i++;
6654
- } else if (token === "--yes") {
6655
- args.yes = true;
6656
- } else if (token === "--no-build") {
6657
- args.noBuild = true;
6658
- } else if (token === "--print-argv") {
6659
- args.printArgv = true;
6660
- } else if (token === "--image") {
6661
- args.image = true;
6662
- } else if (token.startsWith("--image=")) {
6663
- const ref = token.slice("--image=".length);
6664
- if (ref === "") {
6665
- return { ok: false, error: `${bin} sandbox: --image=<ref> requires a non-empty image ref` };
6666
- }
6667
- args.image = true;
6668
- args.imageRef = ref;
6669
- } else if (token === "--version" || token.startsWith("--version=")) {
6670
- const value = token === "--version" ? tail[++i] : token.slice("--version=".length);
6671
- if (value === void 0 || value === "") {
6672
- return { ok: false, error: `${bin} sandbox ${head}: --version requires a value` };
6673
- }
6674
- version = parseReleaseVersion(value);
6675
- if (version === void 0) {
6676
- return {
6677
- ok: false,
6678
- error: `${bin} sandbox ${head}: --version "${value}" is not a release version \u2014 expected X.Y.Z`
6679
- };
6680
- }
6681
- } else if (token === "--json") {
6682
- args.json = true;
6683
- } else if (token.startsWith("-")) {
6684
- return { ok: false, error: `${bin} sandbox ${head}: unknown flag "${token}"` };
6685
- } else {
6686
- positionals.push(token);
6687
- }
6688
- }
6689
- if (checkoutSpellings.has("--checkout") && checkoutSpellings.has("--repo")) {
6690
- return {
6691
- ok: false,
6692
- error: `${bin} sandbox ${head}: --checkout conflicts with --repo \u2014 pass exactly one, and prefer --checkout`
6693
- };
6694
- }
6695
- if (version !== void 0) {
6696
- if (args.imageRef !== void 0) {
6697
- return {
6698
- ok: false,
6699
- error: `${bin} sandbox ${head}: --version conflicts with --image=<ref> \u2014 pass --version for an official release or --image=<ref> for a custom image`
6700
- };
6701
- }
6702
- args.image = true;
6703
- args.imageRef = officialImageRef(version);
6704
- }
6705
- if (head === "list") {
6706
- if (positionals.length > 0) {
6707
- return { ok: false, error: `${bin} sandbox list: unexpected argument "${positionals[0]}"` };
6708
- }
6709
- return { ok: true, args };
6710
- }
6711
- if (positionals.length === 0) {
6712
- return {
6713
- ok: false,
6714
- error: `${bin} sandbox install: a runtime is required, e.g. \`${bin} sandbox install docker\``,
6715
- showHelp: true
6716
- };
6717
- }
6718
- if (positionals.length > 1) {
6719
- return { ok: false, error: `${bin} sandbox install: unexpected argument "${positionals[1]}"` };
6720
- }
6721
- args.runtime = positionals[0];
6722
- return { ok: true, args };
6723
- }
6724
7506
  function parseShellArgs(rest, bin = AGRO_PRODUCT.bin) {
6725
7507
  const args = { help: false };
6726
- if (isHelpFlag(rest[0])) return { ok: true, args: { help: true } };
7508
+ if (isHelpFlag2(rest[0])) return { ok: true, args: { help: true } };
6727
7509
  for (const token of rest) {
6728
7510
  if (token.startsWith("-")) {
6729
7511
  return { ok: false, error: `${bin} shell: unknown flag "${token}"` };
@@ -6737,7 +7519,7 @@ function parseShellArgs(rest, bin = AGRO_PRODUCT.bin) {
6737
7519
  }
6738
7520
  function parseWorkspaceArgs(rest, bin = AGRO_PRODUCT.bin) {
6739
7521
  const args = { help: false, json: false };
6740
- if (rest.length === 0 || isHelpFlag(rest[0])) {
7522
+ if (rest.length === 0 || isHelpFlag2(rest[0])) {
6741
7523
  return { ok: true, args: { ...args, help: true } };
6742
7524
  }
6743
7525
  const positionals = [];
@@ -6809,7 +7591,7 @@ function parseHarnessArgs(rest, bin = AGRO_PRODUCT.bin) {
6809
7591
  host: false,
6810
7592
  force: false
6811
7593
  };
6812
- if (rest.length === 0 || isHelpFlag(rest[0])) {
7594
+ if (rest.length === 0 || isHelpFlag2(rest[0])) {
6813
7595
  return { ok: true, args: { ...args, help: true } };
6814
7596
  }
6815
7597
  const positionals = [];
@@ -6890,7 +7672,7 @@ function parseHarnessArgs(rest, bin = AGRO_PRODUCT.bin) {
6890
7672
  }
6891
7673
  function parseToolArgs(rest, bin = AGRO_PRODUCT.bin) {
6892
7674
  const args = { help: false, yes: false, json: false, host: false, force: false };
6893
- if (rest.length === 0 || isHelpFlag(rest[0])) {
7675
+ if (rest.length === 0 || isHelpFlag2(rest[0])) {
6894
7676
  return { ok: true, args: { ...args, help: true } };
6895
7677
  }
6896
7678
  const positionals = [];
@@ -6948,19 +7730,19 @@ function parseGatewayArgs(rest) {
6948
7730
  }
6949
7731
  return { ok: true, args: { help: false, passthrough: [...rest] } };
6950
7732
  }
6951
- function bundledPayloadExists(bundled, exists = existsSync22) {
6952
- return exists(join18(bundled.sourceControlDir, "manifest.json"));
7733
+ function bundledPayloadExists(bundled, exists = existsSync23) {
7734
+ return exists(join20(bundled.sourceControlDir, "manifest.json"));
6953
7735
  }
6954
7736
  function resolveVendorSource(args, bundled, bin = AGRO_PRODUCT.bin) {
6955
- const exists = bundled.exists ?? existsSync22;
7737
+ const exists = bundled.exists ?? existsSync23;
6956
7738
  if (args.fromDir !== void 0) {
6957
- return { kind: "local", fromDir: resolve11(args.fromDir) };
7739
+ return { kind: "local", fromDir: resolve13(args.fromDir) };
6958
7740
  }
6959
7741
  if (args.fromRemote) {
6960
7742
  return { kind: "remote", ref: args.ref };
6961
7743
  }
6962
7744
  if (bundledPayloadExists(bundled, exists)) {
6963
- return { kind: "local", fromDir: resolve11(bundled.sourceControlDir, "..") };
7745
+ return { kind: "local", fromDir: resolve13(bundled.sourceControlDir, "..") };
6964
7746
  }
6965
7747
  return {
6966
7748
  kind: "remote",
@@ -6972,7 +7754,7 @@ function resolveVendorSource(args, bundled, bin = AGRO_PRODUCT.bin) {
6972
7754
  function readPayloadVersion(checkoutDir) {
6973
7755
  try {
6974
7756
  const parsed = JSON.parse(
6975
- readFileSync12(join18(resolveControlDir(checkoutDir), "cli", "package.json"), "utf8")
7757
+ readFileSync13(join20(resolveControlDir(checkoutDir), "cli", "package.json"), "utf8")
6976
7758
  );
6977
7759
  if (parsed && typeof parsed.version === "string") return parsed.version;
6978
7760
  } catch {
@@ -6981,7 +7763,7 @@ function readPayloadVersion(checkoutDir) {
6981
7763
  }
6982
7764
  async function runWithRemoteSource(hooks, fn) {
6983
7765
  const fetch2 = hooks.fetch ?? fetchRemoteSource;
6984
- const mkdtemp = hooks.mkdtemp ?? (() => mkdtempSync5(join18(tmpdir4(), "oh-remote-")));
7766
+ const mkdtemp = hooks.mkdtemp ?? (() => mkdtempSync5(join20(tmpdir4(), "oh-remote-")));
6985
7767
  const rm = hooks.rm ?? ((dir) => rmSync6(dir, { recursive: true, force: true }));
6986
7768
  const out = hooks.stdout ?? ((s) => process.stdout.write(s));
6987
7769
  const checkoutDir = mkdtemp();
@@ -6998,7 +7780,7 @@ async function main(argv) {
6998
7780
  const product = resolveProduct(process.argv[1]);
6999
7781
  const bin = product.bin;
7000
7782
  const [first, second] = argv;
7001
- if (!first || isHelpFlag(first)) {
7783
+ if (!first || isHelpFlag2(first)) {
7002
7784
  printAgroHelp(product);
7003
7785
  return 0;
7004
7786
  }
@@ -7141,37 +7923,7 @@ async function main(argv) {
7141
7923
  (checkoutDir) => runUpdate({ bin, targetDir, fromDir: checkoutDir, force, dryRun }, io)
7142
7924
  );
7143
7925
  }
7144
- if (first === "sandbox") {
7145
- const parsed = parseSandboxArgs(argv.slice(1), bin);
7146
- if (!parsed.ok) {
7147
- process.stderr.write(`${parsed.error}
7148
- `);
7149
- if (parsed.showHelp) printSandboxHelp(bin);
7150
- return 1;
7151
- }
7152
- const a = parsed.args;
7153
- if (a.help) {
7154
- printSandboxHelp(bin);
7155
- return a.subcommand === void 0 ? 1 : 0;
7156
- }
7157
- const io = lifecycleIo();
7158
- if (a.subcommand === "list") return await runSandboxList({ bin, json: a.json }, io);
7159
- return await runSandboxInstall(
7160
- {
7161
- bin,
7162
- runtime: a.runtime,
7163
- ...a.name !== void 0 ? { name: a.name } : {},
7164
- ...a.checkout !== void 0 ? { checkout: a.checkout } : {},
7165
- ...a.homeMount !== void 0 ? { homeMount: a.homeMount } : {},
7166
- yes: a.yes,
7167
- image: a.image,
7168
- ...a.imageRef !== void 0 ? { imageRef: a.imageRef } : {},
7169
- noBuild: a.noBuild,
7170
- printArgv: a.printArgv
7171
- },
7172
- io
7173
- );
7174
- }
7926
+ if (first === "sandbox") return runSandboxCommand(argv.slice(1), bin);
7175
7927
  if (first === "shell") {
7176
7928
  const parsed = parseShellArgs(argv.slice(1), bin);
7177
7929
  if (!parsed.ok) {
@@ -7256,7 +8008,7 @@ async function main(argv) {
7256
8008
  if (composeVerbs().includes(first)) {
7257
8009
  const verb = first;
7258
8010
  const rest = argv.slice(1);
7259
- if (isHelpFlag(rest[0])) {
8011
+ if (isHelpFlag2(rest[0])) {
7260
8012
  printComposeVerbHelp(verb, bin);
7261
8013
  return 0;
7262
8014
  }
@@ -7371,7 +8123,19 @@ function lifecycleIo() {
7371
8123
  stderr: (s) => process.stderr.write(s)
7372
8124
  };
7373
8125
  }
7374
- main(process.argv.slice(2)).then(
8126
+ async function runCli(argv) {
8127
+ try {
8128
+ return await main(argv);
8129
+ } catch (err) {
8130
+ if (err instanceof RuntimeUnsupportedError) {
8131
+ process.stderr.write(`${err.message}
8132
+ `);
8133
+ return 1;
8134
+ }
8135
+ throw err;
8136
+ }
8137
+ }
8138
+ runCli(process.argv.slice(2)).then(
7375
8139
  (code) => process.exit(code),
7376
8140
  (err) => {
7377
8141
  const msg = err instanceof Error ? err.message : String(err);
@@ -7386,7 +8150,7 @@ export {
7386
8150
  SECRET_VERBS,
7387
8151
  bundledPayloadExists,
7388
8152
  extractSandboxFlag,
7389
- isHelpFlag,
8153
+ isHelpFlag2 as isHelpFlag,
7390
8154
  isVersionFlag,
7391
8155
  parseComposeArgs,
7392
8156
  parseConfigArgs,
@@ -7416,6 +8180,7 @@ export {
7416
8180
  printVendorHelp,
7417
8181
  printWorkspaceHelp,
7418
8182
  resolveVendorSource,
8183
+ runCli,
7419
8184
  runWithRemoteSource,
7420
8185
  runtimeLines
7421
8186
  };