@mifune/agro 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +11 -37
  2. package/dist/agro.js +1292 -1609
  3. package/dist/oh.js +1292 -1609
  4. package/package.json +2 -2
package/dist/agro.js CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { fileURLToPath } from "node:url";
5
- import { dirname as dirname8, join as join11, resolve as resolve10 } from "node:path";
6
- import { existsSync as existsSync17, mkdtempSync as mkdtempSync3, readFileSync as readFileSync12, rmSync as rmSync3 } from "node:fs";
5
+ import { dirname as dirname8, join as join10, resolve as resolve10 } from "node:path";
6
+ import { existsSync as existsSync16, mkdtempSync as mkdtempSync3, readFileSync as readFileSync11, rmSync as rmSync3 } from "node:fs";
7
7
  import { tmpdir as tmpdir3 } from "node:os";
8
8
 
9
9
  // src/commands/update.ts
@@ -315,17 +315,53 @@ import {
315
315
  copyFileSync
316
316
  } from "node:fs";
317
317
  import path2 from "node:path";
318
+
319
+ // src/lib/product.ts
320
+ import { basename } from "node:path";
321
+ var AGRO_PRODUCT = {
322
+ name: "agro",
323
+ bin: "agro",
324
+ title: "AGRO CLI",
325
+ packageName: "@mifune/agro",
326
+ generation: "agro"
327
+ };
328
+ var LEGACY_PRODUCT = {
329
+ name: "oh",
330
+ bin: "oh",
331
+ title: "Open Harness CLI",
332
+ packageName: "@mifune/openharness",
333
+ generation: "legacy"
334
+ };
335
+ var FILE_EXTENSION = /\.[^.]*$/;
336
+ function invokedName(argv1) {
337
+ if (argv1 === void 0) return "";
338
+ return basename(argv1.replace(/\\/g, "/")).replace(FILE_EXTENSION, "");
339
+ }
340
+ function resolveProduct(argv1) {
341
+ return invokedName(argv1) === LEGACY_PRODUCT.bin ? LEGACY_PRODUCT : AGRO_PRODUCT;
342
+ }
343
+ function activeBin() {
344
+ return resolveProduct(process.argv[1]).bin;
345
+ }
346
+ function productFor(bin) {
347
+ return bin === LEGACY_PRODUCT.bin ? LEGACY_PRODUCT : AGRO_PRODUCT;
348
+ }
349
+ function stateNames(bin) {
350
+ return GENERATIONS[productFor(bin).generation];
351
+ }
352
+
353
+ // src/lib/vendor.ts
318
354
  function assertDestInTarget(dest, targetOh, sep5) {
319
355
  if (dest === targetOh || dest.startsWith(targetOh + sep5)) {
320
356
  return;
321
357
  }
322
- throw new Error("oh: refusing to write outside target .oh: " + dest);
358
+ throw new Error(`${activeBin()}: refusing to write outside target .oh: ` + dest);
323
359
  }
324
360
  function assertDestInRoot(dest, targetRoot, sep5) {
325
361
  if (dest === targetRoot || dest.startsWith(targetRoot + sep5)) {
326
362
  return;
327
363
  }
328
- throw new Error("oh: refusing to write outside target root: " + dest);
364
+ throw new Error(`${activeBin()}: refusing to write outside target root: ` + dest);
329
365
  }
330
366
  function walkFiles(root, dir, acc) {
331
367
  const entries = readdirSync2(dir, { withFileTypes: true });
@@ -467,12 +503,12 @@ function readCliVersion(ohDir) {
467
503
  return "0.0.0";
468
504
  }
469
505
  async function runUpdate(opts, io) {
470
- const { targetDir, fromDir, force, dryRun } = opts;
506
+ const { bin, targetDir, fromDir, force, dryRun } = opts;
471
507
  const dryPrefix = dryRun ? "[dry-run] " : "";
472
508
  const source = resolveControlDir(fromDir);
473
509
  if (source.kind === "absent") {
474
510
  io.stderr(
475
- "oh update: update source not found at " + source.agroPath + " or " + source.legacyPath + ". Pass --from <built-AGRO-checkout> or --from-remote [--ref <ref>].\n"
511
+ bin + " update: update source not found at " + source.agroPath + " or " + source.legacyPath + ". Pass --from <built-AGRO-checkout> or --from-remote [--ref <ref>].\n"
476
512
  );
477
513
  return 1;
478
514
  }
@@ -482,7 +518,7 @@ async function runUpdate(opts, io) {
482
518
  const targetOh = target.kind === "absent" ? path3.resolve(targetDir, controlName) : target.path;
483
519
  const targetName = path3.basename(targetOh);
484
520
  if (fromOh === targetOh) {
485
- io.stderr("oh update: source and target are the same " + targetName + "; nothing to update.\n");
521
+ io.stderr(bin + " update: source and target are the same " + targetName + "; nothing to update.\n");
486
522
  return 1;
487
523
  }
488
524
  const available = readCliVersion(fromOh);
@@ -492,25 +528,25 @@ async function runUpdate(opts, io) {
492
528
  io.stdout(dryPrefix + "updating " + targetName + ": " + current + " -> " + available + "\n");
493
529
  } else if (cmp === 0) {
494
530
  if (!force) {
495
- io.stdout(dryPrefix + "oh update: already up to date (v" + current + ")\n");
531
+ io.stdout(dryPrefix + bin + " update: already up to date (v" + current + ")\n");
496
532
  return 0;
497
533
  }
498
- io.stdout(dryPrefix + "oh update: re-overlay (v" + current + ", --force)\n");
534
+ io.stdout(dryPrefix + bin + " update: re-overlay (v" + current + ", --force)\n");
499
535
  } else {
500
536
  if (!force) {
501
537
  io.stderr(
502
- "oh update: refusing downgrade (current v" + current + " > source v" + available + "); pass --force to override\n"
538
+ bin + " update: refusing downgrade (current v" + current + " > source v" + available + "); pass --force to override\n"
503
539
  );
504
540
  return 1;
505
541
  }
506
542
  io.stdout(
507
- dryPrefix + "oh update: downgrading " + targetName + ": " + current + " -> " + available + " (--force)\n"
543
+ dryPrefix + bin + " update: downgrading " + targetName + ": " + current + " -> " + available + " (--force)\n"
508
544
  );
509
545
  }
510
546
  const manifest = loadManifest(fromOh);
511
547
  if (manifest === null) {
512
548
  io.stdout(
513
- dryPrefix + "oh update: no " + controlName + "/manifest.json in source; overlaying all of " + controlName + "/ (legacy mode)\n"
549
+ dryPrefix + bin + " update: no " + controlName + "/manifest.json in source; overlaying all of " + controlName + "/ (legacy mode)\n"
514
550
  );
515
551
  }
516
552
  let created = 0;
@@ -548,12 +584,12 @@ async function runUpdate(opts, io) {
548
584
  const stranded = path3.join(targetOh, dir);
549
585
  if (existsSync3(stranded)) {
550
586
  io.stdout(
551
- dryPrefix + "oh update: " + path3.join(targetName, dir) + " still exists from a pre-move install and is no longer read; move any local edits into " + dir + "/ and delete it\n"
587
+ dryPrefix + bin + " update: " + path3.join(targetName, dir) + " still exists from a pre-move install and is no longer read; move any local edits into " + dir + "/ and delete it\n"
552
588
  );
553
589
  }
554
590
  }
555
591
  io.stdout(
556
- dryPrefix + "oh update: " + created + " created, " + overwritten + " overwritten, " + (skipped + rootResult.skipped) + " skipped\n"
592
+ dryPrefix + bin + " update: " + created + " created, " + overwritten + " overwritten, " + (skipped + rootResult.skipped) + " skipped\n"
557
593
  );
558
594
  return 0;
559
595
  }
@@ -571,39 +607,6 @@ import {
571
607
  writeFileSync
572
608
  } from "node:fs";
573
609
  import { basename as basename2, delimiter, dirname, extname, join as join2 } from "node:path";
574
-
575
- // src/lib/product.ts
576
- import { basename } from "node:path";
577
- var AGRO_PRODUCT = {
578
- name: "agro",
579
- bin: "agro",
580
- title: "AGRO CLI",
581
- packageName: "@mifune/agro",
582
- generation: "agro"
583
- };
584
- var LEGACY_PRODUCT = {
585
- name: "oh",
586
- bin: "oh",
587
- title: "Open Harness CLI",
588
- packageName: "@mifune/openharness",
589
- generation: "legacy"
590
- };
591
- var FILE_EXTENSION = /\.[^.]*$/;
592
- function invokedName(argv1) {
593
- if (argv1 === void 0) return "";
594
- return basename(argv1.replace(/\\/g, "/")).replace(FILE_EXTENSION, "");
595
- }
596
- function resolveProduct(argv1) {
597
- return invokedName(argv1) === LEGACY_PRODUCT.bin ? LEGACY_PRODUCT : AGRO_PRODUCT;
598
- }
599
- function productFor(bin) {
600
- return bin === LEGACY_PRODUCT.bin ? LEGACY_PRODUCT : AGRO_PRODUCT;
601
- }
602
- function stateNames(bin) {
603
- return GENERATIONS[productFor(bin).generation];
604
- }
605
-
606
- // src/commands/self-upgrade.ts
607
610
  var DEFAULT_ARTIFACT_URL = "https://github.com/mifunedev/agro/releases/latest/download/agro.js";
608
611
  var PREFIX = `${AGRO_PRODUCT.bin} update`;
609
612
  var PACKAGE = AGRO_PRODUCT.packageName;
@@ -701,13 +704,13 @@ function compareSemver(a, b) {
701
704
  function versionOf(result) {
702
705
  return result.status === 0 ? result.stdout.trim() : "";
703
706
  }
704
- function refuseUnsupported(installation) {
707
+ function refuseUnsupported(installation, bin) {
705
708
  const { kind, target, reason } = installation;
706
709
  const at = `${kind} installation at ${target}`;
707
710
  switch (kind) {
708
711
  case "image":
709
712
  return new Error(
710
- `${at} \u2014 the sandbox image ships this CLI; pull a newer image on the host (oh/agro stop, then agro sandbox install docker --name <name>)`
713
+ `${at} \u2014 the sandbox image ships this CLI; pull a newer image on the host (${bin} stop, then ${bin} sandbox install docker --name <name>)`
711
714
  );
712
715
  case "source":
713
716
  return new Error(`${at} \u2014 rebuild from the checkout: npm --prefix .agro/cli run build`);
@@ -890,7 +893,7 @@ async function runSelfUpgrade(opts, deps, io) {
890
893
  try {
891
894
  const installation = classifyInstallation(opts.argv1, deps);
892
895
  if (installation.kind !== "npm" && installation.kind !== "standalone") {
893
- throw refuseUnsupported(installation);
896
+ throw refuseUnsupported(installation, resolveProduct(opts.argv1).bin);
894
897
  }
895
898
  io.stdout(`${PREFIX}: ${installation.kind} installation at ${installation.target}
896
899
  `);
@@ -1505,212 +1508,64 @@ function runMigrate(args, io, deps = defaultMigrateDeps()) {
1505
1508
  }
1506
1509
  }
1507
1510
 
1508
- // src/commands/cloud.ts
1509
- import { readFile } from "node:fs/promises";
1510
- import { existsSync as existsSync9, readFileSync as readFileSync9 } from "node:fs";
1511
- import { join as join4 } from "node:path";
1512
-
1513
- // src/lib/prompt.ts
1514
- import { createInterface } from "node:readline";
1515
- var COLORS = {
1516
- reset: "\x1B[0m",
1517
- bold: "\x1B[1m",
1518
- dim: "\x1B[2m",
1519
- green: "\x1B[32m",
1520
- yellow: "\x1B[33m",
1521
- red: "\x1B[31m",
1522
- cyan: "\x1B[36m"
1523
- };
1524
- function header(msg) {
1525
- process.stdout.write(`
1526
- ${COLORS.bold}${msg}${COLORS.reset}
1527
-
1528
- `);
1529
- }
1530
- function bold(s) {
1531
- return `${COLORS.bold}${s}${COLORS.reset}`;
1532
- }
1533
- function redact(token) {
1534
- if (token.length <= 8) return "****";
1535
- return token.slice(0, 5) + "*".repeat(token.length - 5);
1536
- }
1537
- async function ask(question) {
1538
- const rl = createInterface({ input: process.stdin, output: process.stdout });
1539
- try {
1540
- return await new Promise((resolve11) => {
1541
- rl.question(` ${question} `, (answer) => resolve11(answer.trim()));
1542
- });
1543
- } finally {
1544
- rl.close();
1545
- }
1546
- }
1547
- async function askSecret(question) {
1548
- if (!process.stdin.isTTY) return askSecretPiped(question);
1549
- process.stdout.write(` ${question} `);
1550
- process.stdin.setRawMode(true);
1551
- process.stdin.resume();
1552
- const bytes = [];
1553
- return await new Promise((resolve11) => {
1554
- const cleanup = () => {
1555
- try {
1556
- process.stdin.setRawMode(false);
1557
- } catch {
1558
- }
1559
- process.stdin.pause();
1560
- process.stdin.removeListener("data", onData);
1561
- };
1562
- const onData = (data) => {
1563
- for (const byte of data) {
1564
- if (byte === 3) {
1565
- cleanup();
1566
- process.stdout.write("\n");
1567
- process.exit(130);
1568
- } else if (byte === 13 || byte === 10) {
1569
- cleanup();
1570
- process.stdout.write("\n");
1571
- resolve11(Buffer.from(bytes).toString("utf8").trim());
1572
- return;
1573
- } else if (byte === 127 || byte === 8) {
1574
- if (bytes.length > 0) {
1575
- bytes.pop();
1576
- process.stdout.write("\b \b");
1577
- }
1578
- } else if (byte === 21) {
1579
- while (bytes.length > 0) {
1580
- bytes.pop();
1581
- process.stdout.write("\b \b");
1582
- }
1583
- } else if (byte >= 32) {
1584
- bytes.push(byte);
1585
- process.stdout.write("\u25CF");
1586
- }
1587
- }
1588
- };
1589
- process.stdin.on("data", onData);
1590
- });
1591
- }
1592
- async function askSecretPiped(question) {
1593
- const rl = createInterface({ input: process.stdin, output: process.stdout });
1594
- const stdoutWrite = process.stdout.write.bind(process.stdout);
1595
- let prompted = false;
1596
- const patched = (chunk) => {
1597
- if (!prompted) {
1598
- prompted = true;
1599
- return stdoutWrite(chunk);
1600
- }
1601
- return true;
1602
- };
1603
- process.stdout.write = patched;
1604
- let restored = false;
1605
- const restore = () => {
1606
- if (restored) return;
1607
- restored = true;
1608
- process.stdout.write = stdoutWrite;
1609
- try {
1610
- rl.close();
1611
- } catch {
1612
- }
1511
+ // src/lib/execution/runner.ts
1512
+ import { spawnSync as spawnSync2 } from "node:child_process";
1513
+ import { existsSync as existsSync5 } from "node:fs";
1514
+ import { basename as basename3, join as join4 } from "node:path";
1515
+ var spawnRunner = (cmd, args, opts) => {
1516
+ const common = {
1517
+ env: opts.env,
1518
+ ...opts.cwd !== void 0 ? { cwd: opts.cwd } : {},
1519
+ ...opts.timeoutMs !== void 0 ? { timeout: opts.timeoutMs } : {}
1613
1520
  };
1614
- const onSigint = () => {
1615
- restore();
1616
- process.stdout.write("\n");
1617
- process.exit(130);
1521
+ const r = opts.stdio === "capture" ? spawnSync2(cmd, args, { ...common, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }) : spawnSync2(cmd, args, { ...common, stdio: "inherit" });
1522
+ const err = r.error;
1523
+ return {
1524
+ status: r.status,
1525
+ error: err ? { code: err.code, message: err.message } : void 0,
1526
+ stdout: typeof r.stdout === "string" ? r.stdout : void 0,
1527
+ stderr: typeof r.stderr === "string" ? r.stderr : void 0
1618
1528
  };
1619
- process.on("SIGINT", onSigint);
1620
- try {
1621
- return await new Promise((resolve11) => {
1622
- rl.question(` ${question} `, (answer) => {
1623
- process.stdout.write("\n");
1624
- resolve11(answer.trim());
1625
- });
1626
- });
1627
- } finally {
1628
- process.removeListener("SIGINT", onSigint);
1629
- restore();
1529
+ };
1530
+ var ExecutionSpawnError = class extends Error {
1531
+ code;
1532
+ constructor(what, error) {
1533
+ super(`failed to run ${what}${error?.message ? ` (${error.message})` : ""}`);
1534
+ this.name = "ExecutionSpawnError";
1535
+ this.code = error?.code;
1630
1536
  }
1631
- }
1632
- async function confirm(question, defaultYes = true) {
1633
- const suffix = defaultYes ? "[Y/n]" : "[y/N]";
1634
- const answer = (await ask(`${question} ${suffix}`)).toLowerCase();
1635
- if (answer === "") return defaultYes;
1636
- return /^y/.test(answer);
1637
- }
1638
-
1639
- // src/lib/oh-config.ts
1640
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync6, renameSync as renameSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3 } from "node:fs";
1641
- import { basename as basename3, dirname as dirname4, resolve as resolve5 } from "node:path";
1642
-
1643
- // src/lib/env-file.ts
1644
- import { existsSync as existsSync5 } from "node:fs";
1645
- import { resolve as resolve4, sep as sep2 } from "node:path";
1646
- function assertInRoot(dest, root) {
1647
- if (!(dest === root || dest.startsWith(root + sep2))) {
1648
- throw new Error(`refusing to write outside the project root: ${dest}`);
1537
+ };
1538
+ var ExecutionExitError = class extends Error {
1539
+ exitCode;
1540
+ constructor(what, exitCode) {
1541
+ super(`${what} exited ${exitCode}`);
1542
+ this.name = "ExecutionExitError";
1543
+ this.exitCode = exitCode;
1649
1544
  }
1650
- }
1651
- function stripQuotes(s) {
1652
- if (s.length >= 2 && (s[0] === '"' && s.endsWith('"') || s[0] === "'" && s.endsWith("'"))) {
1653
- return s.slice(1, -1);
1545
+ };
1546
+ function assertSpawned(r, what) {
1547
+ if (r.error) {
1548
+ throw new ExecutionSpawnError(what, r.error);
1654
1549
  }
1655
- return s;
1656
1550
  }
1657
- var CONFIG_FIELD_BY_ENV_KEY = {
1658
- DOCKER_SOCKET: "access.dockerSocket"
1659
- };
1660
- function setEnvValue(root, key, value) {
1661
- const path4 = CONFIG_FIELD_BY_ENV_KEY[key];
1662
- if (path4 === void 0) {
1551
+ function requireLifecycleScript(root, rel) {
1552
+ const { controlDir } = resolveProjectLayout(root);
1553
+ const script = join4(controlDir, "scripts", rel);
1554
+ if (!existsSync5(script)) {
1663
1555
  throw new Error(
1664
- `${key} is not a settable oh.json field \u2014 run \`oh config set\` for a non-secret, \`oh secret set\` for a credential`
1556
+ `missing lifecycle script ${script} \u2014 the vendored ${basename3(controlDir)}/ payload looks incomplete; run \`${activeBin()} update\` to re-vendor it`
1665
1557
  );
1666
1558
  }
1667
- return setConfigField(root, path4, value);
1668
- }
1669
- function setConfigField(root, path4, value) {
1670
- const file = ohConfigPath(root);
1671
- assertInRoot(file, resolve4(root));
1672
- const config = readOhConfig(file);
1673
- const before = getOhConfigValue(config, path4);
1674
- const next = setOhConfigValue(config, path4, value);
1675
- const after = getOhConfigValue(next, path4);
1676
- if (existsSync5(file) && JSON.stringify(before) === JSON.stringify(after)) {
1677
- return "already-set";
1678
- }
1679
- writeOhConfig(root, next);
1680
- return before === void 0 ? "added" : "updated";
1681
- }
1682
- function setKeyInEnv(content, key, value) {
1683
- const lines = content.split("\n");
1684
- const live = new RegExp(`^${escapeRegExp(key)}=(.*)$`);
1685
- const commented = new RegExp(`^[ \\t]*#[ \\t]*${escapeRegExp(key)}=(.*)$`);
1686
- for (let i = 0; i < lines.length; i++) {
1687
- const m = live.exec(lines[i]);
1688
- if (m) {
1689
- if (stripQuotes(m[1].trim()) === value) return { content, outcome: "already-set" };
1690
- lines[i] = `${key}=${value}`;
1691
- return { content: lines.join("\n"), outcome: "updated" };
1692
- }
1693
- }
1694
- for (let i = 0; i < lines.length; i++) {
1695
- if (commented.test(lines[i])) {
1696
- lines[i] = `${key}=${value}`;
1697
- return { content: lines.join("\n"), outcome: "uncommented" };
1698
- }
1699
- }
1700
- const body = content.replace(/\n+$/, "");
1701
- return {
1702
- content: body === "" ? `${key}=${value}
1703
- ` : `${body}
1704
- ${key}=${value}
1705
- `,
1706
- outcome: "added"
1707
- };
1708
- }
1709
- function escapeRegExp(s) {
1710
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1559
+ return script;
1711
1560
  }
1712
1561
 
1562
+ // src/lib/env-file.ts
1563
+ import { existsSync as existsSync7 } from "node:fs";
1564
+ import { resolve as resolve5, sep as sep2 } from "node:path";
1565
+
1713
1566
  // src/lib/oh-config.ts
1567
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync6, renameSync as renameSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3 } from "node:fs";
1568
+ import { basename as basename4, dirname as dirname4, resolve as resolve4 } from "node:path";
1714
1569
  var OH_CONFIG_FILE = GENERATIONS.legacy.configFile;
1715
1570
  var OH_CONFIG_MODE = 420;
1716
1571
  var RESERVED_HOME_PATHS = [
@@ -1739,6 +1594,10 @@ var LANGFUSE_PRIVACY_PRESETS = [
1739
1594
  "full-debug"
1740
1595
  ];
1741
1596
  var SANDBOX_RUNTIMES = ["docker"];
1597
+ function configCheckout(config) {
1598
+ const value = config.checkout ?? config.repo;
1599
+ return typeof value === "string" && value !== "" ? value : void 0;
1600
+ }
1742
1601
  function ohConfigPath(root) {
1743
1602
  return resolveProjectLayout(root).configFile;
1744
1603
  }
@@ -1759,13 +1618,12 @@ function defaultOhConfig(name) {
1759
1618
  cron: { agentBin: "claude" },
1760
1619
  build: { skipPnpmInstall: false },
1761
1620
  image: { mode: "build", pullPolicy: "missing" },
1762
- cloud: {},
1763
1621
  langfuse: {},
1764
1622
  composeOverrides: []
1765
1623
  };
1766
1624
  }
1767
1625
  function readOhConfig(path4) {
1768
- if (!existsSync6(path4)) return defaultOhConfig(basename3(dirname4(resolve5(path4))));
1626
+ if (!existsSync6(path4)) return defaultOhConfig(basename4(dirname4(resolve4(path4))));
1769
1627
  let raw;
1770
1628
  try {
1771
1629
  raw = readFileSync6(path4, "utf8");
@@ -1777,13 +1635,13 @@ function readOhConfig(path4) {
1777
1635
  try {
1778
1636
  parsed = JSON.parse(raw);
1779
1637
  } catch {
1780
- throw new Error(`${basename3(path4)} is not valid JSON: ${path4}`);
1638
+ throw new Error(`${basename4(path4)} is not valid JSON: ${path4}`);
1781
1639
  }
1782
1640
  return validateOhConfig(parsed);
1783
1641
  }
1784
1642
  function writeOhConfig(root, config) {
1785
1643
  const path4 = ohConfigPath(root);
1786
- assertInRoot(path4, resolve5(root));
1644
+ assertInRoot(path4, resolve4(root));
1787
1645
  const validated = validateOhConfig({ ...config, version: 1 });
1788
1646
  const body = `${JSON.stringify(validated, null, 2)}
1789
1647
  `;
@@ -1808,6 +1666,7 @@ function validateOhConfig(value) {
1808
1666
  expectString(record, "name");
1809
1667
  expectEnum(record, "runtime", "", SANDBOX_RUNTIMES);
1810
1668
  expectString(record, "repo");
1669
+ expectString(record, "checkout");
1811
1670
  expectString(record, "timezone");
1812
1671
  const storage = expectSection(record, "storage");
1813
1672
  if (storage) {
@@ -1854,8 +1713,6 @@ function validateOhConfig(value) {
1854
1713
  expectEnum(image, "mode", "image.", ["build", "image"]);
1855
1714
  expectEnum(image, "pullPolicy", "image.", ["missing", "always", "never"]);
1856
1715
  }
1857
- const cloud = expectSection(record, "cloud");
1858
- if (cloud) expectString(cloud, "apiUrl", "cloud.");
1859
1716
  const langfuse = expectSection(record, "langfuse");
1860
1717
  if (langfuse) {
1861
1718
  expectString(langfuse, "baseUrl", "langfuse.");
@@ -1916,6 +1773,7 @@ var OH_CONFIG_FIELDS = [
1916
1773
  { path: "name", type: "string" },
1917
1774
  { path: "runtime", type: "enum", values: SANDBOX_RUNTIMES },
1918
1775
  { path: "repo", type: "string" },
1776
+ { path: "checkout", type: "string" },
1919
1777
  { path: "timezone", type: "string" },
1920
1778
  { path: "git.userName", type: "string" },
1921
1779
  { path: "git.userEmail", type: "string" },
@@ -1932,7 +1790,6 @@ var OH_CONFIG_FIELDS = [
1932
1790
  { path: "image.mode", type: "enum", values: ["build", "image"] },
1933
1791
  { path: "image.pullPolicy", type: "enum", values: ["missing", "always", "never"] },
1934
1792
  { path: "storage.homePath", type: "string" },
1935
- { path: "cloud.apiUrl", type: "string" },
1936
1793
  { path: "langfuse.baseUrl", type: "string" },
1937
1794
  { path: "langfuse.privacyPreset", type: "enum", values: LANGFUSE_PRIVACY_PRESETS },
1938
1795
  { path: "composeOverrides", type: "list" }
@@ -1993,6 +1850,74 @@ function coerceFieldValue(field, raw) {
1993
1850
  return raw;
1994
1851
  }
1995
1852
 
1853
+ // src/lib/env-file.ts
1854
+ function assertInRoot(dest, root) {
1855
+ if (!(dest === root || dest.startsWith(root + sep2))) {
1856
+ throw new Error(`refusing to write outside the project root: ${dest}`);
1857
+ }
1858
+ }
1859
+ function stripQuotes(s) {
1860
+ if (s.length >= 2 && (s[0] === '"' && s.endsWith('"') || s[0] === "'" && s.endsWith("'"))) {
1861
+ return s.slice(1, -1);
1862
+ }
1863
+ return s;
1864
+ }
1865
+ var CONFIG_FIELD_BY_ENV_KEY = {
1866
+ DOCKER_SOCKET: "access.dockerSocket"
1867
+ };
1868
+ function setEnvValue(root, key, value, bin) {
1869
+ const path4 = CONFIG_FIELD_BY_ENV_KEY[key];
1870
+ if (path4 === void 0) {
1871
+ throw new Error(
1872
+ `${key} is not a settable oh.json field \u2014 run \`${bin} config set\` for a non-secret, \`${bin} secret set\` for a credential`
1873
+ );
1874
+ }
1875
+ return setConfigField(root, path4, value);
1876
+ }
1877
+ function setConfigField(root, path4, value) {
1878
+ const file = ohConfigPath(root);
1879
+ assertInRoot(file, resolve5(root));
1880
+ const config = readOhConfig(file);
1881
+ const before = getOhConfigValue(config, path4);
1882
+ const next = setOhConfigValue(config, path4, value);
1883
+ const after = getOhConfigValue(next, path4);
1884
+ if (existsSync7(file) && JSON.stringify(before) === JSON.stringify(after)) {
1885
+ return "already-set";
1886
+ }
1887
+ writeOhConfig(root, next);
1888
+ return before === void 0 ? "added" : "updated";
1889
+ }
1890
+ function setKeyInEnv(content, key, value) {
1891
+ const lines = content.split("\n");
1892
+ const live = new RegExp(`^${escapeRegExp(key)}=(.*)$`);
1893
+ const commented = new RegExp(`^[ \\t]*#[ \\t]*${escapeRegExp(key)}=(.*)$`);
1894
+ for (let i = 0; i < lines.length; i++) {
1895
+ const m = live.exec(lines[i]);
1896
+ if (m) {
1897
+ if (stripQuotes(m[1].trim()) === value) return { content, outcome: "already-set" };
1898
+ lines[i] = `${key}=${value}`;
1899
+ return { content: lines.join("\n"), outcome: "updated" };
1900
+ }
1901
+ }
1902
+ for (let i = 0; i < lines.length; i++) {
1903
+ if (commented.test(lines[i])) {
1904
+ lines[i] = `${key}=${value}`;
1905
+ return { content: lines.join("\n"), outcome: "uncommented" };
1906
+ }
1907
+ }
1908
+ const body = content.replace(/\n+$/, "");
1909
+ return {
1910
+ content: body === "" ? `${key}=${value}
1911
+ ` : `${body}
1912
+ ${key}=${value}
1913
+ `,
1914
+ outcome: "added"
1915
+ };
1916
+ }
1917
+ function escapeRegExp(s) {
1918
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1919
+ }
1920
+
1996
1921
  // src/lib/project.ts
1997
1922
  import { dirname as dirname5, resolve as resolve6 } from "node:path";
1998
1923
  function resolveProjectRoot(startDir = process.cwd()) {
@@ -2001,628 +1926,141 @@ function resolveProjectRoot(startDir = process.cwd()) {
2001
1926
  if (resolveControlDir(dir).kind !== "absent") return dir;
2002
1927
  const parent = dirname5(dir);
2003
1928
  if (parent === dir) {
2004
- throw new Error("not an OpenHarness-equipped repo \u2014 run `oh update` first");
1929
+ throw new Error(`not an OpenHarness-equipped repo \u2014 run \`${activeBin()} update\` first`);
2005
1930
  }
2006
1931
  dir = parent;
2007
1932
  }
2008
1933
  }
2009
1934
 
2010
- // src/lib/secrets.ts
2011
- import { existsSync as existsSync8, readFileSync as readFileSync8 } from "node:fs";
2012
- import { resolve as resolve7 } from "node:path";
1935
+ // src/lib/prompt.ts
1936
+ import { createInterface } from "node:readline";
1937
+ var COLORS = {
1938
+ reset: "\x1B[0m",
1939
+ bold: "\x1B[1m",
1940
+ dim: "\x1B[2m",
1941
+ green: "\x1B[32m",
1942
+ yellow: "\x1B[33m",
1943
+ red: "\x1B[31m",
1944
+ cyan: "\x1B[36m"
1945
+ };
1946
+ function header(msg) {
1947
+ process.stdout.write(`
1948
+ ${COLORS.bold}${msg}${COLORS.reset}
2013
1949
 
2014
- // src/lib/env.ts
2015
- import {
2016
- readFileSync as readFileSync7,
2017
- writeFileSync as writeFileSync4,
2018
- existsSync as existsSync7,
2019
- fsyncSync as fsyncSync2,
2020
- renameSync as renameSync4,
2021
- unlinkSync as unlinkSync4,
2022
- openSync as openSync2,
2023
- closeSync as closeSync2
2024
- } from "node:fs";
2025
- function loadEnvInto(envPath, env) {
2026
- if (!existsSync7(envPath)) return;
2027
- const content = readFileSync7(envPath, "utf8");
2028
- for (const rawLine of content.split("\n")) {
2029
- const line = rawLine.trimEnd();
2030
- if (line === "" || /^\s*#/.test(line)) continue;
2031
- const eqIdx = line.indexOf("=");
2032
- if (eqIdx < 1) continue;
2033
- const key = line.slice(0, eqIdx).trim();
2034
- const value = line.slice(eqIdx + 1);
2035
- if (key === "") continue;
2036
- if (env[key] === void 0) env[key] = value;
2037
- }
1950
+ `);
2038
1951
  }
2039
- function writeEnvFile(envPath, content) {
2040
- const tmpPath = `${envPath}.tmp.${process.pid}`;
2041
- let fd;
1952
+ function bold(s) {
1953
+ return `${COLORS.bold}${s}${COLORS.reset}`;
1954
+ }
1955
+ function redact(token) {
1956
+ if (token.length <= 8) return "****";
1957
+ return token.slice(0, 5) + "*".repeat(token.length - 5);
1958
+ }
1959
+ async function ask(question) {
1960
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2042
1961
  try {
2043
- fd = openSync2(tmpPath, "w", 384);
2044
- writeFileSync4(tmpPath, content, "utf8");
2045
- fsyncSync2(fd);
2046
- closeSync2(fd);
2047
- fd = void 0;
2048
- renameSync4(tmpPath, envPath);
2049
- } catch (err) {
2050
- if (fd !== void 0) {
1962
+ return await new Promise((resolve11) => {
1963
+ rl.question(` ${question} `, (answer) => resolve11(answer.trim()));
1964
+ });
1965
+ } finally {
1966
+ rl.close();
1967
+ }
1968
+ }
1969
+ async function askSecret(question) {
1970
+ if (!process.stdin.isTTY) return askSecretPiped(question);
1971
+ process.stdout.write(` ${question} `);
1972
+ process.stdin.setRawMode(true);
1973
+ process.stdin.resume();
1974
+ const bytes = [];
1975
+ return await new Promise((resolve11) => {
1976
+ const cleanup = () => {
2051
1977
  try {
2052
- closeSync2(fd);
1978
+ process.stdin.setRawMode(false);
2053
1979
  } catch {
2054
1980
  }
1981
+ process.stdin.pause();
1982
+ process.stdin.removeListener("data", onData);
1983
+ };
1984
+ const onData = (data) => {
1985
+ for (const byte of data) {
1986
+ if (byte === 3) {
1987
+ cleanup();
1988
+ process.stdout.write("\n");
1989
+ process.exit(130);
1990
+ } else if (byte === 13 || byte === 10) {
1991
+ cleanup();
1992
+ process.stdout.write("\n");
1993
+ resolve11(Buffer.from(bytes).toString("utf8").trim());
1994
+ return;
1995
+ } else if (byte === 127 || byte === 8) {
1996
+ if (bytes.length > 0) {
1997
+ bytes.pop();
1998
+ process.stdout.write("\b \b");
1999
+ }
2000
+ } else if (byte === 21) {
2001
+ while (bytes.length > 0) {
2002
+ bytes.pop();
2003
+ process.stdout.write("\b \b");
2004
+ }
2005
+ } else if (byte >= 32) {
2006
+ bytes.push(byte);
2007
+ process.stdout.write("\u25CF");
2008
+ }
2009
+ }
2010
+ };
2011
+ process.stdin.on("data", onData);
2012
+ });
2013
+ }
2014
+ async function askSecretPiped(question) {
2015
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2016
+ const stdoutWrite = process.stdout.write.bind(process.stdout);
2017
+ let prompted = false;
2018
+ const patched = (chunk) => {
2019
+ if (!prompted) {
2020
+ prompted = true;
2021
+ return stdoutWrite(chunk);
2055
2022
  }
2023
+ return true;
2024
+ };
2025
+ process.stdout.write = patched;
2026
+ let restored = false;
2027
+ const restore = () => {
2028
+ if (restored) return;
2029
+ restored = true;
2030
+ process.stdout.write = stdoutWrite;
2056
2031
  try {
2057
- unlinkSync4(tmpPath);
2032
+ rl.close();
2058
2033
  } catch {
2059
2034
  }
2060
- throw err;
2035
+ };
2036
+ const onSigint = () => {
2037
+ restore();
2038
+ process.stdout.write("\n");
2039
+ process.exit(130);
2040
+ };
2041
+ process.on("SIGINT", onSigint);
2042
+ try {
2043
+ return await new Promise((resolve11) => {
2044
+ rl.question(` ${question} `, (answer) => {
2045
+ process.stdout.write("\n");
2046
+ resolve11(answer.trim());
2047
+ });
2048
+ });
2049
+ } finally {
2050
+ process.removeListener("SIGINT", onSigint);
2051
+ restore();
2061
2052
  }
2062
- }
2063
-
2064
- // src/lib/secrets.ts
2065
- var SECRETS_FILE = ".env";
2066
- var SECRET_KEYS = [
2067
- "GH_TOKEN",
2068
- "XAI_API_KEY",
2069
- "META_API_KEY",
2070
- "SANDBOX_PASSWORD",
2071
- "PI_SLACK_APP_TOKEN",
2072
- "PI_SLACK_BOT_TOKEN",
2073
- "LANGFUSE_PUBLIC_KEY",
2074
- "LANGFUSE_SECRET_KEY",
2075
- "OH_CLOUD_PROVISION_KEY"
2076
- ];
2077
- var ALLOWED = new Set(SECRET_KEYS);
2078
- function secretsFilePath(root) {
2079
- return resolve7(root, SECRETS_FILE);
2080
- }
2081
- function isSecretKey(key) {
2082
- return ALLOWED.has(key);
2083
- }
2084
- function assertSecretKey(key) {
2085
- if (ALLOWED.has(key)) return;
2086
- throw new Error(
2087
- `${key} is not a secret \u2014 non-secret settings live in oh.json; use \`oh config set\` instead`
2088
- );
2089
- }
2090
- function readSecret(root, key) {
2091
- assertSecretKey(key);
2092
- const file = secretsFilePath(root);
2093
- if (!existsSync8(file)) return void 0;
2094
- const env = {};
2095
- loadEnvInto(file, env);
2096
- const value = env[key]?.trim();
2097
- return value === void 0 || value === "" ? void 0 : stripQuotes2(value);
2098
- }
2099
- function setSecret(root, key, value) {
2100
- assertSecretKey(key);
2101
- if (/[\r\n]/.test(value)) {
2102
- throw new Error(`${key} value must not contain a newline`);
2103
- }
2104
- const file = secretsFilePath(root);
2105
- assertInRoot(file, resolve7(root));
2106
- const original = existsSync8(file) ? readFileSync8(file, "utf8") : "";
2107
- const { content } = setKeyInEnv(original, key, value);
2108
- writeEnvFile(file, content);
2109
- }
2110
- function listSecretKeys(root) {
2111
- const file = secretsFilePath(root);
2112
- if (!existsSync8(file)) return [];
2113
- const env = {};
2114
- loadEnvInto(file, env);
2115
- return SECRET_KEYS.filter((key) => {
2116
- const value = env[key]?.trim();
2117
- return value !== void 0 && value !== "";
2118
- });
2119
- }
2120
- function stripQuotes2(s) {
2121
- if (s.length >= 2 && (s[0] === '"' && s.endsWith('"') || s[0] === "'" && s.endsWith("'"))) {
2122
- return s.slice(1, -1);
2123
- }
2124
- return s;
2125
- }
2126
-
2127
- // src/commands/cloud.ts
2128
- var DEFAULT_API_URL = "http://127.0.0.1:3000";
2129
- var DEFAULT_TERMINAL_STATUSES = ["running", "failed", "destroyed"];
2130
- var PROVISION_KEY_SECRET = "OH_CLOUD_PROVISION_KEY";
2131
- var CLOUD_HELP = `oh cloud \u2014 Manage OpenHarness Cloud nodes
2132
-
2133
- Usage:
2134
- oh cloud config [--api-url <url>] [--provision-key <key>]
2135
- oh cloud config show
2136
- oh cloud [global options] nodes list
2137
- oh cloud [global options] nodes get <node-id>
2138
- oh cloud [global options] nodes create --ssh-key-id <id> [--name <name>]
2139
- oh cloud [global options] nodes events <node-id>
2140
- oh cloud [global options] nodes watch <node-id> [--interval <seconds>] [--until <statuses>]
2141
- oh cloud [global options] nodes destroy <node-id>
2142
- oh cloud [global options] nodes restart <node-id>
2143
- oh cloud [global options] nodes rebuild <node-id>
2144
- oh cloud [global options] ssh-keys list
2145
- oh cloud [global options] ssh-keys create --name <name> (--public-key <key> | --public-key-file <path>)
2146
-
2147
- Global options:
2148
- --api-url <url> API base URL (default: OH_CLOUD_API_URL, OH_API_URL,
2149
- oh.json cloud.apiUrl, or http://127.0.0.1:3000)
2150
- --provision-key <key> Admin key (default: OH_CLOUD_PROVISION_KEY in the
2151
- environment or in the root .env)
2152
- --output <field> Print one top-level response field instead of JSON
2153
- -h, --help Show this help
2154
-
2155
- Configuration:
2156
- oh cloud config writes cloud.apiUrl to the repository oh.json and securely
2157
- prompts for the current provisioner key, which it stores as
2158
- ${PROVISION_KEY_SECRET} in the gitignored root .env. This is the temporary
2159
- credential model until OpenHarness Cloud issues user API tokens.
2160
-
2161
- Both settings are repository-local, so oh cloud runs inside an
2162
- OpenHarness-equipped repo. Outside one, pass --api-url and --provision-key.
2163
-
2164
- Environment:
2165
- OH_CLOUD_API_URL Override the OpenHarness Cloud API base URL
2166
- OH_CLOUD_PROVISION_KEY Override the stored key (OH_PROVISION_KEY and
2167
- PROVISION_KEY are also accepted)
2168
-
2169
- Examples:
2170
- oh cloud config
2171
- oh cloud ssh-keys create --name laptop --public-key-file ~/.ssh/openharness_node.pub
2172
- oh cloud nodes create --name smoke-1 --ssh-key-id <ssh-key-id>
2173
- oh cloud nodes watch <node-id>
2174
- `;
2175
- var CloudCliError = class extends Error {
2176
- status;
2177
- payload;
2178
- constructor(message, options = {}) {
2179
- super(message);
2180
- this.name = "CloudCliError";
2181
- this.status = options.status;
2182
- this.payload = options.payload;
2183
- }
2184
- };
2185
- function takeOption(args, name, alias) {
2186
- const names = alias ? [name, alias] : [name];
2187
- for (const candidate of names) {
2188
- const index = args.indexOf(candidate);
2189
- if (index === -1) continue;
2190
- const value = args[index + 1];
2191
- if (value === void 0 || value.startsWith("-")) {
2192
- throw new CloudCliError(`${candidate} requires a value`);
2193
- }
2194
- args.splice(index, 2);
2195
- return value;
2196
- }
2197
- return void 0;
2198
- }
2199
- function requireOption(value, name) {
2200
- if (!value) throw new CloudCliError(`${name} is required`);
2201
- return value;
2202
- }
2203
- function requirePositional(value, name) {
2204
- if (!value || value.startsWith("-")) throw new CloudCliError(`${name} is required`);
2205
- return value;
2206
- }
2207
- function assertNoExtraArgs(args) {
2208
- if (args.length > 0) {
2209
- throw new CloudCliError(
2210
- `unexpected argument${args.length === 1 ? "" : "s"}: ${args.join(" ")}`
2211
- );
2212
- }
2213
- }
2214
- function normalizeApiUrl(value) {
2215
- let url;
2216
- try {
2217
- url = new URL(value);
2218
- } catch {
2219
- throw new CloudCliError(`invalid API URL: ${value}`);
2220
- }
2221
- if (url.protocol !== "http:" && url.protocol !== "https:") {
2222
- throw new CloudCliError(`API URL must use http or https: ${value}`);
2223
- }
2224
- return url.toString().replace(/\/$/, "");
2225
- }
2226
- function formatJson(value) {
2227
- return `${JSON.stringify(value, null, 2)}
2228
- `;
2229
- }
2230
- function bodyPreview(body) {
2231
- const compact = body.replace(/\s+/g, " ").trim();
2232
- return compact.length > 180 ? `${compact.slice(0, 177)}...` : compact;
2233
- }
2234
- function responseKind(contentType) {
2235
- return contentType?.split(";", 1)[0]?.trim() || "non-JSON content";
2236
- }
2237
- function formatApiFailure(status, payload, contentType) {
2238
- if (payload && typeof payload === "object") {
2239
- const record = payload;
2240
- const message = record.error ?? record.message;
2241
- const code = typeof record.code === "string" ? ` (${record.code})` : "";
2242
- if (typeof message === "string") {
2243
- return `API request failed with ${status}${code}: ${message}`;
2244
- }
2245
- }
2246
- if (typeof payload === "string" && payload.length > 0) {
2247
- return `API request failed with ${status}: server returned ${responseKind(contentType)} instead of JSON (${bodyPreview(payload)})`;
2248
- }
2249
- return `API request failed with ${status}`;
2250
- }
2251
- async function parseResponse(response) {
2252
- const text = await response.text();
2253
- const contentType = response.headers.get("content-type");
2254
- if (!text) return { payload: null, isJson: true, contentType };
2255
- try {
2256
- return { payload: JSON.parse(text), isJson: true, contentType };
2257
- } catch {
2258
- return { payload: text, isJson: false, contentType };
2259
- }
2260
- }
2261
- function createCloudApiClient(options) {
2262
- const fetchImpl = options.fetch ?? globalThis.fetch;
2263
- if (typeof fetchImpl !== "function") {
2264
- throw new CloudCliError(
2265
- "the cloud CLI requires Node.js 20 or newer (global fetch is unavailable)"
2266
- );
2267
- }
2268
- return async (method, pathname, body) => {
2269
- let response;
2270
- try {
2271
- response = await fetchImpl(`${options.apiUrl}${pathname}`, {
2272
- method,
2273
- headers: {
2274
- "x-provision-key": options.provisionKey,
2275
- ...body === void 0 ? {} : { "content-type": "application/json" }
2276
- },
2277
- ...body === void 0 ? {} : { body: JSON.stringify(body) }
2278
- });
2279
- } catch (error) {
2280
- const detail = error instanceof Error ? error.message : String(error);
2281
- throw new CloudCliError(`could not reach ${options.apiUrl}: ${detail}`);
2282
- }
2283
- const parsed = await parseResponse(response);
2284
- if (!response.ok) {
2285
- throw new CloudCliError(
2286
- formatApiFailure(response.status, parsed.payload, parsed.contentType),
2287
- {
2288
- status: response.status,
2289
- payload: parsed.payload
2290
- }
2291
- );
2292
- }
2293
- if (!parsed.isJson) {
2294
- throw new CloudCliError(
2295
- `API returned ${response.status} ${responseKind(parsed.contentType)}; expected JSON (${bodyPreview(parsed.payload)})`,
2296
- { status: response.status, payload: parsed.payload }
2297
- );
2298
- }
2299
- return parsed.payload;
2300
- };
2301
- }
2302
- function nodePath(nodeId, suffix = "") {
2303
- return `/api/nodes/${encodeURIComponent(nodeId)}${suffix}`;
2304
- }
2305
- async function readPublicKey(args, readFileImpl) {
2306
- const inline = takeOption(args, "--public-key");
2307
- const file = takeOption(args, "--public-key-file");
2308
- if (inline && file) {
2309
- throw new CloudCliError("use only one of --public-key or --public-key-file");
2310
- }
2311
- if (!inline && !file) {
2312
- throw new CloudCliError("--public-key or --public-key-file is required");
2313
- }
2314
- if (inline) return inline.trim();
2315
- try {
2316
- const value = (await readFileImpl(file, "utf8")).trim();
2317
- if (!value) throw new CloudCliError(`SSH public key file is empty: ${file}`);
2318
- return value;
2319
- } catch (error) {
2320
- if (error instanceof CloudCliError) throw error;
2321
- const detail = error instanceof Error ? error.message : String(error);
2322
- throw new CloudCliError(`could not read SSH public key file ${file}: ${detail}`);
2323
- }
2324
- }
2325
- async function dispatch(args, options) {
2326
- const resource = args.shift();
2327
- const action = args.shift();
2328
- if (!resource || !action) {
2329
- throw new CloudCliError("a resource and action are required. Run `oh cloud --help` for usage.");
2330
- }
2331
- if (resource === "ssh-keys") {
2332
- if (action === "list") {
2333
- assertNoExtraArgs(args);
2334
- return options.request("GET", "/api/ssh-keys");
2335
- }
2336
- if (action === "create") {
2337
- const name = requireOption(takeOption(args, "--name"), "--name");
2338
- const publicKey = await readPublicKey(args, options.readFile);
2339
- assertNoExtraArgs(args);
2340
- return options.request("POST", "/api/ssh-keys", { name, publicKey });
2341
- }
2342
- throw new CloudCliError(`unknown ssh-keys action: ${action}`);
2343
- }
2344
- if (resource !== "nodes") throw new CloudCliError(`unknown resource: ${resource}`);
2345
- if (action === "list") {
2346
- assertNoExtraArgs(args);
2347
- return options.request("GET", "/api/nodes");
2348
- }
2349
- if (action === "create") {
2350
- const name = takeOption(args, "--name");
2351
- const sshKeyId = requireOption(takeOption(args, "--ssh-key-id"), "--ssh-key-id");
2352
- assertNoExtraArgs(args);
2353
- return options.request("POST", "/api/nodes", {
2354
- ...name ? { name } : {},
2355
- sshKeyId
2356
- });
2357
- }
2358
- const nodeIdActions = ["get", "events", "destroy", "restart", "rebuild", "watch"];
2359
- if (!nodeIdActions.includes(action)) {
2360
- throw new CloudCliError(`unknown nodes action: ${action}`);
2361
- }
2362
- const nodeId = requirePositional(args.shift(), "<node-id>");
2363
- if (action === "get") {
2364
- assertNoExtraArgs(args);
2365
- return options.request("GET", nodePath(nodeId));
2366
- }
2367
- if (action === "events") {
2368
- assertNoExtraArgs(args);
2369
- return options.request("GET", nodePath(nodeId, "/events"));
2370
- }
2371
- if (action === "destroy") {
2372
- assertNoExtraArgs(args);
2373
- return options.request("DELETE", nodePath(nodeId));
2374
- }
2375
- if (action === "restart" || action === "rebuild") {
2376
- assertNoExtraArgs(args);
2377
- return options.request("POST", nodePath(nodeId, `/${action}`));
2378
- }
2379
- if (action === "watch") {
2380
- const intervalValue = takeOption(args, "--interval") ?? "5";
2381
- const intervalSeconds = Number(intervalValue);
2382
- if (!Number.isFinite(intervalSeconds) || intervalSeconds < 0) {
2383
- throw new CloudCliError(`--interval must be a non-negative number: ${intervalValue}`);
2384
- }
2385
- const untilValue = takeOption(args, "--until");
2386
- const terminalStatuses = new Set(
2387
- (untilValue ? untilValue.split(",") : DEFAULT_TERMINAL_STATUSES).map((status) => status.trim()).filter(Boolean)
2388
- );
2389
- if (terminalStatuses.size === 0) {
2390
- throw new CloudCliError("--until must contain at least one status");
2391
- }
2392
- assertNoExtraArgs(args);
2393
- let lastStatus;
2394
- while (true) {
2395
- const node = await options.request("GET", nodePath(nodeId));
2396
- if (!node || typeof node !== "object" || typeof node.status !== "string") {
2397
- throw new CloudCliError("node response did not include a status");
2398
- }
2399
- const status = node.status;
2400
- if (status !== lastStatus) {
2401
- options.write(node);
2402
- lastStatus = status;
2403
- }
2404
- if (terminalStatuses.has(status)) return void 0;
2405
- await options.sleep(intervalSeconds * 1e3);
2406
- }
2407
- }
2408
- throw new CloudCliError(`unknown nodes action: ${action}`);
2409
- }
2410
- var OUTSIDE_REPO_HINT = "; pass --api-url and --provision-key to run `oh cloud` outside a repo";
2411
- function requireRoot(io) {
2412
- try {
2413
- return resolveProjectRoot(io.cwd);
2414
- } catch (error) {
2415
- const detail = error instanceof Error ? error.message : String(error);
2416
- throw new CloudCliError(`${detail}${OUTSIDE_REPO_HINT}`);
2417
- }
2418
- }
2419
- function legacyCloudConfigPath(env) {
2420
- const home = env.HOME ?? env.USERPROFILE;
2421
- if (!home) return void 0;
2422
- return join4(home, ".config", "openharness", "cloud.json");
2423
- }
2424
- function readLegacyCloudConfig(path4) {
2425
- let parsed;
2426
- try {
2427
- parsed = JSON.parse(readFileSync9(path4, "utf8"));
2428
- } catch {
2429
- return void 0;
2430
- }
2431
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
2432
- const record = parsed;
2433
- return {
2434
- ...typeof record.apiUrl === "string" ? { apiUrl: record.apiUrl } : {},
2435
- ...typeof record.provisionKey === "string" ? { provisionKey: record.provisionKey } : {}
2436
- };
2437
- }
2438
- function migrateLegacyCloudConfig(root, io, env) {
2439
- const path4 = legacyCloudConfigPath(env);
2440
- if (!path4 || !existsSync9(path4)) return;
2441
- const config = readOhConfig(ohConfigPath(root));
2442
- if (config.cloud?.apiUrl !== void 0) return;
2443
- if (readSecret(root, PROVISION_KEY_SECRET) !== void 0) return;
2444
- const legacy = readLegacyCloudConfig(path4);
2445
- if (!legacy || !legacy.apiUrl && !legacy.provisionKey) return;
2446
- if (legacy.apiUrl) {
2447
- const next = {
2448
- ...config,
2449
- cloud: { ...config.cloud, apiUrl: normalizeApiUrl(legacy.apiUrl) }
2450
- };
2451
- writeOhConfig(root, next);
2452
- }
2453
- if (legacy.provisionKey) setSecret(root, PROVISION_KEY_SECRET, legacy.provisionKey);
2454
- io.stdout(`Migrated OpenHarness Cloud settings out of ${path4}
2455
- `);
2456
- if (legacy.apiUrl) io.stdout(` cloud.apiUrl -> ${ohConfigPath(root)}
2457
- `);
2458
- if (legacy.provisionKey) io.stdout(` ${PROVISION_KEY_SECRET} -> ${join4(root, ".env")}
2459
- `);
2460
- io.stdout(`${path4} is no longer read; delete it when you no longer need the copy.
2461
- `);
2462
- }
2463
- function loadCloudSettings(root, io, env) {
2464
- migrateLegacyCloudConfig(root, io, env);
2465
- const config = readOhConfig(ohConfigPath(root));
2466
- return {
2467
- ...config.cloud?.apiUrl ? { apiUrl: config.cloud.apiUrl } : {},
2468
- ...(() => {
2469
- const key = readSecret(root, PROVISION_KEY_SECRET);
2470
- return key ? { provisionKey: key } : {};
2471
- })()
2472
- };
2473
- }
2474
- function envProvisionKey(env) {
2475
- return env.OH_CLOUD_PROVISION_KEY ?? env.OH_PROVISION_KEY ?? env.PROVISION_KEY;
2476
- }
2477
- async function configureCloud(args, io, env) {
2478
- const root = requireRoot(io);
2479
- const current = loadCloudSettings(root, io, env);
2480
- if (args[0] === "show") {
2481
- args.shift();
2482
- assertNoExtraArgs(args);
2483
- io.stdout(`Config: ${ohConfigPath(root)}
2484
- `);
2485
- io.stdout(`Secrets: ${join4(root, ".env")}
2486
- `);
2487
- io.stdout(`API URL: ${current.apiUrl ?? DEFAULT_API_URL}
2488
- `);
2489
- io.stdout(
2490
- `Provision credential: ${current.provisionKey ? redact(current.provisionKey) : "not configured"}
2491
- `
2492
- );
2493
- return;
2494
- }
2495
- const apiUrl = normalizeApiUrl(
2496
- takeOption(args, "--api-url") ?? current.apiUrl ?? env.OH_CLOUD_API_URL ?? env.OH_API_URL ?? DEFAULT_API_URL
2497
- );
2498
- const providedKey = takeOption(args, "--provision-key");
2499
- assertNoExtraArgs(args);
2500
- const provisionKey = (providedKey ?? await (io.askSecret ?? askSecret)("OpenHarness Cloud provisioner key:")).trim();
2501
- if (!provisionKey) throw new CloudCliError("provisioner key cannot be empty");
2502
- const config = readOhConfig(ohConfigPath(root));
2503
- writeOhConfig(root, { ...config, cloud: { ...config.cloud, apiUrl } });
2504
- setSecret(root, PROVISION_KEY_SECRET, provisionKey);
2505
- io.stdout(`Saved cloud.apiUrl to ${ohConfigPath(root)}
2506
- `);
2507
- io.stdout(`Saved ${PROVISION_KEY_SECRET} to ${join4(root, ".env")}
2508
- `);
2509
- io.stdout("Provision credential: configured\n");
2510
- }
2511
- async function executeCloud(argv, io) {
2512
- const args = [...argv];
2513
- const env = io.env ?? process.env;
2514
- if (args.includes("--help") || args.includes("-h") || args[0] === "help") {
2515
- io.stdout(CLOUD_HELP);
2516
- return;
2517
- }
2518
- if (args[0] === "config") {
2519
- args.shift();
2520
- await configureCloud(args, io, env);
2521
- return;
2522
- }
2523
- const flagApiUrl = takeOption(args, "--api-url");
2524
- const flagProvisionKey = takeOption(args, "--provision-key");
2525
- const stored = flagProvisionKey && flagApiUrl ? {} : loadCloudSettings(requireRoot(io), io, env);
2526
- const apiUrl = normalizeApiUrl(
2527
- flagApiUrl ?? env.OH_CLOUD_API_URL ?? env.OH_API_URL ?? stored.apiUrl ?? DEFAULT_API_URL
2528
- );
2529
- const provisionKey = flagProvisionKey ?? envProvisionKey(env) ?? stored.provisionKey;
2530
- if (!provisionKey) {
2531
- throw new CloudCliError(
2532
- `no provision credential found. Run \`oh cloud config\`, set ${PROVISION_KEY_SECRET}, or pass --api-url and --provision-key.`
2533
- );
2534
- }
2535
- const outputField = takeOption(args, "--output");
2536
- const request = createCloudApiClient({
2537
- apiUrl,
2538
- provisionKey,
2539
- fetch: io.fetch
2540
- });
2541
- const write = (value) => io.stdout(formatJson(value));
2542
- const result = await dispatch(args, {
2543
- request,
2544
- readFile: io.readFile ?? readFile,
2545
- sleep: io.sleep ?? ((milliseconds) => new Promise((resolve11) => setTimeout(resolve11, milliseconds))),
2546
- write
2547
- });
2548
- if (result === void 0) return;
2549
- if (outputField) {
2550
- if (!result || typeof result !== "object" || !(outputField in result)) {
2551
- throw new CloudCliError(`response does not contain top-level field: ${outputField}`);
2552
- }
2553
- const value = result[outputField];
2554
- io.stdout(typeof value === "object" ? formatJson(value) : `${String(value)}
2555
- `);
2556
- return;
2557
- }
2558
- write(result);
2559
- }
2560
- async function runCloud(argv, io) {
2561
- try {
2562
- await executeCloud(argv, io);
2563
- return 0;
2564
- } catch (error) {
2565
- const message = error instanceof Error ? error.message : String(error);
2566
- io.stderr(`oh cloud: ${message}
2567
- `);
2568
- return 1;
2569
- }
2570
- }
2571
-
2572
- // src/lib/execution/runner.ts
2573
- import { spawnSync as spawnSync2 } from "node:child_process";
2574
- import { existsSync as existsSync10 } from "node:fs";
2575
- import { basename as basename4, join as join5 } from "node:path";
2576
- var spawnRunner = (cmd, args, opts) => {
2577
- const common = {
2578
- env: opts.env,
2579
- ...opts.cwd !== void 0 ? { cwd: opts.cwd } : {},
2580
- ...opts.timeoutMs !== void 0 ? { timeout: opts.timeoutMs } : {}
2581
- };
2582
- const r = opts.stdio === "capture" ? spawnSync2(cmd, args, { ...common, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }) : spawnSync2(cmd, args, { ...common, stdio: "inherit" });
2583
- const err = r.error;
2584
- return {
2585
- status: r.status,
2586
- error: err ? { code: err.code, message: err.message } : void 0,
2587
- stdout: typeof r.stdout === "string" ? r.stdout : void 0,
2588
- stderr: typeof r.stderr === "string" ? r.stderr : void 0
2589
- };
2590
- };
2591
- var ExecutionSpawnError = class extends Error {
2592
- code;
2593
- constructor(what, error) {
2594
- super(`failed to run ${what}${error?.message ? ` (${error.message})` : ""}`);
2595
- this.name = "ExecutionSpawnError";
2596
- this.code = error?.code;
2597
- }
2598
- };
2599
- var ExecutionExitError = class extends Error {
2600
- exitCode;
2601
- constructor(what, exitCode) {
2602
- super(`${what} exited ${exitCode}`);
2603
- this.name = "ExecutionExitError";
2604
- this.exitCode = exitCode;
2605
- }
2606
- };
2607
- function assertSpawned(r, what) {
2608
- if (r.error) {
2609
- throw new ExecutionSpawnError(what, r.error);
2610
- }
2611
- }
2612
- function requireLifecycleScript(root, rel) {
2613
- const { controlDir } = resolveProjectLayout(root);
2614
- const script = join5(controlDir, "scripts", rel);
2615
- if (!existsSync10(script)) {
2616
- throw new Error(
2617
- `missing lifecycle script ${script} \u2014 the vendored ${basename4(controlDir)}/ payload looks incomplete; run \`oh update\` to re-vendor it`
2618
- );
2619
- }
2620
- return script;
2053
+ }
2054
+ async function confirm(question, defaultYes = true) {
2055
+ const suffix = defaultYes ? "[Y/n]" : "[y/N]";
2056
+ const answer = (await ask(`${question} ${suffix}`)).toLowerCase();
2057
+ if (answer === "") return defaultYes;
2058
+ return /^y/.test(answer);
2621
2059
  }
2622
2060
 
2623
2061
  // src/lib/registry.ts
2624
- import { existsSync as existsSync11, mkdirSync as mkdirSync4, readdirSync as readdirSync3, statSync as statSync3, writeFileSync as writeFileSync5 } from "node:fs";
2625
- import { dirname as dirname6, join as join6, resolve as resolve8, sep as sep3 } from "node:path";
2062
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readdirSync as readdirSync3, statSync as statSync3, writeFileSync as writeFileSync4 } from "node:fs";
2063
+ import { dirname as dirname6, join as join5, resolve as resolve7, sep as sep3 } from "node:path";
2626
2064
 
2627
2065
  // oh-asset:/home/runner/work/agro/agro/.devcontainer/docker-compose.yml
2628
2066
  var docker_compose_default = 'name: ${SANDBOX_NAME:-agro}\n\nservices:\n sandbox:\n image: ${AGRO_SANDBOX_IMAGE:-${OH_SANDBOX_IMAGE:-sandbox-${SANDBOX_NAME:-agro}}}\n pull_policy: ${AGRO_PULL_POLICY:-${OH_PULL_POLICY:-missing}}\n container_name: ${SANDBOX_NAME:-agro}\n build:\n context: ${AGRO_REPO_DIR:-${OH_REPO_DIR:-..}}\n dockerfile: .devcontainer/Dockerfile\n volumes:\n - ${AGRO_HOME_MOUNT:-${OH_HOME_MOUNT:-workspace}}:/home/sandbox\n - ${AGRO_REPO_DIR:-${OH_REPO_DIR:-..}}:/home/sandbox/harness\n extra_hosts:\n - "host.docker.internal:host-gateway"\n environment:\n - SANDBOX_NAME=${SANDBOX_NAME:-agro}\n - SANDBOX_PASSWORD=${SANDBOX_PASSWORD:-test1234}\n - TZ=${TZ:-America/Los_Angeles}\n - CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS=true\n - CC_SAFETY_NET_STRICT=1\n - CC_SAFETY_NET_WORKTREE=1\n - GIT_USER_NAME=${GIT_USER_NAME:-}\n - GIT_USER_EMAIL=${GIT_USER_EMAIL:-}\n - GH_TOKEN=${GH_TOKEN:-}\n stdin_open: true\n tty: true\n cgroup: private\n cap_add:\n - SYS_ADMIN\n security_opt:\n - apparmor=unconfined\n tmpfs:\n - /run\n - /run/lock\n - /sys/fs\n healthcheck:\n test: ["CMD", "bash", "-c", "if [ -d /home/sandbox/harness/.agro ]; then exec bash /home/sandbox/harness/.agro/scripts/sandbox-healthcheck.sh; fi; exec bash /home/sandbox/harness/.oh/scripts/sandbox-healthcheck.sh"]\n interval: 30s\n timeout: 10s\n retries: 3\n start_period: 600s\n restart: unless-stopped\n\nvolumes:\n workspace:\n';
@@ -3087,7 +2525,7 @@ function ohHome() {
3087
2525
  return resolveUserStateHome(process.env);
3088
2526
  }
3089
2527
  function registryRoot() {
3090
- return join6(ohHome(), "sandboxes");
2528
+ return join5(ohHome(), "sandboxes");
3091
2529
  }
3092
2530
  function assertSandboxName(name) {
3093
2531
  if (!SANDBOX_NAME_PATTERN.test(name)) {
@@ -3098,7 +2536,7 @@ function assertSandboxName(name) {
3098
2536
  }
3099
2537
  function entryRoot(name) {
3100
2538
  assertSandboxName(name);
3101
- return join6(registryRoot(), name);
2539
+ return join5(registryRoot(), name);
3102
2540
  }
3103
2541
  function isDirectory(path4) {
3104
2542
  return statSync3(path4, { throwIfNoEntry: false })?.isDirectory() === true;
@@ -3106,12 +2544,12 @@ function isDirectory(path4) {
3106
2544
  function listEntries() {
3107
2545
  const root = registryRoot();
3108
2546
  if (!isDirectory(root)) return [];
3109
- return readdirSync3(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).filter((name) => SANDBOX_NAME_PATTERN.test(name)).filter((name) => existsSync11(ohConfigPath(join6(root, name)))).sort();
2547
+ return readdirSync3(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).filter((name) => SANDBOX_NAME_PATTERN.test(name)).filter((name) => existsSync8(ohConfigPath(join5(root, name)))).sort();
3110
2548
  }
3111
2549
  function entryRepo(name) {
3112
2550
  const config = readOhConfig(ohConfigPath(entryRoot(name)));
3113
- const repo = config.repo;
3114
- return typeof repo === "string" && repo !== "" ? resolve8(repo) : void 0;
2551
+ const checkout = configCheckout(config);
2552
+ return checkout === void 0 ? void 0 : resolve7(checkout);
3115
2553
  }
3116
2554
  function runningContainerNames(run) {
3117
2555
  const names = /* @__PURE__ */ new Set();
@@ -3137,27 +2575,27 @@ function nextDefaultName(run = spawnRunner) {
3137
2575
  }
3138
2576
  }
3139
2577
  function materialize(root, opts = {}) {
3140
- const entry = resolve8(root);
3141
- const scripts = join6(resolveProjectLayout(entry).controlDir, "scripts");
2578
+ const entry = resolve7(root);
2579
+ const scripts = join5(resolveProjectLayout(entry).controlDir, "scripts");
3142
2580
  const files = [
3143
2581
  {
3144
- dest: join6(entry, ".devcontainer", "docker-compose.yml"),
3145
- body: opts.repo === void 0 ? docker_compose_image_only_default : docker_compose_default,
2582
+ dest: join5(entry, ".devcontainer", "docker-compose.yml"),
2583
+ body: (opts.checkout ?? opts.repo) === void 0 ? docker_compose_image_only_default : docker_compose_default,
3146
2584
  mode: 420
3147
2585
  },
3148
- { dest: join6(entry, ".devcontainer", "docker-compose.ssh.yml"), body: docker_compose_ssh_default, mode: 420 },
3149
- { dest: join6(entry, ".devcontainer", "docker-compose.docker-sock.yml"), body: docker_compose_docker_sock_default, mode: 420 },
3150
- { dest: join6(scripts, "docker-compose.sh"), body: docker_compose_default2, mode: 493 },
3151
- { dest: join6(scripts, "compat.sh"), body: compat_default, mode: 420 },
3152
- { dest: join6(scripts, "check-host-port.sh"), body: check_host_port_default, mode: 493 }
2586
+ { dest: join5(entry, ".devcontainer", "docker-compose.ssh.yml"), body: docker_compose_ssh_default, mode: 420 },
2587
+ { dest: join5(entry, ".devcontainer", "docker-compose.docker-sock.yml"), body: docker_compose_docker_sock_default, mode: 420 },
2588
+ { dest: join5(scripts, "docker-compose.sh"), body: docker_compose_default2, mode: 493 },
2589
+ { dest: join5(scripts, "compat.sh"), body: compat_default, mode: 420 },
2590
+ { dest: join5(scripts, "check-host-port.sh"), body: check_host_port_default, mode: 493 }
3153
2591
  ];
3154
2592
  for (const file of files) {
3155
- const dest = resolve8(file.dest);
2593
+ const dest = resolve7(file.dest);
3156
2594
  if (!dest.startsWith(entry + sep3)) {
3157
2595
  throw new Error(`refusing to materialize outside the sandbox entry: ${dest}`);
3158
2596
  }
3159
2597
  mkdirSync4(dirname6(dest), { recursive: true });
3160
- writeFileSync5(dest, file.body, { mode: file.mode, encoding: "utf8" });
2598
+ writeFileSync4(dest, file.body, { mode: file.mode, encoding: "utf8" });
3161
2599
  }
3162
2600
  }
3163
2601
  function resolveSandboxRoot(opts = {}) {
@@ -3165,14 +2603,14 @@ function resolveSandboxRoot(opts = {}) {
3165
2603
  const root = entryRoot(opts.name);
3166
2604
  if (!isDirectory(root)) {
3167
2605
  throw new Error(
3168
- `no sandbox named \`${opts.name}\` in ${registryRoot()} \u2014 create one with \`oh sandbox install docker --name ${opts.name}\``
2606
+ `no sandbox named \`${opts.name}\` in ${registryRoot()} \u2014 create one with \`${activeBin()} sandbox install docker --name ${opts.name}\``
3169
2607
  );
3170
2608
  }
3171
2609
  return root;
3172
2610
  }
3173
2611
  const names = listEntries();
3174
2612
  if (names.length === 1) return entryRoot(names[0]);
3175
- const cwd = resolve8(opts.cwd ?? process.cwd());
2613
+ const cwd = resolve7(opts.cwd ?? process.cwd());
3176
2614
  for (const name of names) {
3177
2615
  const repo = entryRepo(name);
3178
2616
  if (repo !== void 0 && (cwd === repo || cwd.startsWith(repo + sep3))) {
@@ -3181,7 +2619,7 @@ function resolveSandboxRoot(opts = {}) {
3181
2619
  }
3182
2620
  if (names.length === 0) {
3183
2621
  throw new Error(
3184
- `no sandbox is registered in ${registryRoot()} \u2014 create one with \`oh sandbox install docker\``
2622
+ `no sandbox is registered in ${registryRoot()} \u2014 create one with \`${activeBin()} sandbox install docker\``
3185
2623
  );
3186
2624
  }
3187
2625
  throw new Error(
@@ -3189,333 +2627,131 @@ function resolveSandboxRoot(opts = {}) {
3189
2627
  );
3190
2628
  }
3191
2629
 
3192
- // src/commands/config.ts
3193
- function configRoot(opts) {
3194
- return opts.sandbox === void 0 ? resolveProjectRoot(opts.cwd) : resolveSandboxRoot({ name: opts.sandbox });
3195
- }
3196
- function configFieldList() {
3197
- return ohConfigFieldPaths().map((path4) => ` ${path4}`).join("\n");
3198
- }
3199
- async function runConfigShow(opts, io) {
3200
- const root = configRoot(opts);
3201
- const config = readOhConfig(ohConfigPath(root));
3202
- io.stdout(`${JSON.stringify(config, null, 2)}
3203
- `);
3204
- return 0;
3205
- }
3206
- async function runConfigSet(key, value, opts, io) {
3207
- if (isSecretKey(key) || isSecretKey(key.toUpperCase())) {
3208
- io.stderr(
3209
- `oh config set: ${key.toUpperCase()} is a secret \u2014 oh.json is tracked by git.
3210
- Set it with \`oh secret set ${key.toUpperCase()}\` instead.
3211
- `
3212
- );
3213
- return 1;
3214
- }
3215
- if (!findOhConfigField(key)) {
3216
- io.stderr(`oh config set: unknown field "${key}"
2630
+ // src/lib/secrets.ts
2631
+ import { existsSync as existsSync10, readFileSync as readFileSync8 } from "node:fs";
2632
+ import { resolve as resolve8 } from "node:path";
3217
2633
 
3218
- Fields:
3219
- ${configFieldList()}
3220
- `);
3221
- return 1;
3222
- }
3223
- const root = configRoot(opts);
3224
- let outcome;
3225
- try {
3226
- outcome = setConfigField(root, key, value);
3227
- } catch (error) {
3228
- io.stderr(`oh config set: ${error instanceof Error ? error.message : String(error)}
3229
- `);
3230
- return 1;
3231
- }
3232
- io.stdout(
3233
- outcome === "already-set" ? `oh.json: ${key} already ${value}
3234
- ` : `oh.json: set ${key}=${value} (${outcome})
3235
- `
3236
- );
3237
- return 0;
3238
- }
3239
- var UPSTREAM_REMOTE = "openharness";
3240
- function repoUrl(owner, name) {
3241
- return `git@github.com:${owner}/${name}.git`;
3242
- }
3243
- function shellLine(step) {
3244
- return `${step.cmd} ${step.args.join(" ")}`;
3245
- }
3246
- function manualSteps(owner, name, visibility) {
3247
- return [
3248
- { cmd: "gh", args: ["repo", "create", `${owner}/${name}`, `--${visibility}`] },
3249
- { cmd: "git", args: ["remote", "rename", "origin", UPSTREAM_REMOTE] },
3250
- { cmd: "git", args: ["remote", "add", "origin", repoUrl(owner, name)] },
3251
- { cmd: "git", args: ["push", "-u", "origin", "HEAD"] }
3252
- ];
3253
- }
3254
- function printManual(io, steps, lead) {
3255
- io.stdout(`
3256
- ${lead}
3257
-
3258
- `);
3259
- for (const step of steps) io.stdout(` ${shellLine(step)}
3260
- `);
3261
- io.stdout("\n");
3262
- }
3263
- function validateSegment(kind, value) {
3264
- if (value === "") return `${kind} is required`;
3265
- if (value.startsWith("-")) return `invalid ${kind} "${value}" (must not start with "-")`;
3266
- if (!/^[A-Za-z0-9._-]+$/.test(value)) {
3267
- return `invalid ${kind} "${value}" (use letters, digits, ".", "_" or "-")`;
3268
- }
3269
- return void 0;
3270
- }
3271
- async function askOn(io, question) {
3272
- const askFn = io.ask ?? ask;
3273
- return (await askFn(question)).trim();
3274
- }
3275
- async function confirmOn(io, question, defaultYes) {
3276
- const answer = (await askOn(io, `${question} ${defaultYes ? "[Y/n]" : "[y/N]"}`)).toLowerCase();
3277
- if (answer === "") return defaultYes;
3278
- return /^y/.test(answer);
3279
- }
3280
- async function askVisibility(io) {
3281
- io.stdout(" Repository visibility\n 1) private (recommended)\n 2) public\n");
3282
- for (; ; ) {
3283
- const answer = await askOn(io, "Choose [1-2] (default 1):");
3284
- if (answer === "" || answer === "1") return "private";
3285
- if (answer === "2") return "public";
3286
- io.stderr(" Invalid choice. Pick 1 or 2.\n");
2634
+ // src/lib/env.ts
2635
+ import {
2636
+ readFileSync as readFileSync7,
2637
+ writeFileSync as writeFileSync5,
2638
+ existsSync as existsSync9,
2639
+ fsyncSync as fsyncSync2,
2640
+ renameSync as renameSync4,
2641
+ unlinkSync as unlinkSync4,
2642
+ openSync as openSync2,
2643
+ closeSync as closeSync2
2644
+ } from "node:fs";
2645
+ function loadEnvInto(envPath, env) {
2646
+ if (!existsSync9(envPath)) return;
2647
+ const content = readFileSync7(envPath, "utf8");
2648
+ for (const rawLine of content.split("\n")) {
2649
+ const line = rawLine.trimEnd();
2650
+ if (line === "" || /^\s*#/.test(line)) continue;
2651
+ const eqIdx = line.indexOf("=");
2652
+ if (eqIdx < 1) continue;
2653
+ const key = line.slice(0, eqIdx).trim();
2654
+ const value = line.slice(eqIdx + 1);
2655
+ if (key === "") continue;
2656
+ if (env[key] === void 0) env[key] = value;
3287
2657
  }
3288
2658
  }
3289
- async function runConfigRepo(opts, io) {
3290
- const run = opts.run ?? spawnRunner;
3291
- const isTTY = io.isTTY ?? process.stdin.isTTY === true;
3292
- if (!isTTY) {
3293
- io.stderr(
3294
- "oh config repo: creating a repo on your GitHub account needs an interactive terminal; re-run it from a TTY.\n"
3295
- );
3296
- return 1;
3297
- }
3298
- io.stdout(`
3299
- ${bold("Version this harness in your own repo")}
3300
- `);
3301
- io.stdout(
3302
- ` Creates a repo on your GitHub account, keeps the upstream you cloned from
3303
- as the "${UPSTREAM_REMOTE}" remote, and pushes this branch to yours.
3304
- `
3305
- );
3306
- if (!await confirmOn(io, "Version this harness in your own repo?", false)) {
3307
- printManual(
3308
- io,
3309
- manualSteps("<your-user>", "<repo>", "private"),
3310
- "Skipped \u2014 nothing was created and no remote was touched. To do it later, run `oh config repo`, or by hand:"
3311
- );
3312
- return 0;
3313
- }
3314
- const owner = await askOn(io, "GitHub owner (user or org):");
3315
- const ownerError = validateSegment("GitHub owner", owner);
3316
- if (ownerError) {
3317
- io.stderr(`oh config repo: ${ownerError}
3318
- `);
3319
- return 1;
3320
- }
3321
- const name = await askOn(io, "Repository name [openharness]:");
3322
- const repoName = name === "" ? "openharness" : name;
3323
- const nameError = validateSegment("repository name", repoName);
3324
- if (nameError) {
3325
- io.stderr(`oh config repo: ${nameError}
3326
- `);
3327
- return 1;
3328
- }
3329
- const visibility = await askVisibility(io);
3330
- const url = repoUrl(owner, repoName);
3331
- const manual = manualSteps(owner, repoName, visibility);
3332
- let root;
2659
+ function writeEnvFile(envPath, content) {
2660
+ const tmpPath = `${envPath}.tmp.${process.pid}`;
2661
+ let fd;
3333
2662
  try {
3334
- root = resolveProjectRoot(opts.cwd);
3335
- } catch (error) {
3336
- io.stderr(`oh config repo: ${error instanceof Error ? error.message : String(error)}
3337
- `);
3338
- printManual(io, manual, "Nothing was changed. Finish this by hand from your checkout:");
3339
- return 1;
3340
- }
3341
- const capture2 = { stdio: "capture", cwd: root };
3342
- const auth = run("gh", ["auth", "status"], capture2);
3343
- if (auth.error?.code === "ENOENT") {
3344
- printManual(io, manual, "gh is not on PATH \u2014 finish this by hand:");
3345
- return 0;
3346
- }
3347
- assertSpawned(auth, "gh auth status");
3348
- if (auth.status !== 0) {
3349
- printManual(
3350
- io,
3351
- manual,
3352
- "gh is not authenticated (`gh auth login`) \u2014 finish this by hand once it is:"
3353
- );
3354
- return 0;
3355
- }
3356
- const remotesResult = run("git", ["remote"], capture2);
3357
- assertSpawned(remotesResult, "git remote");
3358
- if (remotesResult.status !== 0) {
3359
- printManual(io, manual, "`git remote` failed \u2014 is this a git repo? Finish this by hand:");
3360
- return 0;
3361
- }
3362
- const remotes = (remotesResult.stdout ?? "").split("\n").map((line) => line.trim()).filter((line) => line !== "");
3363
- let originUrl = "";
3364
- if (remotes.includes("origin")) {
3365
- const shown = run("git", ["remote", "get-url", "origin"], capture2);
3366
- assertSpawned(shown, "git remote get-url origin");
3367
- originUrl = (shown.stdout ?? "").trim();
3368
- }
3369
- const originIsTarget = originUrl !== "" && sameRemote(originUrl, owner, repoName);
3370
- if (!originIsTarget && remotes.includes("origin") && remotes.includes(UPSTREAM_REMOTE)) {
3371
- io.stderr(
3372
- `oh config repo: origin already points at ${originUrl} and a "${UPSTREAM_REMOTE}" remote already exists \u2014 nothing was changed. Sort the remotes out by hand:
3373
- `
3374
- );
3375
- printManual(io, manual.slice(2), "Remaining steps:");
3376
- return 1;
3377
- }
3378
- const steps = [];
3379
- const exists = run("gh", ["repo", "view", `${owner}/${repoName}`], capture2);
3380
- assertSpawned(exists, "gh repo view");
3381
- if (exists.status === 0) {
3382
- io.stdout(` ${owner}/${repoName} already exists \u2014 skipping creation.
3383
- `);
3384
- } else {
3385
- steps.push(manual[0]);
3386
- }
3387
- if (originIsTarget) {
3388
- io.stdout(" origin already points at your repo \u2014 leaving the remotes alone.\n");
3389
- } else {
3390
- if (remotes.includes("origin")) steps.push(manual[1]);
3391
- steps.push(manual[2]);
3392
- }
3393
- steps.push(manual[3]);
3394
- for (let i = 0; i < steps.length; i++) {
3395
- const step = steps[i];
3396
- io.stdout(` $ ${shellLine(step)}
3397
- `);
3398
- const result = run(step.cmd, step.args, { stdio: "inherit", cwd: root });
3399
- if (result.error || result.status !== 0) {
3400
- const detail = result.error?.message ?? `exit ${result.status}`;
3401
- io.stderr(`oh config repo: ${shellLine(step)} failed (${detail}) \u2014 stopping here.
3402
- `);
3403
- const remaining = steps.slice(i + 1);
3404
- if (remaining.length > 0) {
3405
- printManual(io, remaining, "Not run. Finish by hand once the failure is fixed:");
2663
+ fd = openSync2(tmpPath, "w", 384);
2664
+ writeFileSync5(tmpPath, content, "utf8");
2665
+ fsyncSync2(fd);
2666
+ closeSync2(fd);
2667
+ fd = void 0;
2668
+ renameSync4(tmpPath, envPath);
2669
+ } catch (err) {
2670
+ if (fd !== void 0) {
2671
+ try {
2672
+ closeSync2(fd);
2673
+ } catch {
3406
2674
  }
3407
- return 1;
3408
2675
  }
2676
+ try {
2677
+ unlinkSync4(tmpPath);
2678
+ } catch {
2679
+ }
2680
+ throw err;
3409
2681
  }
3410
- io.stdout(
3411
- `
3412
- Done \u2014 origin is ${url}${remotes.includes("origin") && !originIsTarget ? `, upstream kept as "${UPSTREAM_REMOTE}"` : ""}.
3413
- `
3414
- );
3415
- return 0;
3416
- }
3417
- function sameRemote(url, owner, name) {
3418
- const normalized = url.replace(/\.git$/, "").toLowerCase();
3419
- const slug = `${owner}/${name}`.toLowerCase();
3420
- return normalized.endsWith(`:${slug}`) || normalized.endsWith(`/${slug}`);
3421
- }
3422
-
3423
- // src/commands/secret.ts
3424
- import { existsSync as existsSync13 } from "node:fs";
3425
- import { join as join8 } from "node:path";
3426
-
3427
- // src/lib/gitignore.ts
3428
- import { existsSync as existsSync12, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync6 } from "node:fs";
3429
- import { dirname as dirname7, join as join7 } from "node:path";
3430
- function appendGitignoreLines(root, lines) {
3431
- const target = join7(root, ".gitignore");
3432
- const existing = existsSync12(target) ? readFileSync10(target, "utf8") : "";
3433
- const present = new Set(existing.split("\n").map((line) => line.trimEnd()));
3434
- const added = [];
3435
- for (const line of lines) {
3436
- const key = line.trimEnd();
3437
- if (key === "" || present.has(key)) continue;
3438
- present.add(key);
3439
- added.push(line);
3440
- }
3441
- if (added.length === 0) return added;
3442
- let output = existing;
3443
- if (output.length > 0 && !output.endsWith("\n")) output += "\n";
3444
- output += `${added.join("\n")}
3445
- `;
3446
- mkdirSync5(dirname7(target), { recursive: true });
3447
- writeFileSync6(target, output, "utf8");
3448
- return added;
3449
2682
  }
3450
2683
 
3451
- // src/commands/secret.ts
3452
- function secretsRoot(opts) {
3453
- return opts.sandbox === void 0 ? resolveProjectRoot(opts.cwd) : resolveSandboxRoot({ name: opts.sandbox });
3454
- }
3455
- function ignoreSecretsFile(root) {
3456
- if (!existsSync13(join8(root, ".git"))) return;
3457
- appendGitignoreLines(root, [".env"]);
2684
+ // src/lib/secrets.ts
2685
+ var SECRETS_FILE = ".env";
2686
+ var SECRET_KEYS = [
2687
+ "GH_TOKEN",
2688
+ "XAI_API_KEY",
2689
+ "META_API_KEY",
2690
+ "SANDBOX_PASSWORD",
2691
+ "PI_SLACK_APP_TOKEN",
2692
+ "PI_SLACK_BOT_TOKEN",
2693
+ "LANGFUSE_PUBLIC_KEY",
2694
+ "LANGFUSE_SECRET_KEY"
2695
+ ];
2696
+ var ALLOWED = new Set(SECRET_KEYS);
2697
+ function secretsFilePath(root) {
2698
+ return resolve8(root, SECRETS_FILE);
3458
2699
  }
3459
- function secretKeyList() {
3460
- return SECRET_KEYS.map((key) => ` ${key}`).join("\n");
2700
+ function isSecretKey(key) {
2701
+ return ALLOWED.has(key);
3461
2702
  }
3462
- async function runSecretSet(key, opts, io) {
3463
- if (!isSecretKey(key)) {
3464
- io.stderr(
3465
- `oh secret set: ${key} is not a secret \u2014 non-secret settings live in oh.json.
3466
- Set it with \`oh config set ${key}\` instead.
3467
-
3468
- Keys:
3469
- ${secretKeyList()}
3470
- `
3471
- );
3472
- return 1;
3473
- }
3474
- const root = secretsRoot(opts);
3475
- const askSecret2 = io.askSecret ?? askSecret;
3476
- const value = (await askSecret2(`Value for ${bold(key)} (input hidden):`)).trim();
3477
- if (value === "") {
3478
- io.stderr(`oh secret set: no value entered \u2014 ${key} unchanged
3479
- `);
3480
- return 1;
3481
- }
3482
- try {
3483
- setSecret(root, key, value);
3484
- ignoreSecretsFile(root);
3485
- } catch (error) {
3486
- io.stderr(`oh secret set: ${error instanceof Error ? error.message : String(error)}
3487
- `);
3488
- return 1;
3489
- }
3490
- io.stdout(`.env: set ${key}=${redact(value)}
3491
- `);
3492
- return 0;
2703
+ function assertSecretKey(key) {
2704
+ if (ALLOWED.has(key)) return;
2705
+ throw new Error(
2706
+ `${key} is not a secret \u2014 non-secret settings live in oh.json; use \`${activeBin()} config set\` instead`
2707
+ );
3493
2708
  }
3494
- async function runSecretList(opts, io) {
3495
- const root = secretsRoot(opts);
3496
- const keys = listSecretKeys(root);
3497
- if (keys.length === 0) {
3498
- io.stdout("no secrets set \u2014 write one with `oh secret set <KEY>`\n");
3499
- return 0;
2709
+ function readSecret(root, key) {
2710
+ assertSecretKey(key);
2711
+ const file = secretsFilePath(root);
2712
+ if (!existsSync10(file)) return void 0;
2713
+ const env = {};
2714
+ loadEnvInto(file, env);
2715
+ const value = env[key]?.trim();
2716
+ return value === void 0 || value === "" ? void 0 : stripQuotes2(value);
2717
+ }
2718
+ function setSecret(root, key, value) {
2719
+ assertSecretKey(key);
2720
+ if (/[\r\n]/.test(value)) {
2721
+ throw new Error(`${key} value must not contain a newline`);
3500
2722
  }
3501
- const width = Math.max(...keys.map((key) => key.length));
3502
- for (const key of keys) {
3503
- const value = readSecret(root, key);
3504
- io.stdout(`${key.padEnd(width)} ${value === void 0 ? "\u2014" : redact(value)}
3505
- `);
2723
+ const file = secretsFilePath(root);
2724
+ assertInRoot(file, resolve8(root));
2725
+ const original = existsSync10(file) ? readFileSync8(file, "utf8") : "";
2726
+ const { content } = setKeyInEnv(original, key, value);
2727
+ writeEnvFile(file, content);
2728
+ }
2729
+ function listSecretKeys(root) {
2730
+ const file = secretsFilePath(root);
2731
+ if (!existsSync10(file)) return [];
2732
+ const env = {};
2733
+ loadEnvInto(file, env);
2734
+ return SECRET_KEYS.filter((key) => {
2735
+ const value = env[key]?.trim();
2736
+ return value !== void 0 && value !== "";
2737
+ });
2738
+ }
2739
+ function stripQuotes2(s) {
2740
+ if (s.length >= 2 && (s[0] === '"' && s.endsWith('"') || s[0] === "'" && s.endsWith("'"))) {
2741
+ return s.slice(1, -1);
3506
2742
  }
3507
- return 0;
2743
+ return s;
3508
2744
  }
3509
2745
 
3510
2746
  // src/commands/lifecycle.ts
3511
- import { existsSync as existsSync15, mkdtempSync, readFileSync as readFileSync11, rmSync, writeFileSync as writeFileSync7 } from "node:fs";
2747
+ import { existsSync as existsSync12, mkdtempSync, readFileSync as readFileSync9, rmSync, writeFileSync as writeFileSync6 } from "node:fs";
3512
2748
  import { tmpdir } from "node:os";
3513
- import { basename as basename5, join as join9, sep as sep4 } from "node:path";
2749
+ import { basename as basename5, join as join6, sep as sep4 } from "node:path";
3514
2750
 
3515
2751
  // src/lib/execution/detect.ts
3516
- import { existsSync as existsSync14 } from "node:fs";
2752
+ import { existsSync as existsSync11 } from "node:fs";
3517
2753
  var SANDBOX_MARKER_FILE = "/.dockerenv";
3518
- function runningInsideSandbox(env = process.env, fileExists = existsSync14) {
2754
+ function runningInsideSandbox(env = process.env, fileExists = existsSync11) {
3519
2755
  const override = resolveAliasedEnv(env, "EXECUTION_TARGET").value;
3520
2756
  if (override === "local") return true;
3521
2757
  if (override === "docker-compose") return false;
@@ -3672,10 +2908,10 @@ var LocalExecutionTarget = class {
3672
2908
  this.workspace = { hostRoot: opts.projectRoot, targetRoot: opts.projectRoot };
3673
2909
  }
3674
2910
  async provision() {
3675
- throw new HostOnlyError("`oh sandbox`");
2911
+ throw new HostOnlyError(`\`${activeBin()} sandbox\``);
3676
2912
  }
3677
2913
  async destroy() {
3678
- throw new HostOnlyError("`oh destroy`");
2914
+ throw new HostOnlyError(`\`${activeBin()} destroy\``);
3679
2915
  }
3680
2916
  async status() {
3681
2917
  return "ready";
@@ -3710,398 +2946,743 @@ var LocalExecutionTarget = class {
3710
2946
  env: this.childEnv(request),
3711
2947
  ...request.cwd !== void 0 ? { cwd: request.cwd } : {}
3712
2948
  });
3713
- assertSpawned(r, cmd);
3714
- return r.status ?? 1;
3715
- }
3716
- describe() {
3717
- return `local target inside the sandbox at ${this.projectRoot}`;
2949
+ assertSpawned(r, cmd);
2950
+ return r.status ?? 1;
2951
+ }
2952
+ describe() {
2953
+ return `local target inside the sandbox at ${this.projectRoot}`;
2954
+ }
2955
+ argvFor(request, interactive) {
2956
+ const argv = [...request.argv];
2957
+ const user = request.user;
2958
+ if (user === void 0) return argv;
2959
+ const me = this.identity();
2960
+ if (user === me.name) return argv;
2961
+ if (user === "root" && me.uid === 0) return argv;
2962
+ if (user === "root") {
2963
+ return interactive ? ["sudo", "--", ...argv] : ["sudo", "-n", "--", ...argv];
2964
+ }
2965
+ if (me.uid === 0) return ["gosu", user, ...argv];
2966
+ throw new Error(
2967
+ `cannot run as user "${user}" from user "${me.name}" inside the sandbox`
2968
+ );
2969
+ }
2970
+ childEnv(request) {
2971
+ return { ...this.env ?? process.env, ...request.env ?? {} };
2972
+ }
2973
+ };
2974
+ function defaultIdentity() {
2975
+ const info = userInfo();
2976
+ return { name: info.username, uid: info.uid };
2977
+ }
2978
+
2979
+ // src/lib/execution/index.ts
2980
+ function resolveExecutionTarget(opts) {
2981
+ if (runningInsideSandbox(opts.env ?? process.env)) {
2982
+ return new LocalExecutionTarget({
2983
+ projectRoot: opts.projectRoot,
2984
+ ...opts.run ? { run: opts.run } : {},
2985
+ ...opts.env ? { env: opts.env } : {}
2986
+ });
2987
+ }
2988
+ return new DockerComposeExecutionTarget(opts);
2989
+ }
2990
+
2991
+ // src/lib/config-render.ts
2992
+ var RETIRED_KEYS = [
2993
+ "WORKTREES_DIR",
2994
+ "PROJECTS_DIR",
2995
+ "CRONS_DIR",
2996
+ "OH_PROJECT_ROOT",
2997
+ "AGRO_PROJECT_ROOT",
2998
+ "INSTALL_DEEPAGENTS",
2999
+ "INSTALL_OPENCODE",
3000
+ "INSTALL_GROK_BUILD",
3001
+ "INSTALL_HERMES",
3002
+ "INSTALL_AGENT_BROWSER",
3003
+ "INSTALL_TAILSCALE",
3004
+ "SANDBOX_SSH_PASSWORD_AUTH",
3005
+ "SANDBOX_SSH_AUTHORIZED_KEYS",
3006
+ "HERMES_DASHBOARD",
3007
+ "HERMES_DASHBOARD_PORT",
3008
+ "CRON_AGENT_BIN",
3009
+ "SKIP_PNPM_INSTALL",
3010
+ "LANGFUSE_BASE_URL",
3011
+ "LANGFUSE_PRIVACY_PRESET"
3012
+ ];
3013
+ function renderComposeVars(config) {
3014
+ const out = [];
3015
+ const put = (key, value) => {
3016
+ if (value === void 0) return;
3017
+ out.push({ key, value: String(value) });
3018
+ };
3019
+ put("SANDBOX_NAME", config.name);
3020
+ put("TZ", config.timezone);
3021
+ put("AGRO_HOME_MOUNT", config.storage?.homePath);
3022
+ put("AGRO_REPO_DIR", configCheckout(config));
3023
+ put("GIT_USER_NAME", config.git?.userName);
3024
+ put("GIT_USER_EMAIL", config.git?.userEmail);
3025
+ put("DOCKER_SOCKET", config.access?.dockerSocket);
3026
+ put("SANDBOX_SSH", config.access?.ssh);
3027
+ put("SANDBOX_SSH_PORT", config.access?.sshPort);
3028
+ put("AGRO_SANDBOX_IMAGE", config.image?.ref);
3029
+ put("AGRO_PULL_POLICY", config.image?.pullPolicy);
3030
+ for (const { key, value } of out) {
3031
+ if (isSecretKey(key)) {
3032
+ throw new Error(`refusing to render secret ${key} from the sandbox config`);
3033
+ }
3034
+ if (RETIRED_KEYS.includes(key)) {
3035
+ throw new Error(`refusing to render retired variable ${key}`);
3036
+ }
3037
+ if (/[\r\n]/.test(value)) {
3038
+ throw new Error(`config value for ${key} must not contain a newline`);
3039
+ }
3040
+ }
3041
+ return out;
3042
+ }
3043
+ function renderComposeEnv(config) {
3044
+ const vars = renderComposeVars(config);
3045
+ return vars.length === 0 ? "" : `${vars.map(({ key, value }) => `${key}=${value}`).join("\n")}
3046
+ `;
3047
+ }
3048
+
3049
+ // src/commands/lifecycle.ts
3050
+ var COMPOSE_ENV_DIR_PREFIX = "oh-compose-env-";
3051
+ function composeEnvDir() {
3052
+ return mkdtempSync(join6(tmpdir(), COMPOSE_ENV_DIR_PREFIX));
3053
+ }
3054
+ function writeComposeEnv(root, dir) {
3055
+ const file = join6(dir, "compose.env");
3056
+ writeFileSync6(file, renderComposeEnv(readOhConfig(ohConfigPath(root))), {
3057
+ mode: 384,
3058
+ encoding: "utf8"
3059
+ });
3060
+ return file;
3061
+ }
3062
+ function withComposeEnvFile(root, fn) {
3063
+ if (!existsSync12(ohConfigPath(root))) return fn([]);
3064
+ const dir = composeEnvDir();
3065
+ try {
3066
+ return fn(["--extra-env-file", writeComposeEnv(root, dir)]);
3067
+ } finally {
3068
+ rmSync(dir, { recursive: true, force: true });
3069
+ }
3070
+ }
3071
+ async function withComposeEnvFileAsync(root, fn) {
3072
+ if (!existsSync12(ohConfigPath(root))) return await fn([]);
3073
+ const dir = composeEnvDir();
3074
+ try {
3075
+ return await fn(["--extra-env-file", writeComposeEnv(root, dir)]);
3076
+ } finally {
3077
+ rmSync(dir, { recursive: true, force: true });
3078
+ }
3079
+ }
3080
+ function sandboxRoot(opts) {
3081
+ const root = resolveSandboxRoot({ name: opts.name, cwd: opts.cwd });
3082
+ if (existsSync12(ohConfigPath(root))) {
3083
+ const repo = configuredString(root, "repo");
3084
+ materialize(root, repo === void 0 ? {} : { repo });
3085
+ }
3086
+ return root;
3087
+ }
3088
+ var DEFAULT_CONTAINER_NAME = DEFAULT_SANDBOX_NAME;
3089
+ var DEFAULT_SANDBOX_IMAGE = "ghcr.io/mifunedev/agro:latest";
3090
+ function configuredField(root, path4) {
3091
+ const file = ohConfigPath(root);
3092
+ if (!existsSync12(file)) return void 0;
3093
+ try {
3094
+ return getOhConfigValue(readOhConfig(file), path4);
3095
+ } catch {
3096
+ return void 0;
3097
+ }
3098
+ }
3099
+ function configuredString(root, path4) {
3100
+ const value = configuredField(root, path4);
3101
+ return typeof value === "string" && value !== "" ? value : void 0;
3102
+ }
3103
+ function fromProcessEnv(key) {
3104
+ const value = process.env[key];
3105
+ return value !== void 0 && value !== "" ? value : void 0;
3106
+ }
3107
+ function dockerSocketConfigured(root) {
3108
+ return configuredField(root, "access.dockerSocket") !== void 0;
3109
+ }
3110
+ async function maybePromptDockerSocket(root, io, bin) {
3111
+ if (dockerSocketConfigured(root)) return;
3112
+ const interactive = process.stdin.isTTY === true || io.ask !== void 0;
3113
+ if (!interactive) return;
3114
+ const envDir = join6(root, ".devcontainer");
3115
+ if (!existsSync12(envDir)) return;
3116
+ const askFn = io.ask ?? ask;
3117
+ const answer = (await askFn(
3118
+ "Mount host Docker socket into the sandbox? (effectively host root \u2014 enable only if the agent must drive Docker) [y/N]"
3119
+ )).trim().toLowerCase();
3120
+ const enabled = answer === "y" || answer === "yes";
3121
+ setEnvValue(root, "DOCKER_SOCKET", enabled ? "true" : "false", bin);
3122
+ io.stdout(
3123
+ enabled ? "DOCKER_SOCKET=true \u2014 host Docker socket will be mounted\n" : "DOCKER_SOCKET=false \u2014 host Docker socket stays unmounted\n"
3124
+ );
3125
+ }
3126
+ function configuredImage(root) {
3127
+ return aliasedEnvValue(process.env, "SANDBOX_IMAGE") ?? configuredString(root, "image.ref");
3128
+ }
3129
+ async function runSandbox(opts, io) {
3130
+ const run = opts.run ?? spawnRunner;
3131
+ const root = resolveProjectRoot(opts.cwd);
3132
+ const useImage = opts.image === true || opts.imageRef !== void 0;
3133
+ const useNoBuild = useImage || opts.noBuild === true;
3134
+ const imageRef = useImage ? opts.imageRef ?? configuredImage(root) ?? DEFAULT_SANDBOX_IMAGE : void 0;
3135
+ const env = imageRef === void 0 ? void 0 : { ...process.env, ...aliasedEnvPair("SANDBOX_IMAGE", imageRef) };
3136
+ if (opts.printArgv === true) {
3137
+ const script = requireLifecycleScript(root, "docker-compose.sh");
3138
+ return withComposeEnvFile(root, (extraArgs) => {
3139
+ const r = run(
3140
+ "bash",
3141
+ [
3142
+ script,
3143
+ "--repo-dir",
3144
+ root,
3145
+ ...extraArgs,
3146
+ "--print-argv",
3147
+ "up",
3148
+ "-d",
3149
+ useNoBuild ? "--no-build" : "--build"
3150
+ ],
3151
+ { stdio: "inherit", ...env ? { env } : {} }
3152
+ );
3153
+ assertSpawned(r, `bash ${script}`);
3154
+ return r.status ?? 1;
3155
+ });
3156
+ }
3157
+ if (runningInsideSandbox()) {
3158
+ io.stderr(`${new HostOnlyError(`\`${opts.bin} sandbox\``).message}
3159
+ `);
3160
+ return 1;
3161
+ }
3162
+ await maybePromptDockerSocket(root, io, opts.bin);
3163
+ requireLifecycleScript(root, "docker-compose.sh");
3164
+ if (imageRef !== void 0) {
3165
+ io.stdout(`image mode: ${imageRef} (skipping local build)
3166
+ `);
3167
+ } else if (useNoBuild) {
3168
+ io.stdout("no-build mode: reusing the existing image (skipping local build)\n");
3169
+ }
3170
+ try {
3171
+ await withComposeEnvFileAsync(root, async (extraArgs) => {
3172
+ const target = resolveExecutionTarget({
3173
+ projectRoot: root,
3174
+ run,
3175
+ build: !useNoBuild,
3176
+ ...extraArgs.length > 0 ? { extraEnvFile: extraArgs[1] } : {},
3177
+ ...env ? { env } : {}
3178
+ });
3179
+ await target.provision();
3180
+ });
3181
+ return 0;
3182
+ } catch (err) {
3183
+ if (err instanceof ExecutionExitError) return err.exitCode;
3184
+ if (err instanceof HostOnlyError) {
3185
+ io.stderr(`${err.message}
3186
+ `);
3187
+ return 1;
3188
+ }
3189
+ throw err;
3718
3190
  }
3719
- argvFor(request, interactive) {
3720
- const argv = [...request.argv];
3721
- const user = request.user;
3722
- if (user === void 0) return argv;
3723
- const me = this.identity();
3724
- if (user === me.name) return argv;
3725
- if (user === "root" && me.uid === 0) return argv;
3726
- if (user === "root") {
3727
- return interactive ? ["sudo", "--", ...argv] : ["sudo", "-n", "--", ...argv];
3191
+ }
3192
+ function configuredContainerName(root) {
3193
+ return fromProcessEnv("SANDBOX_NAME") ?? configuredString(root, "name");
3194
+ }
3195
+ function runShell(opts, io) {
3196
+ const run = opts.run ?? spawnRunner;
3197
+ const root = sandboxRoot(opts);
3198
+ const name = configuredContainerName(root) ?? DEFAULT_CONTAINER_NAME;
3199
+ const target = resolveExecutionTarget({ projectRoot: root, container: name, run });
3200
+ let code;
3201
+ try {
3202
+ code = target.attach({ argv: ["zsh"], user: "sandbox" });
3203
+ } catch (err) {
3204
+ if (err instanceof ExecutionSpawnError && err.code === "ENOENT") {
3205
+ throw new Error(`docker is required for \`${opts.bin} shell\` but was not found on PATH`);
3728
3206
  }
3729
- if (me.uid === 0) return ["gosu", user, ...argv];
3730
- throw new Error(
3731
- `cannot run as user "${user}" from user "${me.name}" inside the sandbox`
3732
- );
3207
+ throw err;
3733
3208
  }
3734
- childEnv(request) {
3735
- return { ...this.env ?? process.env, ...request.env ?? {} };
3209
+ if (code !== 0) {
3210
+ io.stderr(
3211
+ `container \`${name}\` not running? start it with \`${opts.bin} sandbox install docker\`
3212
+ `
3213
+ );
3736
3214
  }
3737
- };
3738
- function defaultIdentity() {
3739
- const info = userInfo();
3740
- return { name: info.username, uid: info.uid };
3215
+ return code;
3741
3216
  }
3742
-
3743
- // src/lib/execution/index.ts
3744
- function resolveExecutionTarget(opts) {
3745
- if (runningInsideSandbox(opts.env ?? process.env)) {
3746
- return new LocalExecutionTarget({
3747
- projectRoot: opts.projectRoot,
3748
- ...opts.run ? { run: opts.run } : {},
3749
- ...opts.env ? { env: opts.env } : {}
3217
+ var COMPOSE_VERBS = Object.freeze({
3218
+ stop: Object.freeze(["stop"]),
3219
+ restart: Object.freeze(["restart"]),
3220
+ logs: Object.freeze(["logs", "-f"]),
3221
+ ps: Object.freeze(["ps"]),
3222
+ destroy: Object.freeze(["down", "-v"])
3223
+ });
3224
+ function composeVerbs() {
3225
+ return Object.keys(COMPOSE_VERBS);
3226
+ }
3227
+ function runComposeVerb(verb, opts, extra = []) {
3228
+ const run = opts.run ?? spawnRunner;
3229
+ const root = sandboxRoot(opts);
3230
+ const script = requireLifecycleScript(root, "docker-compose.sh");
3231
+ return withComposeEnvFile(root, (extraArgs) => {
3232
+ const r = run("bash", [script, ...extraArgs, ...COMPOSE_VERBS[verb], ...extra], {
3233
+ stdio: "inherit"
3750
3234
  });
3235
+ assertSpawned(r, `bash ${script} ${verb}`);
3236
+ return r.status ?? 1;
3237
+ });
3238
+ }
3239
+ function runComposeConfig(opts, extra = []) {
3240
+ const run = opts.run ?? spawnRunner;
3241
+ const root = sandboxRoot(opts);
3242
+ const script = requireLifecycleScript(root, "docker-compose.sh");
3243
+ return withComposeEnvFile(root, (extraArgs) => {
3244
+ const r = run("bash", [script, ...extraArgs, "config", ...extra], { stdio: "inherit" });
3245
+ assertSpawned(r, `bash ${script} config`);
3246
+ return r.status ?? 1;
3247
+ });
3248
+ }
3249
+ function namedVolumes(root) {
3250
+ let text;
3251
+ try {
3252
+ text = readFileSync9(join6(root, ".devcontainer", "docker-compose.yml"), "utf8");
3253
+ } catch {
3254
+ return [];
3751
3255
  }
3752
- return new DockerComposeExecutionTarget(opts);
3256
+ const lines = text.split("\n");
3257
+ const start = lines.findIndex((line) => /^volumes:\s*$/.test(line));
3258
+ if (start === -1) return [];
3259
+ const names = [];
3260
+ for (const line of lines.slice(start + 1)) {
3261
+ if (/^\S/.test(line)) break;
3262
+ const match = /^ {2}([A-Za-z0-9][A-Za-z0-9_.-]*):\s*$/.exec(line);
3263
+ if (match) names.push(match[1]);
3264
+ }
3265
+ return names;
3753
3266
  }
3754
-
3755
- // src/lib/config-render.ts
3756
- var RETIRED_KEYS = [
3757
- "WORKTREES_DIR",
3758
- "PROJECTS_DIR",
3759
- "CRONS_DIR",
3760
- "OH_PROJECT_ROOT",
3761
- "AGRO_PROJECT_ROOT",
3762
- "INSTALL_DEEPAGENTS",
3763
- "INSTALL_OPENCODE",
3764
- "INSTALL_GROK_BUILD",
3765
- "INSTALL_HERMES",
3766
- "INSTALL_AGENT_BROWSER",
3767
- "INSTALL_TAILSCALE",
3768
- "SANDBOX_SSH_PASSWORD_AUTH",
3769
- "SANDBOX_SSH_AUTHORIZED_KEYS",
3770
- "HERMES_DASHBOARD",
3771
- "HERMES_DASHBOARD_PORT",
3772
- "CRON_AGENT_BIN",
3773
- "SKIP_PNPM_INSTALL",
3774
- "LANGFUSE_BASE_URL",
3775
- "LANGFUSE_PRIVACY_PRESET"
3776
- ];
3777
- function renderComposeVars(config) {
3778
- const out = [];
3779
- const put = (key, value) => {
3780
- if (value === void 0) return;
3781
- out.push({ key, value: String(value) });
3782
- };
3783
- put("SANDBOX_NAME", config.name);
3784
- put("TZ", config.timezone);
3785
- put("AGRO_HOME_MOUNT", config.storage?.homePath);
3786
- put("AGRO_REPO_DIR", config.repo);
3787
- put("GIT_USER_NAME", config.git?.userName);
3788
- put("GIT_USER_EMAIL", config.git?.userEmail);
3789
- put("DOCKER_SOCKET", config.access?.dockerSocket);
3790
- put("SANDBOX_SSH", config.access?.ssh);
3791
- put("SANDBOX_SSH_PORT", config.access?.sshPort);
3792
- put("AGRO_SANDBOX_IMAGE", config.image?.ref);
3793
- put("AGRO_PULL_POLICY", config.image?.pullPolicy);
3794
- for (const { key, value } of out) {
3795
- if (isSecretKey(key)) {
3796
- throw new Error(`refusing to render secret ${key} from the sandbox config`);
3267
+ function destroyConfirmationPhrase(root) {
3268
+ return configuredContainerName(root) ?? DEFAULT_CONTAINER_NAME;
3269
+ }
3270
+ async function runDestroy(opts, io) {
3271
+ const root = sandboxRoot(opts);
3272
+ const name = destroyConfirmationPhrase(root);
3273
+ if (opts.yes !== true) {
3274
+ const interactive = process.stdin.isTTY === true || io.ask !== void 0;
3275
+ if (!interactive) {
3276
+ io.stderr(
3277
+ `${opts.bin} destroy: refusing to destroy \`${name}\` without a terminal \u2014 re-run with --yes to confirm non-interactively
3278
+ `
3279
+ );
3280
+ return 1;
3797
3281
  }
3798
- if (RETIRED_KEYS.includes(key)) {
3799
- throw new Error(`refusing to render retired variable ${key}`);
3282
+ const homePath = readOhConfig(ohConfigPath(root)).storage?.homePath;
3283
+ io.stdout(`
3284
+ ${bold(`${opts.bin} destroy \u2014 ${name}`)}
3285
+
3286
+ `);
3287
+ if (homePath !== void 0 && homePath !== "") {
3288
+ io.stdout("`docker compose down -v` removes the containers.\n");
3289
+ io.stdout(
3290
+ `This sandbox keeps its home on a host bind at ${homePath}, which \`down -v\`
3291
+ does not touch \u2014 every agent CLI login, the gh CLI token, and the SSH keys
3292
+ stay there. Delete that directory yourself if you also want them gone.
3293
+
3294
+ `
3295
+ );
3296
+ } else {
3297
+ const volumes = namedVolumes(root).map((volume) => `${name}_${volume}`);
3298
+ io.stdout("`docker compose down -v` removes the containers and deletes\n");
3299
+ io.stdout(
3300
+ volumes.length > 0 ? `these named volumes with everything in them:
3301
+
3302
+ ${volumes.join("\n ")}
3303
+
3304
+ ` : "every named volume this project owns, with everything in them.\n\n"
3305
+ );
3306
+ io.stdout(
3307
+ "That is the provider authentication those volumes hold \u2014 every agent CLI\nlogin, the gh CLI token, and the SSH keys. Sign-in starts over.\n\n"
3308
+ );
3800
3309
  }
3801
- if (/[\r\n]/.test(value)) {
3802
- throw new Error(`config value for ${key} must not contain a newline`);
3310
+ const askFn = io.ask ?? ask;
3311
+ const answer = (await askFn(`Type the sandbox name \`${name}\` to destroy it:`)).trim();
3312
+ if (answer !== name) {
3313
+ io.stderr(`${opts.bin} destroy: aborted \u2014 nothing was removed
3314
+ `);
3315
+ return 1;
3803
3316
  }
3804
3317
  }
3805
- return out;
3318
+ const code = runComposeVerb("destroy", { ...opts, name: basename5(root) });
3319
+ if (code === 0 && root.startsWith(registryRoot() + sep4)) {
3320
+ rmSync(root, { recursive: true, force: true });
3321
+ io.stdout(`removed the sandbox entry ${root}
3322
+ `);
3323
+ }
3324
+ return code;
3806
3325
  }
3807
- function renderComposeEnv(config) {
3808
- const vars = renderComposeVars(config);
3809
- return vars.length === 0 ? "" : `${vars.map(({ key, value }) => `${key}=${value}`).join("\n")}
3810
- `;
3326
+ function runGateway(args, opts) {
3327
+ const run = opts.run ?? spawnRunner;
3328
+ const root = resolveProjectRoot(opts.cwd);
3329
+ const script = requireLifecycleScript(root, "gateway.sh");
3330
+ const r = run("bash", [script, ...args], {
3331
+ stdio: "inherit",
3332
+ env: { ...process.env, ...aliasedEnvPair("PROJECT_ROOT", root) }
3333
+ });
3334
+ assertSpawned(r, `bash ${script}`);
3335
+ return r.status ?? 1;
3811
3336
  }
3812
3337
 
3813
- // src/commands/lifecycle.ts
3814
- var COMPOSE_ENV_DIR_PREFIX = "oh-compose-env-";
3815
- function composeEnvDir() {
3816
- return mkdtempSync(join9(tmpdir(), COMPOSE_ENV_DIR_PREFIX));
3817
- }
3818
- function writeComposeEnv(root, dir) {
3819
- const file = join9(dir, "compose.env");
3820
- writeFileSync7(file, renderComposeEnv(readOhConfig(ohConfigPath(root))), {
3821
- mode: 384,
3822
- encoding: "utf8"
3823
- });
3824
- return file;
3338
+ // src/commands/config.ts
3339
+ var HOME_PATH_FIELD = "storage.homePath";
3340
+ function currentHomePath(root) {
3341
+ return readOhConfig(ohConfigPath(root)).storage?.homePath;
3825
3342
  }
3826
- function withComposeEnvFile(root, fn) {
3827
- if (!existsSync15(ohConfigPath(root))) return fn([]);
3828
- const dir = composeEnvDir();
3829
- try {
3830
- return fn(["--extra-env-file", writeComposeEnv(root, dir)]);
3831
- } finally {
3832
- rmSync(dir, { recursive: true, force: true });
3343
+ function existingNamedVolume(root, run) {
3344
+ const project = destroyConfirmationPhrase(root);
3345
+ for (const volume of namedVolumes(root).map((name) => `${project}_${name}`)) {
3346
+ const result = run("docker", ["volume", "inspect", volume], { stdio: "capture" });
3347
+ if (result.error === void 0 && result.status === 0) return volume;
3833
3348
  }
3349
+ return void 0;
3350
+ }
3351
+ function configRoot(opts) {
3352
+ return opts.sandbox === void 0 ? resolveProjectRoot(opts.cwd) : resolveSandboxRoot({ name: opts.sandbox });
3353
+ }
3354
+ function configFieldList() {
3355
+ return ohConfigFieldPaths().map((path4) => ` ${path4}`).join("\n");
3356
+ }
3357
+ async function runConfigShow(opts, io) {
3358
+ const root = configRoot(opts);
3359
+ const config = readOhConfig(ohConfigPath(root));
3360
+ io.stdout(`${JSON.stringify(config, null, 2)}
3361
+ `);
3362
+ return 0;
3834
3363
  }
3835
- async function withComposeEnvFileAsync(root, fn) {
3836
- if (!existsSync15(ohConfigPath(root))) return await fn([]);
3837
- const dir = composeEnvDir();
3838
- try {
3839
- return await fn(["--extra-env-file", writeComposeEnv(root, dir)]);
3840
- } finally {
3841
- rmSync(dir, { recursive: true, force: true });
3364
+ async function runConfigSet(key, value, opts, io) {
3365
+ if (isSecretKey(key) || isSecretKey(key.toUpperCase())) {
3366
+ io.stderr(
3367
+ `${opts.bin} config set: ${key.toUpperCase()} is a secret \u2014 oh.json is tracked by git.
3368
+ Set it with \`${opts.bin} secret set ${key.toUpperCase()}\` instead.
3369
+ `
3370
+ );
3371
+ return 1;
3842
3372
  }
3843
- }
3844
- function sandboxRoot(opts) {
3845
- const root = resolveSandboxRoot({ name: opts.name, cwd: opts.cwd });
3846
- if (existsSync15(ohConfigPath(root))) {
3847
- const repo = configuredString(root, "repo");
3848
- materialize(root, repo === void 0 ? {} : { repo });
3373
+ if (!findOhConfigField(key)) {
3374
+ io.stderr(`${opts.bin} config set: unknown field "${key}"
3375
+
3376
+ Fields:
3377
+ ${configFieldList()}
3378
+ `);
3379
+ return 1;
3849
3380
  }
3850
- return root;
3851
- }
3852
- var DEFAULT_CONTAINER_NAME = DEFAULT_SANDBOX_NAME;
3853
- var DEFAULT_SANDBOX_IMAGE = "ghcr.io/mifunedev/agro:latest";
3854
- function configuredField(root, path4) {
3855
- const file = ohConfigPath(root);
3856
- if (!existsSync15(file)) return void 0;
3381
+ const root = configRoot(opts);
3382
+ if (key === HOME_PATH_FIELD && opts.force !== true && currentHomePath(root) !== value) {
3383
+ const volume = existingNamedVolume(root, opts.run ?? spawnRunner);
3384
+ if (volume !== void 0) {
3385
+ io.stderr(
3386
+ `${opts.bin} config set: ${HOME_PATH_FIELD} refused \u2014 the named volume ${volume} already exists.
3387
+ The next start would mount ${value} at /home/sandbox instead, and every file already in ${volume} would be orphaned there, invisible to the sandbox.
3388
+ Pass --force to change it anyway.
3389
+ `
3390
+ );
3391
+ return 1;
3392
+ }
3393
+ }
3394
+ let outcome;
3857
3395
  try {
3858
- return getOhConfigValue(readOhConfig(file), path4);
3859
- } catch {
3860
- return void 0;
3396
+ outcome = setConfigField(root, key, value);
3397
+ } catch (error) {
3398
+ io.stderr(`${opts.bin} config set: ${error instanceof Error ? error.message : String(error)}
3399
+ `);
3400
+ return 1;
3861
3401
  }
3402
+ io.stdout(
3403
+ outcome === "already-set" ? `oh.json: ${key} already ${value}
3404
+ ` : `oh.json: set ${key}=${value} (${outcome})
3405
+ `
3406
+ );
3407
+ return 0;
3862
3408
  }
3863
- function configuredString(root, path4) {
3864
- const value = configuredField(root, path4);
3865
- return typeof value === "string" && value !== "" ? value : void 0;
3409
+ var UPSTREAM_REMOTE = "openharness";
3410
+ function repoUrl(owner, name) {
3411
+ return `git@github.com:${owner}/${name}.git`;
3866
3412
  }
3867
- function fromProcessEnv(key) {
3868
- const value = process.env[key];
3869
- return value !== void 0 && value !== "" ? value : void 0;
3413
+ function shellLine(step) {
3414
+ return `${step.cmd} ${step.args.join(" ")}`;
3870
3415
  }
3871
- function dockerSocketConfigured(root) {
3872
- return configuredField(root, "access.dockerSocket") !== void 0;
3416
+ function manualSteps(owner, name, visibility) {
3417
+ return [
3418
+ { cmd: "gh", args: ["repo", "create", `${owner}/${name}`, `--${visibility}`] },
3419
+ { cmd: "git", args: ["remote", "rename", "origin", UPSTREAM_REMOTE] },
3420
+ { cmd: "git", args: ["remote", "add", "origin", repoUrl(owner, name)] },
3421
+ { cmd: "git", args: ["push", "-u", "origin", "HEAD"] }
3422
+ ];
3873
3423
  }
3874
- async function maybePromptDockerSocket(root, io) {
3875
- if (dockerSocketConfigured(root)) return;
3876
- const interactive = process.stdin.isTTY === true || io.ask !== void 0;
3877
- if (!interactive) return;
3878
- const envDir = join9(root, ".devcontainer");
3879
- if (!existsSync15(envDir)) return;
3424
+ function printManual(io, steps, lead) {
3425
+ io.stdout(`
3426
+ ${lead}
3427
+
3428
+ `);
3429
+ for (const step of steps) io.stdout(` ${shellLine(step)}
3430
+ `);
3431
+ io.stdout("\n");
3432
+ }
3433
+ function validateSegment(kind, value) {
3434
+ if (value === "") return `${kind} is required`;
3435
+ if (value.startsWith("-")) return `invalid ${kind} "${value}" (must not start with "-")`;
3436
+ if (!/^[A-Za-z0-9._-]+$/.test(value)) {
3437
+ return `invalid ${kind} "${value}" (use letters, digits, ".", "_" or "-")`;
3438
+ }
3439
+ return void 0;
3440
+ }
3441
+ async function askOn(io, question) {
3880
3442
  const askFn = io.ask ?? ask;
3881
- const answer = (await askFn(
3882
- "Mount host Docker socket into the sandbox? (effectively host root \u2014 enable only if the agent must drive Docker) [y/N]"
3883
- )).trim().toLowerCase();
3884
- const enabled = answer === "y" || answer === "yes";
3885
- setEnvValue(root, "DOCKER_SOCKET", enabled ? "true" : "false");
3886
- io.stdout(
3887
- enabled ? "DOCKER_SOCKET=true \u2014 host Docker socket will be mounted\n" : "DOCKER_SOCKET=false \u2014 host Docker socket stays unmounted\n"
3888
- );
3443
+ return (await askFn(question)).trim();
3889
3444
  }
3890
- function configuredImage(root) {
3891
- return aliasedEnvValue(process.env, "SANDBOX_IMAGE") ?? configuredString(root, "image.ref");
3445
+ async function confirmOn(io, question, defaultYes) {
3446
+ const answer = (await askOn(io, `${question} ${defaultYes ? "[Y/n]" : "[y/N]"}`)).toLowerCase();
3447
+ if (answer === "") return defaultYes;
3448
+ return /^y/.test(answer);
3892
3449
  }
3893
- async function runSandbox(opts, io) {
3450
+ async function askVisibility(io) {
3451
+ io.stdout(" Repository visibility\n 1) private (recommended)\n 2) public\n");
3452
+ for (; ; ) {
3453
+ const answer = await askOn(io, "Choose [1-2] (default 1):");
3454
+ if (answer === "" || answer === "1") return "private";
3455
+ if (answer === "2") return "public";
3456
+ io.stderr(" Invalid choice. Pick 1 or 2.\n");
3457
+ }
3458
+ }
3459
+ async function runConfigRepo(opts, io) {
3894
3460
  const run = opts.run ?? spawnRunner;
3895
- const root = resolveProjectRoot(opts.cwd);
3896
- const useImage = opts.image === true || opts.imageRef !== void 0;
3897
- const useNoBuild = useImage || opts.noBuild === true;
3898
- const imageRef = useImage ? opts.imageRef ?? configuredImage(root) ?? DEFAULT_SANDBOX_IMAGE : void 0;
3899
- const env = imageRef === void 0 ? void 0 : { ...process.env, ...aliasedEnvPair("SANDBOX_IMAGE", imageRef) };
3900
- if (opts.printArgv === true) {
3901
- const script = requireLifecycleScript(root, "docker-compose.sh");
3902
- return withComposeEnvFile(root, (extraArgs) => {
3903
- const r = run(
3904
- "bash",
3905
- [
3906
- script,
3907
- "--repo-dir",
3908
- root,
3909
- ...extraArgs,
3910
- "--print-argv",
3911
- "up",
3912
- "-d",
3913
- useNoBuild ? "--no-build" : "--build"
3914
- ],
3915
- { stdio: "inherit", ...env ? { env } : {} }
3916
- );
3917
- assertSpawned(r, `bash ${script}`);
3918
- return r.status ?? 1;
3919
- });
3461
+ const isTTY = io.isTTY ?? process.stdin.isTTY === true;
3462
+ if (!isTTY) {
3463
+ io.stderr(
3464
+ `${opts.bin} config repo: creating a repo on your GitHub account needs an interactive terminal; re-run it from a TTY.
3465
+ `
3466
+ );
3467
+ return 1;
3920
3468
  }
3921
- if (runningInsideSandbox()) {
3922
- io.stderr(`${new HostOnlyError("`oh sandbox`").message}
3469
+ io.stdout(`
3470
+ ${bold("Version this harness in your own repo")}
3471
+ `);
3472
+ io.stdout(
3473
+ ` Creates a repo on your GitHub account, keeps the upstream you cloned from
3474
+ as the "${UPSTREAM_REMOTE}" remote, and pushes this branch to yours.
3475
+ `
3476
+ );
3477
+ if (!await confirmOn(io, "Version this harness in your own repo?", false)) {
3478
+ printManual(
3479
+ io,
3480
+ manualSteps("<your-user>", "<repo>", "private"),
3481
+ `Skipped \u2014 nothing was created and no remote was touched. To do it later, run \`${opts.bin} config repo\`, or by hand:`
3482
+ );
3483
+ return 0;
3484
+ }
3485
+ const owner = await askOn(io, "GitHub owner (user or org):");
3486
+ const ownerError = validateSegment("GitHub owner", owner);
3487
+ if (ownerError) {
3488
+ io.stderr(`${opts.bin} config repo: ${ownerError}
3923
3489
  `);
3924
3490
  return 1;
3925
3491
  }
3926
- await maybePromptDockerSocket(root, io);
3927
- requireLifecycleScript(root, "docker-compose.sh");
3928
- if (imageRef !== void 0) {
3929
- io.stdout(`image mode: ${imageRef} (skipping local build)
3492
+ const name = await askOn(io, "Repository name [openharness]:");
3493
+ const repoName = name === "" ? "openharness" : name;
3494
+ const nameError = validateSegment("repository name", repoName);
3495
+ if (nameError) {
3496
+ io.stderr(`${opts.bin} config repo: ${nameError}
3930
3497
  `);
3931
- } else if (useNoBuild) {
3932
- io.stdout("no-build mode: reusing the existing image (skipping local build)\n");
3498
+ return 1;
3933
3499
  }
3500
+ const visibility = await askVisibility(io);
3501
+ const url = repoUrl(owner, repoName);
3502
+ const manual = manualSteps(owner, repoName, visibility);
3503
+ let root;
3934
3504
  try {
3935
- await withComposeEnvFileAsync(root, async (extraArgs) => {
3936
- const target = resolveExecutionTarget({
3937
- projectRoot: root,
3938
- run,
3939
- build: !useNoBuild,
3940
- ...extraArgs.length > 0 ? { extraEnvFile: extraArgs[1] } : {},
3941
- ...env ? { env } : {}
3942
- });
3943
- await target.provision();
3944
- });
3505
+ root = resolveProjectRoot(opts.cwd);
3506
+ } catch (error) {
3507
+ io.stderr(`${opts.bin} config repo: ${error instanceof Error ? error.message : String(error)}
3508
+ `);
3509
+ printManual(io, manual, "Nothing was changed. Finish this by hand from your checkout:");
3510
+ return 1;
3511
+ }
3512
+ const capture2 = { stdio: "capture", cwd: root };
3513
+ const auth = run("gh", ["auth", "status"], capture2);
3514
+ if (auth.error?.code === "ENOENT") {
3515
+ printManual(io, manual, "gh is not on PATH \u2014 finish this by hand:");
3945
3516
  return 0;
3946
- } catch (err) {
3947
- if (err instanceof ExecutionExitError) return err.exitCode;
3948
- if (err instanceof HostOnlyError) {
3949
- io.stderr(`${err.message}
3517
+ }
3518
+ assertSpawned(auth, "gh auth status");
3519
+ if (auth.status !== 0) {
3520
+ printManual(
3521
+ io,
3522
+ manual,
3523
+ "gh is not authenticated (`gh auth login`) \u2014 finish this by hand once it is:"
3524
+ );
3525
+ return 0;
3526
+ }
3527
+ const remotesResult = run("git", ["remote"], capture2);
3528
+ assertSpawned(remotesResult, "git remote");
3529
+ if (remotesResult.status !== 0) {
3530
+ printManual(io, manual, "`git remote` failed \u2014 is this a git repo? Finish this by hand:");
3531
+ return 0;
3532
+ }
3533
+ const remotes = (remotesResult.stdout ?? "").split("\n").map((line) => line.trim()).filter((line) => line !== "");
3534
+ let originUrl = "";
3535
+ if (remotes.includes("origin")) {
3536
+ const shown = run("git", ["remote", "get-url", "origin"], capture2);
3537
+ assertSpawned(shown, "git remote get-url origin");
3538
+ originUrl = (shown.stdout ?? "").trim();
3539
+ }
3540
+ const originIsTarget = originUrl !== "" && sameRemote(originUrl, owner, repoName);
3541
+ if (!originIsTarget && remotes.includes("origin") && remotes.includes(UPSTREAM_REMOTE)) {
3542
+ io.stderr(
3543
+ `${opts.bin} config repo: origin already points at ${originUrl} and a "${UPSTREAM_REMOTE}" remote already exists \u2014 nothing was changed. Sort the remotes out by hand:
3544
+ `
3545
+ );
3546
+ printManual(io, manual.slice(2), "Remaining steps:");
3547
+ return 1;
3548
+ }
3549
+ const steps = [];
3550
+ const exists = run("gh", ["repo", "view", `${owner}/${repoName}`], capture2);
3551
+ assertSpawned(exists, "gh repo view");
3552
+ if (exists.status === 0) {
3553
+ io.stdout(` ${owner}/${repoName} already exists \u2014 skipping creation.
3554
+ `);
3555
+ } else {
3556
+ steps.push(manual[0]);
3557
+ }
3558
+ if (originIsTarget) {
3559
+ io.stdout(" origin already points at your repo \u2014 leaving the remotes alone.\n");
3560
+ } else {
3561
+ if (remotes.includes("origin")) steps.push(manual[1]);
3562
+ steps.push(manual[2]);
3563
+ }
3564
+ steps.push(manual[3]);
3565
+ for (let i = 0; i < steps.length; i++) {
3566
+ const step = steps[i];
3567
+ io.stdout(` $ ${shellLine(step)}
3568
+ `);
3569
+ const result = run(step.cmd, step.args, { stdio: "inherit", cwd: root });
3570
+ if (result.error || result.status !== 0) {
3571
+ const detail = result.error?.message ?? `exit ${result.status}`;
3572
+ io.stderr(`${opts.bin} config repo: ${shellLine(step)} failed (${detail}) \u2014 stopping here.
3950
3573
  `);
3574
+ const remaining = steps.slice(i + 1);
3575
+ if (remaining.length > 0) {
3576
+ printManual(io, remaining, "Not run. Finish by hand once the failure is fixed:");
3577
+ }
3951
3578
  return 1;
3952
3579
  }
3953
- throw err;
3954
3580
  }
3581
+ io.stdout(
3582
+ `
3583
+ Done \u2014 origin is ${url}${remotes.includes("origin") && !originIsTarget ? `, upstream kept as "${UPSTREAM_REMOTE}"` : ""}.
3584
+ `
3585
+ );
3586
+ return 0;
3955
3587
  }
3956
- function configuredContainerName(root) {
3957
- return fromProcessEnv("SANDBOX_NAME") ?? configuredString(root, "name");
3588
+ function sameRemote(url, owner, name) {
3589
+ const normalized = url.replace(/\.git$/, "").toLowerCase();
3590
+ const slug = `${owner}/${name}`.toLowerCase();
3591
+ return normalized.endsWith(`:${slug}`) || normalized.endsWith(`/${slug}`);
3958
3592
  }
3959
- function runShell(opts, io) {
3960
- const run = opts.run ?? spawnRunner;
3961
- const root = sandboxRoot(opts);
3962
- const name = configuredContainerName(root) ?? DEFAULT_CONTAINER_NAME;
3963
- const target = resolveExecutionTarget({ projectRoot: root, container: name, run });
3964
- let code;
3965
- try {
3966
- code = target.attach({ argv: ["zsh"], user: "sandbox" });
3967
- } catch (err) {
3968
- if (err instanceof ExecutionSpawnError && err.code === "ENOENT") {
3969
- throw new Error("docker is required for `oh shell` but was not found on PATH");
3970
- }
3971
- throw err;
3972
- }
3973
- if (code !== 0) {
3974
- io.stderr(
3975
- `container \`${name}\` not running? start it with \`oh sandbox install docker\`
3976
- `
3977
- );
3593
+
3594
+ // src/commands/secret.ts
3595
+ import { existsSync as existsSync14 } from "node:fs";
3596
+ import { join as join8 } from "node:path";
3597
+
3598
+ // src/lib/gitignore.ts
3599
+ import { existsSync as existsSync13, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "node:fs";
3600
+ import { dirname as dirname7, join as join7 } from "node:path";
3601
+ function appendGitignoreLines(root, lines) {
3602
+ const target = join7(root, ".gitignore");
3603
+ const existing = existsSync13(target) ? readFileSync10(target, "utf8") : "";
3604
+ const present = new Set(existing.split("\n").map((line) => line.trimEnd()));
3605
+ const added = [];
3606
+ for (const line of lines) {
3607
+ const key = line.trimEnd();
3608
+ if (key === "" || present.has(key)) continue;
3609
+ present.add(key);
3610
+ added.push(line);
3978
3611
  }
3979
- return code;
3612
+ if (added.length === 0) return added;
3613
+ let output = existing;
3614
+ if (output.length > 0 && !output.endsWith("\n")) output += "\n";
3615
+ output += `${added.join("\n")}
3616
+ `;
3617
+ mkdirSync5(dirname7(target), { recursive: true });
3618
+ writeFileSync7(target, output, "utf8");
3619
+ return added;
3980
3620
  }
3981
- var COMPOSE_VERBS = Object.freeze({
3982
- stop: Object.freeze(["stop"]),
3983
- restart: Object.freeze(["restart"]),
3984
- logs: Object.freeze(["logs", "-f"]),
3985
- ps: Object.freeze(["ps"]),
3986
- destroy: Object.freeze(["down", "-v"])
3987
- });
3988
- function composeVerbs() {
3989
- return Object.keys(COMPOSE_VERBS);
3621
+
3622
+ // src/commands/secret.ts
3623
+ function secretsRoot(opts) {
3624
+ return opts.sandbox === void 0 ? resolveProjectRoot(opts.cwd) : resolveSandboxRoot({ name: opts.sandbox });
3990
3625
  }
3991
- function runComposeVerb(verb, opts, extra = []) {
3992
- const run = opts.run ?? spawnRunner;
3993
- const root = sandboxRoot(opts);
3994
- const script = requireLifecycleScript(root, "docker-compose.sh");
3995
- return withComposeEnvFile(root, (extraArgs) => {
3996
- const r = run("bash", [script, ...extraArgs, ...COMPOSE_VERBS[verb], ...extra], {
3997
- stdio: "inherit"
3998
- });
3999
- assertSpawned(r, `bash ${script} ${verb}`);
4000
- return r.status ?? 1;
4001
- });
3626
+ function ignoreSecretsFile(root) {
3627
+ if (!existsSync14(join8(root, ".git"))) return;
3628
+ appendGitignoreLines(root, [".env"]);
4002
3629
  }
4003
- function runComposeConfig(opts, extra = []) {
4004
- const run = opts.run ?? spawnRunner;
4005
- const root = sandboxRoot(opts);
4006
- const script = requireLifecycleScript(root, "docker-compose.sh");
4007
- return withComposeEnvFile(root, (extraArgs) => {
4008
- const r = run("bash", [script, ...extraArgs, "config", ...extra], { stdio: "inherit" });
4009
- assertSpawned(r, `bash ${script} config`);
4010
- return r.status ?? 1;
4011
- });
3630
+ function secretKeyList() {
3631
+ return SECRET_KEYS.map((key) => ` ${key}`).join("\n");
4012
3632
  }
4013
- function namedVolumes(root) {
4014
- let text;
4015
- try {
4016
- text = readFileSync11(join9(root, ".devcontainer", "docker-compose.yml"), "utf8");
4017
- } catch {
4018
- return [];
3633
+ async function runSecretSet(key, opts, io) {
3634
+ if (!isSecretKey(key)) {
3635
+ io.stderr(
3636
+ `${opts.bin} secret set: ${key} is not a secret \u2014 non-secret settings live in oh.json.
3637
+ Set it with \`${opts.bin} config set ${key}\` instead.
3638
+
3639
+ Keys:
3640
+ ${secretKeyList()}
3641
+ `
3642
+ );
3643
+ return 1;
4019
3644
  }
4020
- const lines = text.split("\n");
4021
- const start = lines.findIndex((line) => /^volumes:\s*$/.test(line));
4022
- if (start === -1) return [];
4023
- const names = [];
4024
- for (const line of lines.slice(start + 1)) {
4025
- if (/^\S/.test(line)) break;
4026
- const match = /^ {2}([A-Za-z0-9][A-Za-z0-9_.-]*):\s*$/.exec(line);
4027
- if (match) names.push(match[1]);
3645
+ const root = secretsRoot(opts);
3646
+ const askSecret2 = io.askSecret ?? askSecret;
3647
+ const value = (await askSecret2(`Value for ${bold(key)} (input hidden):`)).trim();
3648
+ if (value === "") {
3649
+ io.stderr(`${opts.bin} secret set: no value entered \u2014 ${key} unchanged
3650
+ `);
3651
+ return 1;
4028
3652
  }
4029
- return names;
4030
- }
4031
- function destroyConfirmationPhrase(root) {
4032
- return configuredContainerName(root) ?? DEFAULT_CONTAINER_NAME;
3653
+ try {
3654
+ setSecret(root, key, value);
3655
+ ignoreSecretsFile(root);
3656
+ } catch (error) {
3657
+ io.stderr(`${opts.bin} secret set: ${error instanceof Error ? error.message : String(error)}
3658
+ `);
3659
+ return 1;
3660
+ }
3661
+ io.stdout(`.env: set ${key}=${redact(value)}
3662
+ `);
3663
+ return 0;
4033
3664
  }
4034
- async function runDestroy(opts, io) {
4035
- const root = sandboxRoot(opts);
4036
- const name = destroyConfirmationPhrase(root);
4037
- if (opts.yes !== true) {
4038
- const interactive = process.stdin.isTTY === true || io.ask !== void 0;
4039
- if (!interactive) {
4040
- io.stderr(
4041
- `oh destroy: refusing to destroy \`${name}\` without a terminal \u2014 re-run with --yes to confirm non-interactively
4042
- `
4043
- );
4044
- return 1;
4045
- }
4046
- const homePath = readOhConfig(ohConfigPath(root)).storage?.homePath;
4047
- io.stdout(`
4048
- ${bold(`oh destroy \u2014 ${name}`)}
4049
-
3665
+ async function runSecretList(opts, io) {
3666
+ const root = secretsRoot(opts);
3667
+ const keys = listSecretKeys(root);
3668
+ if (keys.length === 0) {
3669
+ io.stdout(`no secrets set \u2014 write one with \`${opts.bin} secret set <KEY>\`
4050
3670
  `);
4051
- if (homePath !== void 0 && homePath !== "") {
4052
- io.stdout("`docker compose down -v` removes the containers.\n");
4053
- io.stdout(
4054
- `This sandbox keeps its home on a host bind at ${homePath}, which \`down -v\`
4055
- does not touch \u2014 every agent CLI login, the gh CLI token, and the SSH keys
4056
- stay there. Delete that directory yourself if you also want them gone.
4057
-
4058
- `
4059
- );
4060
- } else {
4061
- const volumes = namedVolumes(root).map((volume) => `${name}_${volume}`);
4062
- io.stdout("`docker compose down -v` removes the containers and deletes\n");
4063
- io.stdout(
4064
- volumes.length > 0 ? `these named volumes with everything in them:
4065
-
4066
- ${volumes.join("\n ")}
4067
-
4068
- ` : "every named volume this project owns, with everything in them.\n\n"
4069
- );
4070
- io.stdout(
4071
- "That is the provider authentication those volumes hold \u2014 every agent CLI\nlogin, the gh CLI token, and the SSH keys. Sign-in starts over.\n\n"
4072
- );
4073
- }
4074
- const askFn = io.ask ?? ask;
4075
- const answer = (await askFn(`Type the sandbox name \`${name}\` to destroy it:`)).trim();
4076
- if (answer !== name) {
4077
- io.stderr("oh destroy: aborted \u2014 nothing was removed\n");
4078
- return 1;
4079
- }
3671
+ return 0;
4080
3672
  }
4081
- const code = runComposeVerb("destroy", { ...opts, name: basename5(root) });
4082
- if (code === 0 && root.startsWith(registryRoot() + sep4)) {
4083
- rmSync(root, { recursive: true, force: true });
4084
- io.stdout(`removed the sandbox entry ${root}
3673
+ const width = Math.max(...keys.map((key) => key.length));
3674
+ for (const key of keys) {
3675
+ const value = readSecret(root, key);
3676
+ io.stdout(`${key.padEnd(width)} ${value === void 0 ? "\u2014" : redact(value)}
4085
3677
  `);
4086
3678
  }
4087
- return code;
4088
- }
4089
- function runGateway(args, opts) {
4090
- const run = opts.run ?? spawnRunner;
4091
- const root = resolveProjectRoot(opts.cwd);
4092
- const script = requireLifecycleScript(root, "gateway.sh");
4093
- const r = run("bash", [script, ...args], {
4094
- stdio: "inherit",
4095
- env: { ...process.env, ...aliasedEnvPair("PROJECT_ROOT", root) }
4096
- });
4097
- assertSpawned(r, `bash ${script}`);
4098
- return r.status ?? 1;
3679
+ return 0;
4099
3680
  }
4100
3681
 
4101
3682
  // src/commands/sandbox.ts
4102
- import { existsSync as existsSync16, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, rmSync as rmSync2 } from "node:fs";
3683
+ import { existsSync as existsSync15, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, rmSync as rmSync2, statSync as statSync4 } from "node:fs";
4103
3684
  import { tmpdir as tmpdir2 } from "node:os";
4104
- import { join as join10, resolve as resolve9 } from "node:path";
3685
+ import { join as join9, resolve as resolve9 } from "node:path";
4105
3686
 
4106
3687
  // src/lib/runtimes/catalog.ts
4107
3688
  var RUNTIME_CATALOG = Object.freeze([
@@ -4119,7 +3700,7 @@ var RUNTIME_CATALOG = Object.freeze([
4119
3700
  tier: "microvm",
4120
3701
  state: "planned",
4121
3702
  provisionable: false,
4122
- notProvisionableReason: "microsandbox is not a provisionable runtime yet; see docs/rfcs/rfc-runtime-support.md. Inside a sandbox run `oh tool install microsandbox`.",
3703
+ notProvisionableReason: (bin) => `microsandbox is not a provisionable runtime yet; see docs/rfcs/rfc-runtime-support.md. Inside a sandbox run \`${bin} tool install microsandbox\`.`,
4123
3704
  docsPath: "docs/runtimes/microsandbox.md"
4124
3705
  })
4125
3706
  ]);
@@ -4145,14 +3726,28 @@ function gitIdentity(run, key) {
4145
3726
  if (result.error || result.status !== 0) return "";
4146
3727
  return (result.stdout ?? "").trim();
4147
3728
  }
3729
+ function isBuildCapable(repo) {
3730
+ return repo !== void 0 && existsSync15(join9(repo, ".devcontainer", "Dockerfile"));
3731
+ }
3732
+ function prepareHomeMount(value) {
3733
+ const path4 = resolve9(value);
3734
+ if (existsSync15(path4)) {
3735
+ if (!statSync4(path4).isDirectory()) {
3736
+ throw new Error(`--home-mount path exists and is not a directory: ${path4}`);
3737
+ }
3738
+ return path4;
3739
+ }
3740
+ mkdirSync6(path4, { recursive: true });
3741
+ return path4;
3742
+ }
4148
3743
  function readSeedConfig(repo) {
4149
3744
  if (repo === void 0) return void 0;
4150
3745
  const file = ohConfigPath(repo);
4151
- return existsSync16(file) ? readOhConfig(file) : void 0;
3746
+ return existsSync15(file) ? readOhConfig(file) : void 0;
4152
3747
  }
4153
3748
  function readEntryConfig(name) {
4154
3749
  const file = ohConfigPath(entryRoot(name));
4155
- return existsSync16(file) ? readOhConfig(file) : void 0;
3750
+ return existsSync15(file) ? readOhConfig(file) : void 0;
4156
3751
  }
4157
3752
  function assignSetting(target, path4, value) {
4158
3753
  const segments = path4.split(".");
@@ -4182,11 +3777,11 @@ function mergeSettings(...sources) {
4182
3777
  function nonEmpty(value) {
4183
3778
  return value === void 0 || value === "" ? void 0 : value;
4184
3779
  }
4185
- function seedConfig(name, repo, seed, run) {
3780
+ function seedConfig(name, checkout, seed, run) {
4186
3781
  const config = overlaySettings(defaultOhConfig(name), seed);
4187
3782
  config.name = name;
4188
3783
  config.runtime = "docker";
4189
- if (repo !== void 0) config.repo = repo;
3784
+ if (checkout !== void 0) config.checkout = checkout;
4190
3785
  config.timezone = nonEmpty(seed?.timezone) ?? hostTimezone();
4191
3786
  config.git = {
4192
3787
  userName: nonEmpty(seed?.git?.userName) ?? gitIdentity(run, "user.name"),
@@ -4194,7 +3789,7 @@ function seedConfig(name, repo, seed, run) {
4194
3789
  };
4195
3790
  config.image = {
4196
3791
  ...config.image,
4197
- mode: seed?.image?.mode ?? (config.repo === void 0 ? "image" : "build")
3792
+ mode: seed?.image?.mode ?? (isBuildCapable(configCheckout(config)) ? "build" : "image")
4198
3793
  };
4199
3794
  return config;
4200
3795
  }
@@ -4234,58 +3829,99 @@ async function runWizard(config, io) {
4234
3829
  config.access?.dockerSocket === true
4235
3830
  );
4236
3831
  config.access = access;
3832
+ const homePath = await askDefaulted(
3833
+ ask2,
3834
+ "Host path for /home/sandbox (blank keeps the Docker-managed volume)",
3835
+ config.storage?.homePath ?? ""
3836
+ );
3837
+ if (homePath !== "") config.storage = { ...config.storage, homePath: resolve9(homePath) };
4237
3838
  }
4238
3839
  async function runSandboxInstall(opts, io) {
4239
3840
  const run = opts.run ?? spawnRunner;
4240
3841
  const runtime = findRuntime(opts.runtime);
4241
3842
  if (runtime === void 0) {
4242
3843
  io.stderr(
4243
- `oh sandbox install: unknown runtime "${opts.runtime}" \u2014 known runtimes: ${runtimeIds().join(", ")}
3844
+ `${opts.bin} sandbox install: unknown runtime "${opts.runtime}" \u2014 known runtimes: ${runtimeIds().join(", ")}
4244
3845
  `
4245
3846
  );
4246
3847
  return 1;
4247
3848
  }
4248
3849
  if (!runtime.provisionable) {
4249
- io.stderr(`oh sandbox install: ${runtime.notProvisionableReason}
3850
+ io.stderr(`${opts.bin} sandbox install: ${runtime.notProvisionableReason?.(opts.bin) ?? ""}
4250
3851
  `);
4251
3852
  return 1;
4252
3853
  }
4253
- const repo = opts.repo === void 0 ? void 0 : resolve9(opts.repo);
4254
- if (repo !== void 0 && !existsSync16(repo)) {
4255
- io.stderr(`oh sandbox install: --repo directory does not exist: ${repo}
3854
+ const checkout = opts.checkout === void 0 ? void 0 : resolve9(opts.checkout);
3855
+ if (checkout !== void 0 && !existsSync15(checkout)) {
3856
+ io.stderr(`${opts.bin} sandbox install: --checkout directory does not exist: ${checkout}
4256
3857
  `);
4257
3858
  return 1;
4258
3859
  }
4259
- const repoSeed = readSeedConfig(repo);
3860
+ const repoSeed = readSeedConfig(checkout);
4260
3861
  const name = opts.name ?? repoSeed?.name ?? nextDefaultName(run);
4261
3862
  try {
4262
3863
  assertSandboxName(name);
4263
3864
  } catch (error) {
4264
- io.stderr(`oh sandbox install: ${error instanceof Error ? error.message : String(error)}
3865
+ io.stderr(`${opts.bin} sandbox install: ${error instanceof Error ? error.message : String(error)}
4265
3866
  `);
4266
3867
  return 1;
4267
3868
  }
4268
- const config = seedConfig(name, repo, mergeSettings(readEntryConfig(name), repoSeed), run);
3869
+ const config = seedConfig(name, checkout, mergeSettings(readEntryConfig(name), repoSeed), run);
3870
+ const buildRequested = config.image?.mode === "build" && opts.noBuild !== true && opts.image !== true && opts.imageRef === void 0;
3871
+ if (buildRequested && !isBuildCapable(configCheckout(config))) {
3872
+ const target = configCheckout(config) ?? resolve9(opts.checkout ?? ".");
3873
+ io.stderr(
3874
+ `${opts.bin} sandbox install: image.mode is "build" but ${join9(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
3875
+ `
3876
+ );
3877
+ return 1;
3878
+ }
3879
+ if (opts.homeMount !== void 0) {
3880
+ try {
3881
+ config.storage = { ...config.storage, homePath: prepareHomeMount(opts.homeMount) };
3882
+ } catch (error) {
3883
+ io.stderr(`${opts.bin} sandbox install: ${error instanceof Error ? error.message : String(error)}
3884
+ `);
3885
+ return 1;
3886
+ }
3887
+ }
4269
3888
  const interactive = opts.yes !== true && (process.stdin.isTTY === true || io.ask !== void 0);
3889
+ const homePathBeforeWizard = config.storage?.homePath;
4270
3890
  if (interactive) {
4271
3891
  header("Configure the sandbox (press Enter to accept the shown default)");
4272
3892
  await runWizard(config, io);
3893
+ const chosen = config.storage?.homePath;
3894
+ if (chosen !== void 0 && chosen !== homePathBeforeWizard) {
3895
+ try {
3896
+ config.storage = { ...config.storage, homePath: prepareHomeMount(chosen) };
3897
+ } catch (error) {
3898
+ io.stderr(
3899
+ `${opts.bin} sandbox install: ${error instanceof Error ? error.message : String(error)}
3900
+ `
3901
+ );
3902
+ return 1;
3903
+ }
3904
+ }
4273
3905
  }
4274
3906
  if (opts.imageRef !== void 0) {
4275
3907
  config.image = { ...config.image, ref: opts.imageRef, mode: "image" };
4276
3908
  }
4277
- const useNoBuild = opts.noBuild === true || !(config.repo !== void 0 && config.image?.mode === "build");
3909
+ if (configCheckout(config) !== void 0 && config.image?.mode === "image" && nonEmpty(config.image?.ref) === void 0) {
3910
+ config.image = { ...config.image, ref: DEFAULT_SANDBOX_IMAGE };
3911
+ }
3912
+ const useNoBuild = opts.noBuild === true || !(configCheckout(config) !== void 0 && config.image?.mode === "build");
4278
3913
  const sandboxOpts = {
3914
+ bin: opts.bin,
4279
3915
  run,
4280
3916
  ...opts.image === true ? { image: true } : {},
4281
3917
  ...opts.imageRef !== void 0 ? { imageRef: opts.imageRef } : {},
4282
3918
  ...useNoBuild ? { noBuild: true } : {}
4283
3919
  };
4284
3920
  if (opts.printArgv === true) {
4285
- const preview = mkdtempSync2(join10(tmpdir2(), "oh-sandbox-preview-"));
3921
+ const preview = mkdtempSync2(join9(tmpdir2(), "oh-sandbox-preview-"));
4286
3922
  try {
4287
3923
  writeOhConfig(preview, config);
4288
- materialize(preview, { ...config.repo !== void 0 ? { repo: config.repo } : {} });
3924
+ materialize(preview, { ...configCheckout(config) !== void 0 ? { checkout: configCheckout(config) } : {} });
4289
3925
  return await runSandbox({ ...sandboxOpts, cwd: preview, printArgv: true }, io);
4290
3926
  } finally {
4291
3927
  rmSync2(preview, { recursive: true, force: true });
@@ -4294,9 +3930,9 @@ async function runSandboxInstall(opts, io) {
4294
3930
  const root = entryRoot(config.name);
4295
3931
  mkdirSync6(root, { recursive: true });
4296
3932
  writeOhConfig(root, config);
4297
- materialize(root, { ...config.repo !== void 0 ? { repo: config.repo } : {} });
3933
+ materialize(root, { ...configCheckout(config) !== void 0 ? { checkout: configCheckout(config) } : {} });
4298
3934
  const code = await runSandbox({ ...sandboxOpts, cwd: root }, io);
4299
- if (code === 0) io.stdout(`next: oh shell ${config.name}
3935
+ if (code === 0) io.stdout(`next: ${opts.bin} shell ${config.name}
4300
3936
  `);
4301
3937
  return code;
4302
3938
  }
@@ -4314,10 +3950,12 @@ async function runSandboxList(opts, io) {
4314
3950
  for (const name of listEntries()) {
4315
3951
  const root = entryRoot(name);
4316
3952
  const config = readOhConfig(ohConfigPath(root));
3953
+ const checkout = configCheckout(config) ?? "-";
4317
3954
  rows.push({
4318
3955
  name,
4319
3956
  runtime: config.runtime ?? "docker",
4320
- repo: config.repo ?? "-",
3957
+ checkout,
3958
+ repo: checkout,
4321
3959
  status: await entryStatus(root, name, run)
4322
3960
  });
4323
3961
  }
@@ -4328,7 +3966,7 @@ async function runSandboxList(opts, io) {
4328
3966
  }
4329
3967
  if (rows.length === 0) {
4330
3968
  io.stdout(
4331
- `no sandbox is registered in ${registryRoot()} \u2014 create one with \`oh sandbox install docker\`
3969
+ `no sandbox is registered in ${registryRoot()} \u2014 create one with \`${opts.bin} sandbox install docker\`
4332
3970
  `
4333
3971
  );
4334
3972
  return 0;
@@ -4339,7 +3977,7 @@ async function runSandboxList(opts, io) {
4339
3977
  const statusWidth = width((row) => row.status);
4340
3978
  for (const row of rows) {
4341
3979
  io.stdout(
4342
- `${row.name.padEnd(nameWidth)} ${row.runtime.padEnd(runtimeWidth)} ${row.status.padEnd(statusWidth)} ${row.repo}
3980
+ `${row.name.padEnd(nameWidth)} ${row.runtime.padEnd(runtimeWidth)} ${row.status.padEnd(statusWidth)} ${row.checkout}
4343
3981
  `
4344
3982
  );
4345
3983
  }
@@ -4469,6 +4107,20 @@ var HARNESS_CATALOG = [
4469
4107
  docsPath: "docs/harnesses/muse-code.md",
4470
4108
  kind: "installable"
4471
4109
  },
4110
+ {
4111
+ id: "antigravity-cli",
4112
+ title: "Antigravity CLI",
4113
+ binary: "agy",
4114
+ installArgv: [
4115
+ "bash",
4116
+ "-lc",
4117
+ 'set -o pipefail; curl -fsSL https://antigravity.google/cli/install.sh | bash -s -- --dir "$HOME/.local/bin"'
4118
+ ],
4119
+ installUser: "sandbox",
4120
+ verifyArgv: ["agy", "--version"],
4121
+ docsPath: "docs/harnesses/antigravity-cli.md",
4122
+ kind: "installable"
4123
+ },
4472
4124
  {
4473
4125
  id: "t3code",
4474
4126
  title: "T3 Code",
@@ -4542,7 +4194,7 @@ function cell(value, absent) {
4542
4194
  if (value === null) return absent;
4543
4195
  return value ? "yes" : "no";
4544
4196
  }
4545
- function renderTable(states, io) {
4197
+ function renderTable(states, io, bin) {
4546
4198
  const header2 = ["HARNESS", "KIND", "INSTALLED"];
4547
4199
  const rows = states.map((s) => [s.id, s.kind, cell(s.installed, "?")]);
4548
4200
  const widths = header2.map(
@@ -4552,7 +4204,9 @@ function renderTable(states, io) {
4552
4204
  io.stdout(line(header2));
4553
4205
  for (const row of rows) io.stdout(line(row));
4554
4206
  if (states.some((s) => s.installed === null)) {
4555
- io.stdout("\nINSTALLED is `?` \u2014 the sandbox is not running. Start it with `oh sandbox`.\n");
4207
+ io.stdout(`
4208
+ INSTALLED is \`?\` \u2014 the sandbox is not running. Start it with \`${bin} sandbox\`.
4209
+ `);
4556
4210
  }
4557
4211
  }
4558
4212
  async function runHarnessList(opts, io) {
@@ -4563,12 +4217,12 @@ async function runHarnessList(opts, io) {
4563
4217
  io.stdout(`${JSON.stringify(states, null, 2)}
4564
4218
  `);
4565
4219
  } else {
4566
- renderTable(states, io);
4220
+ renderTable(states, io, opts.bin);
4567
4221
  }
4568
4222
  return 0;
4569
4223
  }
4570
- function unknownHarness(name, io) {
4571
- io.stderr(`oh harness: unknown harness "${name}"
4224
+ function unknownHarness(name, io, bin) {
4225
+ io.stderr(`${bin} harness: unknown harness "${name}"
4572
4226
 
4573
4227
  `);
4574
4228
  io.stderr(`Known harnesses:
@@ -4582,21 +4236,21 @@ async function runHarnessStatus(name, opts, io) {
4582
4236
  let only;
4583
4237
  if (name !== void 0) {
4584
4238
  only = findHarness(name);
4585
- if (!only) return unknownHarness(name, io);
4239
+ if (!only) return unknownHarness(name, io, opts.bin);
4586
4240
  }
4587
4241
  const states = await collectStates(root, run, opts.env, only ? [only] : void 0);
4588
4242
  if (opts.json) {
4589
4243
  io.stdout(`${JSON.stringify(only ? states[0] : states, null, 2)}
4590
4244
  `);
4591
4245
  } else {
4592
- renderTable(states, io);
4246
+ renderTable(states, io, opts.bin);
4593
4247
  }
4594
4248
  return 0;
4595
4249
  }
4596
4250
  function hermesTargetRoot(target) {
4597
4251
  return target.kind === "docker-compose" ? "/home/sandbox/harness" : target.workspace.targetRoot;
4598
4252
  }
4599
- async function reconcileHermes(target, io) {
4253
+ async function reconcileHermes(target, io, bin) {
4600
4254
  const root = hermesTargetRoot(target);
4601
4255
  const result = await target.exec({
4602
4256
  argv: remoteControlDirScript(root, "scripts/link-providers.sh", ["--init", "--hermes-only"]),
@@ -4605,7 +4259,7 @@ async function reconcileHermes(target, io) {
4605
4259
  stdio: "inherit"
4606
4260
  });
4607
4261
  if (result.exitCode !== 0) {
4608
- io.stderr(`oh harness: Hermes integration failed (exit ${result.exitCode}); no installation success reported.
4262
+ io.stderr(`${bin} harness: Hermes integration failed (exit ${result.exitCode}); no installation success reported.
4609
4263
  `);
4610
4264
  }
4611
4265
  return result.exitCode;
@@ -4618,7 +4272,7 @@ async function runHarnessInstall(name, opts, io) {
4618
4272
  name === "hermes" && runningInsideSandbox(env) && projectRoot !== void 0 ? projectRoot : opts.cwd
4619
4273
  );
4620
4274
  const entry = findHarness(name);
4621
- if (!entry) return unknownHarness(name, io);
4275
+ if (!entry) return unknownHarness(name, io, opts.bin);
4622
4276
  const target = targetFor(root, run, opts.env);
4623
4277
  let status;
4624
4278
  try {
@@ -4632,8 +4286,8 @@ async function runHarnessInstall(name, opts, io) {
4632
4286
  }
4633
4287
  if (!isReachable(status)) {
4634
4288
  io.stderr(
4635
- `oh harness: the sandbox is not running (${status}).
4636
- Start it with \`oh sandbox\`, then re-run this command.
4289
+ `${opts.bin} harness: the sandbox is not running (${status}).
4290
+ Start it with \`${opts.bin} sandbox\`, then re-run this command.
4637
4291
  `
4638
4292
  );
4639
4293
  return 1;
@@ -4644,7 +4298,7 @@ Start it with \`oh sandbox\`, then re-run this command.
4644
4298
  HERMES_HOME: `${hermesTargetRoot(target)}/.hermes`
4645
4299
  } : void 0;
4646
4300
  if (hermes) {
4647
- const code = await reconcileHermes(target, io);
4301
+ const code = await reconcileHermes(target, io, opts.bin);
4648
4302
  if (code !== 0) return code;
4649
4303
  }
4650
4304
  const already = await probeInstalled(target, entry, installEnv);
@@ -4666,16 +4320,17 @@ Start it with \`oh sandbox\`, then re-run this command.
4666
4320
  ...installEnv ? { env: installEnv } : {}
4667
4321
  });
4668
4322
  if (r.exitCode !== 0) {
4669
- io.stderr(`oh harness: installing ${entry.id} failed (exit ${r.exitCode}).
4323
+ io.stderr(`${opts.bin} harness: installing ${entry.id} failed (exit ${r.exitCode}).
4670
4324
  `);
4671
4325
  return r.exitCode;
4672
4326
  }
4673
4327
  if (hermes) {
4674
4328
  if (await probeInstalled(target, entry, installEnv) !== true) {
4675
- io.stderr("oh harness: Hermes installation finished but executable verification failed.\n");
4329
+ io.stderr(`${opts.bin} harness: Hermes installation finished but executable verification failed.
4330
+ `);
4676
4331
  return 1;
4677
4332
  }
4678
- const code = await reconcileHermes(target, io);
4333
+ const code = await reconcileHermes(target, io, opts.bin);
4679
4334
  if (code !== 0) return code;
4680
4335
  }
4681
4336
  io.stdout(`${entry.id}: installed \u2014 see ${sourceDocsUrl(entry.docsPath)} for authentication
@@ -4796,7 +4451,7 @@ var TOOL_CATALOG = Object.freeze([
4796
4451
  binary: "docker",
4797
4452
  verifyArgv: Object.freeze(["bash", "-lc", "command -v docker >/dev/null"]),
4798
4453
  versionArgv: Object.freeze(["docker", "--version"]),
4799
- notInstallableReason: "The Docker CLI is installed in the base image. Note that the CLI being present says nothing about whether a daemon is reachable \u2014 `oh ps <name>` answers that.",
4454
+ notInstallableReason: (bin) => `The Docker CLI is installed in the base image. Note that the CLI being present says nothing about whether a daemon is reachable \u2014 \`${bin} ps <name>\` answers that.`,
4800
4455
  docsPath: TOOLS_DOC
4801
4456
  }),
4802
4457
  Object.freeze({
@@ -4806,7 +4461,7 @@ var TOOL_CATALOG = Object.freeze([
4806
4461
  binary: "gh",
4807
4462
  verifyArgv: Object.freeze(["bash", "-lc", "command -v gh >/dev/null"]),
4808
4463
  versionArgv: Object.freeze(["gh", "--version"]),
4809
- notInstallableReason: "The GitHub CLI is installed in the base image. Run `gh auth login` inside the sandbox to authenticate it.",
4464
+ notInstallableReason: () => "The GitHub CLI is installed in the base image. Run `gh auth login` inside the sandbox to authenticate it.",
4810
4465
  docsPath: TOOLS_DOC
4811
4466
  }),
4812
4467
  Object.freeze({
@@ -4913,7 +4568,7 @@ function cell2(value, absent) {
4913
4568
  if (value === null) return absent;
4914
4569
  return value ? "yes" : "no";
4915
4570
  }
4916
- function renderTable2(rows, io) {
4571
+ function renderTable2(rows, io, bin) {
4917
4572
  const header2 = ["TOOL", "KIND", "INSTALLED"];
4918
4573
  const body = rows.map((r) => [r.id, r.kind, cell2(r.installed, "?")]);
4919
4574
  const widths = header2.map(
@@ -4923,11 +4578,13 @@ function renderTable2(rows, io) {
4923
4578
  io.stdout(line(header2));
4924
4579
  for (const row of body) io.stdout(line(row));
4925
4580
  if (rows.some((r) => r.installed === null)) {
4926
- io.stdout("\nINSTALLED is `?` \u2014 the sandbox is not running. Start it with `oh sandbox`.\n");
4581
+ io.stdout(`
4582
+ INSTALLED is \`?\` \u2014 the sandbox is not running. Start it with \`${bin} sandbox\`.
4583
+ `);
4927
4584
  }
4928
4585
  }
4929
- function renderDetail(rows, io) {
4930
- renderTable2(rows, io);
4586
+ function renderDetail(rows, io, bin) {
4587
+ renderTable2(rows, io, bin);
4931
4588
  for (const r of rows) {
4932
4589
  io.stdout(`
4933
4590
  ${r.id} \u2014 ${r.title}
@@ -4948,12 +4605,12 @@ async function runToolList(opts, io) {
4948
4605
  io.stdout(`${JSON.stringify(rows, null, 2)}
4949
4606
  `);
4950
4607
  } else {
4951
- renderTable2(rows, io);
4608
+ renderTable2(rows, io, opts.bin);
4952
4609
  }
4953
4610
  return 0;
4954
4611
  }
4955
- function unknownTool(name, io) {
4956
- io.stderr(`oh tool: unknown tool "${name}"
4612
+ function unknownTool(name, io, bin) {
4613
+ io.stderr(`${bin} tool: unknown tool "${name}"
4957
4614
 
4958
4615
  `);
4959
4616
  io.stderr(`Known tools:
@@ -4967,14 +4624,14 @@ async function runToolStatus(name, opts, io) {
4967
4624
  let only;
4968
4625
  if (name !== void 0) {
4969
4626
  only = findTool(name);
4970
- if (!only) return unknownTool(name, io);
4627
+ if (!only) return unknownTool(name, io, opts.bin);
4971
4628
  }
4972
4629
  const rows = await collectRows(root, run, opts.env, only ? [only] : void 0);
4973
4630
  if (opts.json) {
4974
4631
  io.stdout(`${JSON.stringify(only ? rows[0] : rows, null, 2)}
4975
4632
  `);
4976
4633
  } else {
4977
- renderDetail(rows, io);
4634
+ renderDetail(rows, io, opts.bin);
4978
4635
  }
4979
4636
  return 0;
4980
4637
  }
@@ -4995,12 +4652,12 @@ async function runToolInstall(name, opts, io) {
4995
4652
  const run = opts.run ?? spawnRunner;
4996
4653
  const root = resolveProjectRoot(opts.cwd);
4997
4654
  const entry = findTool(name);
4998
- if (!entry) return unknownTool(name, io);
4655
+ if (!entry) return unknownTool(name, io, opts.bin);
4999
4656
  if (entry.installArgv === void 0) {
5000
- io.stderr(`oh tool: ${entry.id} cannot be installed by this command.
4657
+ io.stderr(`${opts.bin} tool: ${entry.id} cannot be installed by this command.
5001
4658
 
5002
4659
  `);
5003
- io.stderr(`${entry.notInstallableReason ?? ""}
4660
+ io.stderr(`${entry.notInstallableReason?.(opts.bin) ?? ""}
5004
4661
 
5005
4662
  `);
5006
4663
  io.stderr(`Installable tools:
@@ -5021,8 +4678,8 @@ ${installableToolIds().map((t) => ` ${t}`).join("\n")}
5021
4678
  }
5022
4679
  if (!isReachable2(status)) {
5023
4680
  io.stderr(
5024
- `oh tool: the sandbox is not running (${status}).
5025
- Start it with \`oh sandbox\`, then re-run this command.
4681
+ `${opts.bin} tool: the sandbox is not running (${status}).
4682
+ Start it with \`${opts.bin} sandbox\`, then re-run this command.
5026
4683
  `
5027
4684
  );
5028
4685
  return 1;
@@ -5046,7 +4703,7 @@ Start it with \`oh sandbox\`, then re-run this command.
5046
4703
  stdio: "inherit"
5047
4704
  });
5048
4705
  if (r.exitCode !== 0) {
5049
- io.stderr(`oh tool: installing ${entry.id} failed (exit ${r.exitCode}).
4706
+ io.stderr(`${opts.bin} tool: installing ${entry.id} failed (exit ${r.exitCode}).
5050
4707
  `);
5051
4708
  return r.exitCode;
5052
4709
  }
@@ -5119,7 +4776,7 @@ function fetchRemoteSource(opts) {
5119
4776
  }
5120
4777
 
5121
4778
  // src/cli.ts
5122
- var VERSION = true ? "0.10.0" : "0.0.0-dev";
4779
+ var VERSION = true ? "0.11.0" : "0.0.0-dev";
5123
4780
  var DEFAULT_SOURCE_OH_DIR = resolve10(
5124
4781
  dirname8(fileURLToPath(import.meta.url)),
5125
4782
  "../.."
@@ -5163,7 +4820,6 @@ Usage:
5163
4820
  ${bin} harness <args...> Install and inspect agent CLI harnesses
5164
4821
  ${bin} tool <args...> Install and inspect sandbox tooling
5165
4822
  ${bin} gateway <args...> Manage a messaging client session (pi|hermes)
5166
- ${bin} cloud <args...> Manage OpenHarness Cloud nodes
5167
4823
  ${bin} --version Print version
5168
4824
  ${bin} --help Show this help
5169
4825
 
@@ -5176,7 +4832,7 @@ function printConfigHelp(bin = LEGACY_PRODUCT.bin) {
5176
4832
 
5177
4833
  Usage:
5178
4834
  ${bin} config show [--sandbox <name>]
5179
- ${bin} config set <field> <value> [--sandbox <name>]
4835
+ ${bin} config set <field> <value> [--sandbox <name>] [--force]
5180
4836
  ${bin} config repo Create your own GitHub repo and point origin at it
5181
4837
  ${bin} config <integration> Run an integration wizard
5182
4838
  ${bin} config <integration> --help
@@ -5187,6 +4843,10 @@ change with \`${bin} stop <name> && ${bin} sandbox install docker --name <name>\
5187
4843
  reference:
5188
4844
  ${sourceDocsUrl("docs/configuration.md")}
5189
4845
 
4846
+ \`${bin} config set storage.homePath <dir>\` refuses the change when the sandbox's named
4847
+ volume already exists, because the next start would orphan every file in it. Pass
4848
+ --force to change it anyway.
4849
+
5190
4850
  Fields:
5191
4851
  ${configFieldList()}
5192
4852
 
@@ -5287,24 +4947,34 @@ function printSandboxHelp(bin = LEGACY_PRODUCT.bin) {
5287
4947
  process.stdout.write(`${bin} sandbox \u2014 Create and list sandboxes
5288
4948
 
5289
4949
  Usage:
5290
- ${bin} sandbox install <runtime> [--name <name>] [--repo <dir>] [--yes]
4950
+ ${bin} sandbox install <runtime> [--name <name>] [--checkout <dir>]
4951
+ [--home-mount <dir>] [--yes]
5291
4952
  [--image[=<ref>]] [--no-build] [--print-argv]
5292
4953
  ${bin} sandbox list [--json]
5293
4954
 
5294
4955
  \`install\` writes a sandbox entry under \${${stateNames(bin).envPrefix}HOME:-~/${stateNames(bin).userStateDir}}/sandboxes/<name>/,
5295
4956
  materialises the compose files and the compose wrapper into it, then starts the
5296
4957
  container. It needs no project checkout and runs from any directory. Without
5297
- --repo the sandbox runs the prebuilt image; with --repo <dir> that checkout is
5298
- bound at /home/sandbox/harness and can be built locally.
4958
+ --checkout the sandbox runs the prebuilt image; with --checkout <dir> that
4959
+ checkout is bound at /home/sandbox/harness; the sandbox builds locally only
4960
+ when that directory holds .devcontainer/Dockerfile.
5299
4961
 
5300
4962
  On a terminal without --yes it asks for the sandbox name, the timezone, the git
5301
- identity, SSH (with its host port), and the host Docker socket. With --repo it
5302
- seeds those answers from that checkout's ${stateNames(bin).configFile}.
4963
+ identity, SSH (with its host port), the host Docker socket, and the host path
4964
+ for /home/sandbox. With --checkout it seeds those answers from that
4965
+ checkout's ${stateNames(bin).configFile}.
5303
4966
 
5304
4967
  Flags:
5305
4968
  --name <name> Registry entry name (default: the lowest free ${DEFAULT_NAME_PREFIX}<n>)
5306
- --repo <dir> Bind this checkout into the sandbox and seed the defaults
5307
- from its ${stateNames(bin).configFile}
4969
+ --checkout <dir> Bind this checkout into the sandbox and seed the defaults
4970
+ from its ${stateNames(bin).configFile}. The sandbox builds
4971
+ locally only when that directory holds
4972
+ .devcontainer/Dockerfile
4973
+ --repo <dir> Deprecated alias for --checkout
4974
+ --home-mount <dir>
4975
+ Bind this host directory at /home/sandbox instead of the
4976
+ Docker-managed volume. Relative paths resolve against the
4977
+ working directory. The directory is created when absent
5308
4978
  --yes Non-interactive: keep every default and ask nothing
5309
4979
  --image[=<ref>] Run the prebuilt image instead of building (implies
5310
4980
  --no-build). Ref resolves last-wins: --image=<ref> >
@@ -5482,11 +5152,13 @@ var CONFIG_VERBS = ["show", "set", "repo"];
5482
5152
  function parseConfigArgs(input, bin = LEGACY_PRODUCT.bin) {
5483
5153
  const scoped = extractSandboxFlag(`${bin} config`, input);
5484
5154
  if (!scoped.ok) return scoped;
5485
- const rest = scoped.args.rest;
5155
+ const forced = scoped.args.rest.includes("--force");
5156
+ const rest = scoped.args.rest.filter((arg) => arg !== "--force");
5486
5157
  const args = {
5487
5158
  help: false,
5488
5159
  integrationHelp: false,
5489
- ...scoped.args.sandbox === void 0 ? {} : { sandbox: scoped.args.sandbox }
5160
+ ...scoped.args.sandbox === void 0 ? {} : { sandbox: scoped.args.sandbox },
5161
+ ...forced ? { force: true } : {}
5490
5162
  };
5491
5163
  if (rest.length === 0 || isHelpFlag(rest[0])) {
5492
5164
  return { ok: true, args: { ...args, help: true } };
@@ -5673,7 +5345,9 @@ function parseUpdateArgs(rest, bin = LEGACY_PRODUCT.bin) {
5673
5345
  }
5674
5346
  var SANDBOX_VALUE_FLAGS = {
5675
5347
  "--name": "name",
5676
- "--repo": "repo"
5348
+ "--checkout": "checkout",
5349
+ "--repo": "checkout",
5350
+ "--home-mount": "homeMount"
5677
5351
  };
5678
5352
  function parseSandboxArgs(rest, bin = LEGACY_PRODUCT.bin) {
5679
5353
  const args = {
@@ -5698,6 +5372,7 @@ function parseSandboxArgs(rest, bin = LEGACY_PRODUCT.bin) {
5698
5372
  args.subcommand = head;
5699
5373
  if (isHelpFlag(tail[0])) return { ok: true, args: { ...args, help: true } };
5700
5374
  const positionals = [];
5375
+ const checkoutSpellings = /* @__PURE__ */ new Set();
5701
5376
  for (let i = 0; i < tail.length; i++) {
5702
5377
  const token = tail[i];
5703
5378
  const valueFlag = SANDBOX_VALUE_FLAGS[token];
@@ -5706,6 +5381,7 @@ function parseSandboxArgs(rest, bin = LEGACY_PRODUCT.bin) {
5706
5381
  if (value === void 0) {
5707
5382
  return { ok: false, error: `${bin} sandbox ${head}: ${token} requires a value` };
5708
5383
  }
5384
+ if (valueFlag === "checkout") checkoutSpellings.add(token);
5709
5385
  args[valueFlag] = value;
5710
5386
  i++;
5711
5387
  } else if (token === "--yes") {
@@ -5731,6 +5407,12 @@ function parseSandboxArgs(rest, bin = LEGACY_PRODUCT.bin) {
5731
5407
  positionals.push(token);
5732
5408
  }
5733
5409
  }
5410
+ if (checkoutSpellings.has("--checkout") && checkoutSpellings.has("--repo")) {
5411
+ return {
5412
+ ok: false,
5413
+ error: `${bin} sandbox ${head}: --checkout conflicts with --repo \u2014 pass exactly one, and prefer --checkout`
5414
+ };
5415
+ }
5734
5416
  if (head === "list") {
5735
5417
  if (positionals.length > 0) {
5736
5418
  return { ok: false, error: `${bin} sandbox list: unexpected argument "${positionals[0]}"` };
@@ -5843,11 +5525,11 @@ function parseGatewayArgs(rest) {
5843
5525
  }
5844
5526
  return { ok: true, args: { help: false, passthrough: [...rest] } };
5845
5527
  }
5846
- function bundledPayloadExists(bundled, exists = existsSync17) {
5847
- return exists(join11(bundled.sourceOhDir, "manifest.json"));
5528
+ function bundledPayloadExists(bundled, exists = existsSync16) {
5529
+ return exists(join10(bundled.sourceOhDir, "manifest.json"));
5848
5530
  }
5849
5531
  function resolveUpdateSource(args, bundled, bin = LEGACY_PRODUCT.bin) {
5850
- const exists = bundled.exists ?? existsSync17;
5532
+ const exists = bundled.exists ?? existsSync16;
5851
5533
  if (args.fromDir !== void 0) {
5852
5534
  return { kind: "local", fromDir: resolve10(args.fromDir) };
5853
5535
  }
@@ -5867,7 +5549,7 @@ function resolveUpdateSource(args, bundled, bin = LEGACY_PRODUCT.bin) {
5867
5549
  function readPayloadVersion(checkoutDir) {
5868
5550
  try {
5869
5551
  const parsed = JSON.parse(
5870
- readFileSync12(join11(resolveControlDir(checkoutDir).path, "cli", "package.json"), "utf8")
5552
+ readFileSync11(join10(resolveControlDir(checkoutDir).path, "cli", "package.json"), "utf8")
5871
5553
  );
5872
5554
  if (parsed && typeof parsed.version === "string") return parsed.version;
5873
5555
  } catch {
@@ -5876,7 +5558,7 @@ function readPayloadVersion(checkoutDir) {
5876
5558
  }
5877
5559
  async function runWithRemoteSource(hooks, fn) {
5878
5560
  const fetch2 = hooks.fetch ?? fetchRemoteSource;
5879
- const mkdtemp = hooks.mkdtemp ?? (() => mkdtempSync3(join11(tmpdir3(), "oh-remote-")));
5561
+ const mkdtemp = hooks.mkdtemp ?? (() => mkdtempSync3(join10(tmpdir3(), "oh-remote-")));
5880
5562
  const rm = hooks.rm ?? ((dir) => rmSync3(dir, { recursive: true, force: true }));
5881
5563
  const out = hooks.stdout ?? ((s) => process.stdout.write(s));
5882
5564
  const checkoutDir = mkdtemp();
@@ -5920,9 +5602,13 @@ async function main(argv) {
5920
5602
  stdout: (s) => process.stdout.write(s),
5921
5603
  stderr: (s) => process.stderr.write(s)
5922
5604
  };
5923
- const scope = a.sandbox === void 0 ? {} : { sandbox: a.sandbox };
5605
+ const scope = {
5606
+ bin,
5607
+ ...a.sandbox === void 0 ? {} : { sandbox: a.sandbox },
5608
+ ...a.force === true ? { force: true } : {}
5609
+ };
5924
5610
  if (a.verb === "show") return await runConfigShow(scope, io);
5925
- if (a.verb === "repo") return await runConfigRepo({}, io);
5611
+ if (a.verb === "repo") return await runConfigRepo({ bin }, io);
5926
5612
  return await runConfigSet(a.key, a.value, scope, io);
5927
5613
  }
5928
5614
  const name = a.integration;
@@ -5957,7 +5643,7 @@ async function main(argv) {
5957
5643
  stdout: (s) => process.stdout.write(s),
5958
5644
  stderr: (s) => process.stderr.write(s)
5959
5645
  };
5960
- const scope = a.sandbox === void 0 ? {} : { sandbox: a.sandbox };
5646
+ const scope = a.sandbox === void 0 ? { bin } : { bin, sandbox: a.sandbox };
5961
5647
  if (a.verb === "list") return await runSecretList(scope, io);
5962
5648
  return await runSecretSet(a.key, scope, io);
5963
5649
  }
@@ -6002,12 +5688,12 @@ async function main(argv) {
6002
5688
  const targetDir = process.cwd();
6003
5689
  const source = resolveUpdateSource(parsed.args, { sourceOhDir: DEFAULT_SOURCE_OH_DIR }, bin);
6004
5690
  if (source.kind === "local") {
6005
- return await runUpdate({ targetDir, fromDir: source.fromDir, force, dryRun }, io);
5691
+ return await runUpdate({ bin, targetDir, fromDir: source.fromDir, force, dryRun }, io);
6006
5692
  }
6007
5693
  if (source.notice) process.stdout.write(source.notice);
6008
5694
  return await runWithRemoteSource(
6009
5695
  { ref: source.ref },
6010
- (checkoutDir) => runUpdate({ targetDir, fromDir: checkoutDir, force, dryRun }, io)
5696
+ (checkoutDir) => runUpdate({ bin, targetDir, fromDir: checkoutDir, force, dryRun }, io)
6011
5697
  );
6012
5698
  }
6013
5699
  if (first === "sandbox") {
@@ -6024,12 +5710,14 @@ async function main(argv) {
6024
5710
  return a.subcommand === void 0 ? 1 : 0;
6025
5711
  }
6026
5712
  const io = lifecycleIo();
6027
- if (a.subcommand === "list") return await runSandboxList({ json: a.json }, io);
5713
+ if (a.subcommand === "list") return await runSandboxList({ bin, json: a.json }, io);
6028
5714
  return await runSandboxInstall(
6029
5715
  {
5716
+ bin,
6030
5717
  runtime: a.runtime,
6031
5718
  ...a.name !== void 0 ? { name: a.name } : {},
6032
- ...a.repo !== void 0 ? { repo: a.repo } : {},
5719
+ ...a.checkout !== void 0 ? { checkout: a.checkout } : {},
5720
+ ...a.homeMount !== void 0 ? { homeMount: a.homeMount } : {},
6033
5721
  yes: a.yes,
6034
5722
  image: a.image,
6035
5723
  ...a.imageRef !== void 0 ? { imageRef: a.imageRef } : {},
@@ -6051,7 +5739,7 @@ async function main(argv) {
6051
5739
  return 0;
6052
5740
  }
6053
5741
  return runShell(
6054
- parsed.args.name === void 0 ? {} : { name: parsed.args.name },
5742
+ parsed.args.name === void 0 ? { bin } : { bin, name: parsed.args.name },
6055
5743
  lifecycleIo()
6056
5744
  );
6057
5745
  }
@@ -6068,6 +5756,7 @@ async function main(argv) {
6068
5756
  }
6069
5757
  return await runDestroy(
6070
5758
  {
5759
+ bin,
6071
5760
  yes: parsed.args.yes,
6072
5761
  ...parsed.args.name !== void 0 ? { name: parsed.args.name } : {}
6073
5762
  },
@@ -6086,7 +5775,7 @@ async function main(argv) {
6086
5775
  printComposeHelp(bin);
6087
5776
  return 0;
6088
5777
  }
6089
- return runComposeConfig({}, parsed.args.passthrough);
5778
+ return runComposeConfig({ bin }, parsed.args.passthrough);
6090
5779
  }
6091
5780
  if (composeVerbs().includes(first)) {
6092
5781
  const verb = first;
@@ -6110,7 +5799,7 @@ async function main(argv) {
6110
5799
  );
6111
5800
  return 1;
6112
5801
  }
6113
- return runComposeVerb(verb, head.length === 0 ? {} : { name: head[0] }, extra);
5802
+ return runComposeVerb(verb, head.length === 0 ? { bin } : { bin, name: head[0] }, extra);
6114
5803
  }
6115
5804
  if (first === "harness") {
6116
5805
  const parsed = parseHarnessArgs(argv.slice(1), bin);
@@ -6130,12 +5819,12 @@ async function main(argv) {
6130
5819
  stderr: (s) => process.stderr.write(s)
6131
5820
  };
6132
5821
  if (a.subcommand === "list") {
6133
- return await runHarnessList({ json: a.json }, io);
5822
+ return await runHarnessList({ bin, json: a.json }, io);
6134
5823
  }
6135
5824
  if (a.subcommand === "status") {
6136
- return await runHarnessStatus(a.name, { json: a.json }, io);
5825
+ return await runHarnessStatus(a.name, { bin, json: a.json }, io);
6137
5826
  }
6138
- return await runHarnessInstall(a.name, {}, io);
5827
+ return await runHarnessInstall(a.name, { bin }, io);
6139
5828
  }
6140
5829
  if (first === "tool") {
6141
5830
  const parsed = parseToolArgs(argv.slice(1), bin);
@@ -6155,18 +5844,12 @@ async function main(argv) {
6155
5844
  stderr: (s) => process.stderr.write(s)
6156
5845
  };
6157
5846
  if (a.subcommand === "list") {
6158
- return await runToolList({ json: a.json }, io);
5847
+ return await runToolList({ bin, json: a.json }, io);
6159
5848
  }
6160
5849
  if (a.subcommand === "status") {
6161
- return await runToolStatus(a.name, { json: a.json }, io);
5850
+ return await runToolStatus(a.name, { bin, json: a.json }, io);
6162
5851
  }
6163
- return await runToolInstall(a.name, { yes: a.yes }, io);
6164
- }
6165
- if (first === "cloud") {
6166
- return await runCloud(argv.slice(1), {
6167
- stdout: (s) => process.stdout.write(s),
6168
- stderr: (s) => process.stderr.write(s)
6169
- });
5852
+ return await runToolInstall(a.name, { bin, yes: a.yes }, io);
6170
5853
  }
6171
5854
  if (first === "gateway") {
6172
5855
  const parsed = parseGatewayArgs(argv.slice(1));
@@ -6179,7 +5862,7 @@ async function main(argv) {
6179
5862
  printGatewayHelp(bin);
6180
5863
  return 0;
6181
5864
  }
6182
- return runGateway(parsed.args.passthrough, {});
5865
+ return runGateway(parsed.args.passthrough, { bin });
6183
5866
  }
6184
5867
  process.stderr.write(`${bin}: unknown command "${first}"
6185
5868