@mifune/agro 0.9.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 (5) hide show
  1. package/NOTICE +2 -2
  2. package/README.md +25 -51
  3. package/dist/agro.js +2023 -1682
  4. package/dist/oh.js +2023 -1682
  5. package/package.json +6 -6
package/dist/agro.js CHANGED
@@ -2,20 +2,246 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { fileURLToPath } from "node:url";
5
- import { dirname as dirname6, join as join10, resolve as resolve8 } from "node:path";
5
+ import { dirname as dirname8, join as join10, resolve as resolve10 } from "node:path";
6
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
10
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
11
+ import path3 from "node:path";
12
+
13
+ // src/lib/compat.ts
10
14
  import {
11
- existsSync as existsSync2,
12
- statSync,
13
- readFileSync as readFileSync2
15
+ existsSync,
16
+ lstatSync,
17
+ readdirSync,
18
+ readFileSync,
19
+ readlinkSync,
20
+ statSync
14
21
  } from "node:fs";
15
- import path3 from "node:path";
22
+ import { homedir } from "node:os";
23
+ import { join, resolve } from "node:path";
24
+ var GENERATIONS = {
25
+ legacy: {
26
+ controlDir: ".oh",
27
+ configFile: "oh.json",
28
+ envPrefix: "OH_",
29
+ userStateDir: ".oh",
30
+ seedDir: "/opt/oh-seed"
31
+ },
32
+ agro: {
33
+ controlDir: ".agro",
34
+ configFile: "agro.json",
35
+ envPrefix: "AGRO_",
36
+ userStateDir: ".agro",
37
+ seedDir: "/opt/agro-seed"
38
+ }
39
+ };
40
+ var LEGACY_CHECKOUT_DIR = ".openharness";
41
+ var DEFAULT_GENERATION = "agro";
42
+ var DEFAULT_SANDBOX_NAME = "agro";
43
+ var CompatConflictError = class extends Error {
44
+ legacyPath;
45
+ agroPath;
46
+ differences;
47
+ constructor(legacyPath, agroPath, differences) {
48
+ super(
49
+ `${legacyPath} and ${agroPath} both exist and differ \u2014 resolve the conflict before continuing (keep exactly one, or make them identical); differences: ${differences.join("; ")}`
50
+ );
51
+ this.name = "CompatConflictError";
52
+ this.legacyPath = legacyPath;
53
+ this.agroPath = agroPath;
54
+ this.differences = differences;
55
+ }
56
+ };
57
+ function entryType(stats) {
58
+ if (stats.isSymbolicLink()) return "symlink";
59
+ if (stats.isDirectory()) return "directory";
60
+ if (stats.isFile()) return "file";
61
+ return "other";
62
+ }
63
+ function lstatOrUndefined(path4) {
64
+ return lstatSync(path4, { throwIfNoEntry: false });
65
+ }
66
+ function statOrUndefined(path4) {
67
+ try {
68
+ return statSync(path4, { throwIfNoEntry: false });
69
+ } catch {
70
+ return void 0;
71
+ }
72
+ }
73
+ function sameBytes(a, b) {
74
+ const left = readFileSync(a);
75
+ const right = readFileSync(b);
76
+ return left.equals(right);
77
+ }
78
+ function octal(mode) {
79
+ return (mode & 511).toString(8).padStart(3, "0");
80
+ }
81
+ function contentDifference(type, legacy, agro, left, right) {
82
+ switch (type) {
83
+ case "symlink":
84
+ return readlinkSync(legacy) !== readlinkSync(agro) ? "symlink target differs" : void 0;
85
+ case "file":
86
+ return left.size !== right.size || !sameBytes(legacy, agro) ? "content differs" : void 0;
87
+ case "other":
88
+ return "unsupported entry type";
89
+ default:
90
+ return void 0;
91
+ }
92
+ }
93
+ function entryDifferences(label, legacy, agro, left, right) {
94
+ const type = entryType(left);
95
+ if (type !== entryType(right)) return [`${label}: type ${type} vs ${entryType(right)}`];
96
+ const differences = [];
97
+ if ((left.mode & 511) !== (right.mode & 511)) {
98
+ differences.push(`${label}: mode ${octal(left.mode)} vs ${octal(right.mode)}`);
99
+ }
100
+ const content = contentDifference(type, legacy, agro, left, right);
101
+ if (content !== void 0) differences.push(`${label}: ${content}`);
102
+ return differences;
103
+ }
104
+ function compareTrees(legacy, agro, rel = "") {
105
+ const label = rel === "" ? "." : rel;
106
+ const left = lstatOrUndefined(legacy);
107
+ const right = lstatOrUndefined(agro);
108
+ if (left === void 0 && right === void 0) return [];
109
+ if (left === void 0) return [`${label}: only in ${agro}`];
110
+ if (right === void 0) return [`${label}: only in ${legacy}`];
111
+ const differences = entryDifferences(label, legacy, agro, left, right);
112
+ if (!left.isDirectory() || !right.isDirectory()) return differences;
113
+ const names = /* @__PURE__ */ new Set([...readdirSync(legacy), ...readdirSync(agro)]);
114
+ for (const name of [...names].sort()) {
115
+ const childRel = rel === "" ? name : `${rel}/${name}`;
116
+ differences.push(...compareTrees(join(legacy, name), join(agro, name), childRel));
117
+ }
118
+ return differences;
119
+ }
120
+ function resolvePair(legacyPath, agroPath, present) {
121
+ const legacyExists = present(legacyPath);
122
+ const agroExists = present(agroPath);
123
+ const base = { legacyPath, agroPath };
124
+ if (!legacyExists && !agroExists) {
125
+ const path4 = DEFAULT_GENERATION === "agro" ? agroPath : legacyPath;
126
+ return { ...base, kind: "absent", generation: DEFAULT_GENERATION, path: path4 };
127
+ }
128
+ if (legacyExists && !agroExists) {
129
+ return { ...base, kind: "legacy-only", generation: "legacy", path: legacyPath };
130
+ }
131
+ if (!legacyExists && agroExists) {
132
+ return { ...base, kind: "agro-only", generation: "agro", path: agroPath };
133
+ }
134
+ const differences = compareTrees(legacyPath, agroPath);
135
+ if (differences.length > 0) throw new CompatConflictError(legacyPath, agroPath, differences);
136
+ return { ...base, kind: "both-equivalent", generation: "agro", path: agroPath };
137
+ }
138
+ function isDirectoryAt(path4) {
139
+ return statOrUndefined(path4)?.isDirectory() === true;
140
+ }
141
+ function isFileAt(path4) {
142
+ return statOrUndefined(path4)?.isFile() === true;
143
+ }
144
+ function resolveControlDir(root) {
145
+ const dir = resolve(root);
146
+ return resolvePair(
147
+ join(dir, GENERATIONS.legacy.controlDir),
148
+ join(dir, GENERATIONS.agro.controlDir),
149
+ isDirectoryAt
150
+ );
151
+ }
152
+ function resolveConfigFile(root) {
153
+ const dir = resolve(root);
154
+ return resolvePair(
155
+ join(dir, GENERATIONS.legacy.configFile),
156
+ join(dir, GENERATIONS.agro.configFile),
157
+ isFileAt
158
+ );
159
+ }
160
+ function resolveProjectLayout(root) {
161
+ const dir = resolve(root);
162
+ const control = resolveControlDir(dir);
163
+ const config = resolveConfigFile(dir);
164
+ const generation = control.kind !== "absent" ? control.generation : config.kind !== "absent" ? config.generation : DEFAULT_GENERATION;
165
+ const names = GENERATIONS[generation];
166
+ return {
167
+ generation,
168
+ root: dir,
169
+ controlDir: join(dir, names.controlDir),
170
+ configFile: join(dir, names.configFile)
171
+ };
172
+ }
173
+ function controlDirCandidates() {
174
+ return [GENERATIONS.agro.controlDir, GENERATIONS.legacy.controlDir];
175
+ }
176
+ function remoteControlDirScript(root, rel, args) {
177
+ const candidates = controlDirCandidates().join(" ");
178
+ const script = `root="$1"; shift; rel="$1"; shift; for name in ${candidates}; do if [ -d "$root/$name" ]; then exec bash "$root/$name/$rel" "$@"; fi; done; printf 'no control plane (%s) under %s\\n' '${candidates}' "$root" >&2; exit 1`;
179
+ return ["bash", "-c", script, "control-dir", root, rel, ...args];
180
+ }
181
+ function envValue(env, key) {
182
+ const value = env[key];
183
+ return value !== void 0 && value !== "" ? value : void 0;
184
+ }
185
+ function resolveAliasedEnv(env, suffix) {
186
+ const agroKey = `${GENERATIONS.agro.envPrefix}${suffix}`;
187
+ const legacyKey = `${GENERATIONS.legacy.envPrefix}${suffix}`;
188
+ const agro = envValue(env, agroKey);
189
+ const legacy = envValue(env, legacyKey);
190
+ const conflict = agro !== void 0 && legacy !== void 0 && agro !== legacy;
191
+ if (agro !== void 0) {
192
+ return { key: suffix, agroKey, legacyKey, value: agro, source: "agro", conflict };
193
+ }
194
+ if (legacy !== void 0) {
195
+ return { key: suffix, agroKey, legacyKey, value: legacy, source: "legacy", conflict: false };
196
+ }
197
+ return { key: suffix, agroKey, legacyKey, value: void 0, source: "none", conflict: false };
198
+ }
199
+ function aliasConflictWarning(resolved2) {
200
+ if (!resolved2.conflict) return void 0;
201
+ return `compat: ${resolved2.agroKey} and ${resolved2.legacyKey} are both set and differ \u2014 using ${resolved2.agroKey}`;
202
+ }
203
+ function aliasedEnvPair(suffix, value) {
204
+ return {
205
+ [`${GENERATIONS.agro.envPrefix}${suffix}`]: value,
206
+ [`${GENERATIONS.legacy.envPrefix}${suffix}`]: value
207
+ };
208
+ }
209
+ function aliasedEnvValue(env, suffix, warn = (message) => process.stderr.write(`${message}
210
+ `)) {
211
+ const resolved2 = resolveAliasedEnv(env, suffix);
212
+ const warning = aliasConflictWarning(resolved2);
213
+ if (warning !== void 0) warn(warning);
214
+ return resolved2.value;
215
+ }
216
+ var REGISTRY_SUBDIR = "sandboxes";
217
+ function discoverUserState(home = homedir()) {
218
+ const probe = (name) => {
219
+ const path4 = join(home, name);
220
+ return { path: path4, exists: isDirectoryAt(path4) };
221
+ };
222
+ return {
223
+ agroHome: probe(GENERATIONS.agro.userStateDir),
224
+ legacyHome: probe(GENERATIONS.legacy.userStateDir),
225
+ legacyCheckout: probe(LEGACY_CHECKOUT_DIR)
226
+ };
227
+ }
228
+ function resolveRegistryHome(env = process.env, home = homedir(), warn) {
229
+ const configured = aliasedEnvValue(env, "HOME", warn);
230
+ if (configured !== void 0) return { path: resolve(configured), configured: true };
231
+ return { path: home, configured: false };
232
+ }
233
+ function resolveUserStateHome(env = process.env, home = homedir(), warn) {
234
+ const configured = aliasedEnvValue(env, "HOME", warn);
235
+ if (configured !== void 0) return resolve(configured);
236
+ const discovered = discoverUserState(home);
237
+ const legacyRegistry = join(discovered.legacyHome.path, REGISTRY_SUBDIR);
238
+ const agroRegistry = join(discovered.agroHome.path, REGISTRY_SUBDIR);
239
+ const resolution = resolvePair(legacyRegistry, agroRegistry, isDirectoryAt);
240
+ return resolution.generation === "agro" ? discovered.agroHome.path : discovered.legacyHome.path;
241
+ }
16
242
 
17
243
  // src/lib/manifest.ts
18
- import { readFileSync } from "node:fs";
244
+ import { readFileSync as readFileSync2 } from "node:fs";
19
245
  import path from "node:path";
20
246
  var REGEX_SPECIAL = /* @__PURE__ */ new Set([".", "+", "?", "^", "$", "{", "}", "(", ")", "|", "[", "]", "\\"]);
21
247
  function globToRegExp(glob) {
@@ -66,7 +292,7 @@ function rootPayloadDirs(manifest) {
66
292
  function loadManifest(fromOh) {
67
293
  try {
68
294
  const manifestPath = path.resolve(fromOh, "manifest.json");
69
- const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
295
+ const parsed = JSON.parse(readFileSync2(manifestPath, "utf8"));
70
296
  if (!parsed || !Array.isArray(parsed.include) || parsed.include.length === 0) {
71
297
  return null;
72
298
  }
@@ -82,30 +308,66 @@ function loadManifest(fromOh) {
82
308
 
83
309
  // src/lib/vendor.ts
84
310
  import {
85
- existsSync,
86
- lstatSync,
311
+ existsSync as existsSync2,
312
+ lstatSync as lstatSync2,
87
313
  mkdirSync,
88
- readdirSync,
314
+ readdirSync as readdirSync2,
89
315
  copyFileSync
90
316
  } from "node:fs";
91
317
  import path2 from "node:path";
92
- function assertDestInTarget(dest, targetOh, sep4) {
93
- if (dest === targetOh || dest.startsWith(targetOh + sep4)) {
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
354
+ function assertDestInTarget(dest, targetOh, sep5) {
355
+ if (dest === targetOh || dest.startsWith(targetOh + sep5)) {
94
356
  return;
95
357
  }
96
- throw new Error("oh: refusing to write outside target .oh: " + dest);
358
+ throw new Error(`${activeBin()}: refusing to write outside target .oh: ` + dest);
97
359
  }
98
- function assertDestInRoot(dest, targetRoot, sep4) {
99
- if (dest === targetRoot || dest.startsWith(targetRoot + sep4)) {
360
+ function assertDestInRoot(dest, targetRoot, sep5) {
361
+ if (dest === targetRoot || dest.startsWith(targetRoot + sep5)) {
100
362
  return;
101
363
  }
102
- throw new Error("oh: refusing to write outside target root: " + dest);
364
+ throw new Error(`${activeBin()}: refusing to write outside target root: ` + dest);
103
365
  }
104
366
  function walkFiles(root, dir, acc) {
105
- const entries = readdirSync(dir, { withFileTypes: true });
367
+ const entries = readdirSync2(dir, { withFileTypes: true });
106
368
  for (const entry of entries) {
107
369
  const abs = path2.resolve(dir, entry.name);
108
- const st = lstatSync(abs);
370
+ const st = lstatSync2(abs);
109
371
  if (st.isSymbolicLink()) {
110
372
  continue;
111
373
  }
@@ -142,7 +404,7 @@ function copyOhPayload(fromOh, targetOh, manifest, opts, report) {
142
404
  }
143
405
  const dest = path2.join(targetOh, rel);
144
406
  assertDestInTarget(dest, targetOh, path2.sep);
145
- const exists = existsSync(dest);
407
+ const exists = existsSync2(dest);
146
408
  if (exists && skipExisting && !force) {
147
409
  report?.("skip-exists", rel);
148
410
  skipped++;
@@ -168,7 +430,7 @@ function copyRootPayload(fromRoot, targetRoot, manifest, opts, report) {
168
430
  const relpaths = [];
169
431
  for (const dir of rootPayloadDirs(manifest)) {
170
432
  const abs = path2.join(fromRoot, dir);
171
- if (!existsSync(abs) || !lstatSync(abs).isDirectory()) {
433
+ if (!existsSync2(abs) || !lstatSync2(abs).isDirectory()) {
172
434
  continue;
173
435
  }
174
436
  walkFiles(fromRoot, abs, relpaths);
@@ -190,7 +452,7 @@ function copyRootPayload(fromRoot, targetRoot, manifest, opts, report) {
190
452
  }
191
453
  const dest = path2.join(targetRoot, rel);
192
454
  assertDestInRoot(dest, targetRoot, path2.sep);
193
- const exists = existsSync(dest);
455
+ const exists = existsSync2(dest);
194
456
  if (exists && skipExisting && !force) {
195
457
  report?.("skip-exists", rel);
196
458
  skipped++;
@@ -232,7 +494,7 @@ function compareVersions(a, b) {
232
494
  function readCliVersion(ohDir) {
233
495
  const pkgPath = path3.resolve(ohDir, "cli", "package.json");
234
496
  try {
235
- const parsed = JSON.parse(readFileSync2(pkgPath, "utf8"));
497
+ const parsed = JSON.parse(readFileSync3(pkgPath, "utf8"));
236
498
  if (parsed && typeof parsed.version === "string") {
237
499
  return parsed.version;
238
500
  }
@@ -241,46 +503,50 @@ function readCliVersion(ohDir) {
241
503
  return "0.0.0";
242
504
  }
243
505
  async function runUpdate(opts, io) {
244
- const { targetDir, fromDir, force, dryRun } = opts;
506
+ const { bin, targetDir, fromDir, force, dryRun } = opts;
245
507
  const dryPrefix = dryRun ? "[dry-run] " : "";
246
- const fromOh = path3.resolve(fromDir, ".oh");
247
- if (!existsSync2(fromOh) || !statSync(fromOh).isDirectory()) {
508
+ const source = resolveControlDir(fromDir);
509
+ if (source.kind === "absent") {
248
510
  io.stderr(
249
- "oh update: update source not found at " + fromOh + ". Pass --from <built-OpenHarness-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"
250
512
  );
251
513
  return 1;
252
514
  }
253
- const targetOh = path3.resolve(targetDir, ".oh");
515
+ const fromOh = source.path;
516
+ const controlName = path3.basename(fromOh);
517
+ const target = resolveControlDir(targetDir);
518
+ const targetOh = target.kind === "absent" ? path3.resolve(targetDir, controlName) : target.path;
519
+ const targetName = path3.basename(targetOh);
254
520
  if (fromOh === targetOh) {
255
- io.stderr("oh update: source and target are the same .oh; nothing to update.\n");
521
+ io.stderr(bin + " update: source and target are the same " + targetName + "; nothing to update.\n");
256
522
  return 1;
257
523
  }
258
524
  const available = readCliVersion(fromOh);
259
525
  const current = readCliVersion(targetOh);
260
526
  const cmp = compareVersions(available, current);
261
527
  if (cmp > 0) {
262
- io.stdout(dryPrefix + "updating .oh: " + current + " -> " + available + "\n");
528
+ io.stdout(dryPrefix + "updating " + targetName + ": " + current + " -> " + available + "\n");
263
529
  } else if (cmp === 0) {
264
530
  if (!force) {
265
- 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");
266
532
  return 0;
267
533
  }
268
- io.stdout(dryPrefix + "oh update: re-overlay (v" + current + ", --force)\n");
534
+ io.stdout(dryPrefix + bin + " update: re-overlay (v" + current + ", --force)\n");
269
535
  } else {
270
536
  if (!force) {
271
537
  io.stderr(
272
- "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"
273
539
  );
274
540
  return 1;
275
541
  }
276
542
  io.stdout(
277
- dryPrefix + "oh update: downgrading .oh: " + current + " -> " + available + " (--force)\n"
543
+ dryPrefix + bin + " update: downgrading " + targetName + ": " + current + " -> " + available + " (--force)\n"
278
544
  );
279
545
  }
280
546
  const manifest = loadManifest(fromOh);
281
547
  if (manifest === null) {
282
548
  io.stdout(
283
- dryPrefix + "oh update: no .oh/manifest.json in source; overlaying all of .oh/ (legacy mode)\n"
549
+ dryPrefix + bin + " update: no " + controlName + "/manifest.json in source; overlaying all of " + controlName + "/ (legacy mode)\n"
284
550
  );
285
551
  }
286
552
  let created = 0;
@@ -315,15 +581,15 @@ async function runUpdate(opts, io) {
315
581
  report
316
582
  );
317
583
  for (const dir of rootPayloadDirs(manifest ?? { include: [], exclude: [] })) {
318
- const stranded = path3.resolve(targetDir, ".oh", dir);
319
- if (existsSync2(stranded)) {
584
+ const stranded = path3.join(targetOh, dir);
585
+ if (existsSync3(stranded)) {
320
586
  io.stdout(
321
- dryPrefix + "oh update: " + path3.join(".oh", 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"
322
588
  );
323
589
  }
324
590
  }
325
591
  io.stdout(
326
- 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"
327
593
  );
328
594
  return 0;
329
595
  }
@@ -336,232 +602,12 @@ import {
336
602
  readFileSync as readFileSync4,
337
603
  realpathSync,
338
604
  renameSync,
339
- statSync as statSync3,
605
+ statSync as statSync2,
340
606
  unlinkSync,
341
607
  writeFileSync
342
608
  } from "node:fs";
343
609
  import { basename as basename2, delimiter, dirname, extname, join as join2 } from "node:path";
344
-
345
- // src/lib/compat.ts
346
- import {
347
- existsSync as existsSync3,
348
- lstatSync as lstatSync2,
349
- readdirSync as readdirSync2,
350
- readFileSync as readFileSync3,
351
- readlinkSync,
352
- statSync as statSync2
353
- } from "node:fs";
354
- import { homedir } from "node:os";
355
- import { join, resolve } from "node:path";
356
- var GENERATIONS = {
357
- legacy: {
358
- controlDir: ".oh",
359
- configFile: "oh.json",
360
- envPrefix: "OH_",
361
- userStateDir: ".oh",
362
- seedDir: "/opt/oh-seed"
363
- },
364
- agro: {
365
- controlDir: ".agro",
366
- configFile: "agro.json",
367
- envPrefix: "AGRO_",
368
- userStateDir: ".agro",
369
- seedDir: "/opt/agro-seed"
370
- }
371
- };
372
- var LEGACY_CHECKOUT_DIR = ".openharness";
373
- var CompatConflictError = class extends Error {
374
- legacyPath;
375
- agroPath;
376
- differences;
377
- constructor(legacyPath, agroPath, differences) {
378
- super(
379
- `${legacyPath} and ${agroPath} both exist and differ \u2014 resolve the conflict before continuing (keep exactly one, or make them identical); differences: ${differences.join("; ")}`
380
- );
381
- this.name = "CompatConflictError";
382
- this.legacyPath = legacyPath;
383
- this.agroPath = agroPath;
384
- this.differences = differences;
385
- }
386
- };
387
- function entryType(stats) {
388
- if (stats.isSymbolicLink()) return "symlink";
389
- if (stats.isDirectory()) return "directory";
390
- if (stats.isFile()) return "file";
391
- return "other";
392
- }
393
- function lstatOrUndefined(path4) {
394
- return lstatSync2(path4, { throwIfNoEntry: false });
395
- }
396
- function statOrUndefined(path4) {
397
- try {
398
- return statSync2(path4, { throwIfNoEntry: false });
399
- } catch {
400
- return void 0;
401
- }
402
- }
403
- function sameBytes(a, b) {
404
- const left = readFileSync3(a);
405
- const right = readFileSync3(b);
406
- return left.equals(right);
407
- }
408
- function octal(mode) {
409
- return (mode & 511).toString(8).padStart(3, "0");
410
- }
411
- function contentDifference(type, legacy, agro, left, right) {
412
- switch (type) {
413
- case "symlink":
414
- return readlinkSync(legacy) !== readlinkSync(agro) ? "symlink target differs" : void 0;
415
- case "file":
416
- return left.size !== right.size || !sameBytes(legacy, agro) ? "content differs" : void 0;
417
- case "other":
418
- return "unsupported entry type";
419
- default:
420
- return void 0;
421
- }
422
- }
423
- function entryDifferences(label, legacy, agro, left, right) {
424
- const type = entryType(left);
425
- if (type !== entryType(right)) return [`${label}: type ${type} vs ${entryType(right)}`];
426
- const differences = [];
427
- if ((left.mode & 511) !== (right.mode & 511)) {
428
- differences.push(`${label}: mode ${octal(left.mode)} vs ${octal(right.mode)}`);
429
- }
430
- const content = contentDifference(type, legacy, agro, left, right);
431
- if (content !== void 0) differences.push(`${label}: ${content}`);
432
- return differences;
433
- }
434
- function compareTrees(legacy, agro, rel = "") {
435
- const label = rel === "" ? "." : rel;
436
- const left = lstatOrUndefined(legacy);
437
- const right = lstatOrUndefined(agro);
438
- if (left === void 0 && right === void 0) return [];
439
- if (left === void 0) return [`${label}: only in ${agro}`];
440
- if (right === void 0) return [`${label}: only in ${legacy}`];
441
- const differences = entryDifferences(label, legacy, agro, left, right);
442
- if (!left.isDirectory() || !right.isDirectory()) return differences;
443
- const names = /* @__PURE__ */ new Set([...readdirSync2(legacy), ...readdirSync2(agro)]);
444
- for (const name of [...names].sort()) {
445
- const childRel = rel === "" ? name : `${rel}/${name}`;
446
- differences.push(...compareTrees(join(legacy, name), join(agro, name), childRel));
447
- }
448
- return differences;
449
- }
450
- function resolvePair(legacyPath, agroPath, present) {
451
- const legacyExists = present(legacyPath);
452
- const agroExists = present(agroPath);
453
- const base = { legacyPath, agroPath };
454
- if (!legacyExists && !agroExists) {
455
- return { ...base, kind: "absent", generation: void 0, path: void 0 };
456
- }
457
- if (legacyExists && !agroExists) {
458
- return { ...base, kind: "legacy-only", generation: "legacy", path: legacyPath };
459
- }
460
- if (!legacyExists && agroExists) {
461
- return { ...base, kind: "agro-only", generation: "agro", path: agroPath };
462
- }
463
- const differences = compareTrees(legacyPath, agroPath);
464
- if (differences.length > 0) throw new CompatConflictError(legacyPath, agroPath, differences);
465
- return { ...base, kind: "both-equivalent", generation: "agro", path: agroPath };
466
- }
467
- function isDirectoryAt(path4) {
468
- return statOrUndefined(path4)?.isDirectory() === true;
469
- }
470
- function isFileAt(path4) {
471
- return statOrUndefined(path4)?.isFile() === true;
472
- }
473
- function resolveControlDir(root) {
474
- const dir = resolve(root);
475
- return resolvePair(
476
- join(dir, GENERATIONS.legacy.controlDir),
477
- join(dir, GENERATIONS.agro.controlDir),
478
- isDirectoryAt
479
- );
480
- }
481
- function resolveConfigFile(root) {
482
- const dir = resolve(root);
483
- return resolvePair(
484
- join(dir, GENERATIONS.legacy.configFile),
485
- join(dir, GENERATIONS.agro.configFile),
486
- isFileAt
487
- );
488
- }
489
- function envValue(env, key) {
490
- const value = env[key];
491
- return value !== void 0 && value !== "" ? value : void 0;
492
- }
493
- function resolveAliasedEnv(env, suffix) {
494
- const agroKey = `${GENERATIONS.agro.envPrefix}${suffix}`;
495
- const legacyKey = `${GENERATIONS.legacy.envPrefix}${suffix}`;
496
- const agro = envValue(env, agroKey);
497
- const legacy = envValue(env, legacyKey);
498
- const conflict = agro !== void 0 && legacy !== void 0 && agro !== legacy;
499
- if (agro !== void 0) {
500
- return { key: suffix, agroKey, legacyKey, value: agro, source: "agro", conflict };
501
- }
502
- if (legacy !== void 0) {
503
- return { key: suffix, agroKey, legacyKey, value: legacy, source: "legacy", conflict: false };
504
- }
505
- return { key: suffix, agroKey, legacyKey, value: void 0, source: "none", conflict: false };
506
- }
507
- function aliasConflictWarning(resolved) {
508
- if (!resolved.conflict) return void 0;
509
- return `compat: ${resolved.agroKey} and ${resolved.legacyKey} are both set and differ \u2014 using ${resolved.agroKey}`;
510
- }
511
- function aliasedEnvValue(env, suffix, warn = (message) => process.stderr.write(`${message}
512
- `)) {
513
- const resolved = resolveAliasedEnv(env, suffix);
514
- const warning = aliasConflictWarning(resolved);
515
- if (warning !== void 0) warn(warning);
516
- return resolved.value;
517
- }
518
- var REGISTRY_SUBDIR = "sandboxes";
519
- function discoverUserState(home = homedir()) {
520
- const probe = (name) => {
521
- const path4 = join(home, name);
522
- return { path: path4, exists: isDirectoryAt(path4) };
523
- };
524
- return {
525
- agroHome: probe(GENERATIONS.agro.userStateDir),
526
- legacyHome: probe(GENERATIONS.legacy.userStateDir),
527
- legacyCheckout: probe(LEGACY_CHECKOUT_DIR)
528
- };
529
- }
530
- function resolveUserStateHome(env = process.env, home = homedir(), warn) {
531
- const configured = aliasedEnvValue(env, "HOME", warn);
532
- if (configured !== void 0) return resolve(configured);
533
- const discovered = discoverUserState(home);
534
- const legacyRegistry = join(discovered.legacyHome.path, REGISTRY_SUBDIR);
535
- const agroRegistry = join(discovered.agroHome.path, REGISTRY_SUBDIR);
536
- const resolution = resolvePair(legacyRegistry, agroRegistry, isDirectoryAt);
537
- return resolution.generation === "agro" ? discovered.agroHome.path : discovered.legacyHome.path;
538
- }
539
-
540
- // src/lib/product.ts
541
- import { basename } from "node:path";
542
- var AGRO_PRODUCT = {
543
- name: "agro",
544
- bin: "agro",
545
- title: "AGRO CLI",
546
- packageName: "@mifune/agro"
547
- };
548
- var LEGACY_PRODUCT = {
549
- name: "oh",
550
- bin: "oh",
551
- title: "Open Harness CLI",
552
- packageName: "@mifune/openharness"
553
- };
554
- var FILE_EXTENSION = /\.[^.]*$/;
555
- function invokedName(argv1) {
556
- if (argv1 === void 0) return "";
557
- return basename(argv1.replace(/\\/g, "/")).replace(FILE_EXTENSION, "");
558
- }
559
- function resolveProduct(argv1) {
560
- return invokedName(argv1) === LEGACY_PRODUCT.bin ? LEGACY_PRODUCT : AGRO_PRODUCT;
561
- }
562
-
563
- // src/commands/self-upgrade.ts
564
- var DEFAULT_ARTIFACT_URL = "https://github.com/mifunedev/openharness/releases/latest/download/agro.js";
610
+ var DEFAULT_ARTIFACT_URL = "https://github.com/mifunedev/agro/releases/latest/download/agro.js";
565
611
  var PREFIX = `${AGRO_PRODUCT.bin} update`;
566
612
  var PACKAGE = AGRO_PRODUCT.packageName;
567
613
  var IMAGE_ROOT = "/opt/oh/";
@@ -609,9 +655,9 @@ function kindOfTarget(target, deps) {
609
655
  function classifyInstallation(argv1, deps) {
610
656
  const invoked = argv1 ?? "";
611
657
  if (invoked === "") return { kind: "unknown", target: "", invoked, reason: "no executable path (argv[1] is empty)" };
612
- const resolved = resolveTarget(invoked, deps);
613
- if (resolved.reason !== void 0) return { kind: "unknown", target: resolved.target, invoked, reason: resolved.reason };
614
- return { ...kindOfTarget(resolved.target, deps), target: resolved.target, invoked };
658
+ const resolved2 = resolveTarget(invoked, deps);
659
+ if (resolved2.reason !== void 0) return { kind: "unknown", target: resolved2.target, invoked, reason: resolved2.reason };
660
+ return { ...kindOfTarget(resolved2.target, deps), target: resolved2.target, invoked };
615
661
  }
616
662
  function parseSemver(input) {
617
663
  const m = SEMVER.exec(input.trim());
@@ -658,16 +704,16 @@ function compareSemver(a, b) {
658
704
  function versionOf(result) {
659
705
  return result.status === 0 ? result.stdout.trim() : "";
660
706
  }
661
- function refuseUnsupported(installation) {
707
+ function refuseUnsupported(installation, bin) {
662
708
  const { kind, target, reason } = installation;
663
709
  const at = `${kind} installation at ${target}`;
664
710
  switch (kind) {
665
711
  case "image":
666
712
  return new Error(
667
- `${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>)`
668
714
  );
669
715
  case "source":
670
- return new Error(`${at} \u2014 rebuild from the checkout: npm --prefix .oh/cli run build`);
716
+ return new Error(`${at} \u2014 rebuild from the checkout: npm --prefix .agro/cli run build`);
671
717
  case "legacy-package":
672
718
  return new Error(`${at} \u2014 install the canonical package: npm install -g ${PACKAGE}`);
673
719
  default:
@@ -679,13 +725,13 @@ function refuseUnsupported(installation) {
679
725
  function assertOnlyAgroOnPath(installation, deps) {
680
726
  const found = deps.which(AGRO_PRODUCT.bin);
681
727
  if (found === void 0) return;
682
- let resolved;
728
+ let resolved2;
683
729
  try {
684
- resolved = deps.realpath(found);
730
+ resolved2 = deps.realpath(found);
685
731
  } catch {
686
- resolved = found;
732
+ resolved2 = found;
687
733
  }
688
- if (resolved !== installation.target) {
734
+ if (resolved2 !== installation.target) {
689
735
  throw new Error(
690
736
  `another ${AGRO_PRODUCT.bin} is earlier on PATH (${found}); upgrading ${installation.target} would not change what runs \u2014 remove or reorder it first`
691
737
  );
@@ -847,7 +893,7 @@ async function runSelfUpgrade(opts, deps, io) {
847
893
  try {
848
894
  const installation = classifyInstallation(opts.argv1, deps);
849
895
  if (installation.kind !== "npm" && installation.kind !== "standalone") {
850
- throw refuseUnsupported(installation);
896
+ throw refuseUnsupported(installation, resolveProduct(opts.argv1).bin);
851
897
  }
852
898
  io.stdout(`${PREFIX}: ${installation.kind} installation at ${installation.target}
853
899
  `);
@@ -863,14 +909,14 @@ async function runSelfUpgrade(opts, deps, io) {
863
909
  }
864
910
  function whichOnPath(name, env, platform) {
865
911
  const win32 = platform === "win32";
866
- const sep4 = win32 ? ";" : delimiter;
867
- const dirs = (env.PATH ?? "").split(sep4).filter((d) => d !== "");
912
+ const sep5 = win32 ? ";" : delimiter;
913
+ const dirs = (env.PATH ?? "").split(sep5).filter((d) => d !== "");
868
914
  const exts = win32 ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
869
915
  for (const dir of dirs) {
870
916
  for (const ext of exts) {
871
917
  const candidate = join2(dir, `${name}${ext}`);
872
918
  try {
873
- if (statSync3(candidate).isFile()) {
919
+ if (statSync2(candidate).isFile()) {
874
920
  if (!win32) accessSync(candidate, constants.X_OK);
875
921
  return candidate;
876
922
  }
@@ -897,7 +943,7 @@ function defaultDeps(currentVersion) {
897
943
  return {
898
944
  env: process.env,
899
945
  realpath: (path4) => realpathSync(path4),
900
- stat: (path4) => statSync3(path4),
946
+ stat: (path4) => statSync2(path4),
901
947
  access: (path4, mode) => accessSync(path4, mode),
902
948
  readFile: (path4) => readFileSync4(path4),
903
949
  writeFile: (path4, data, mode) => writeFileSync(path4, data, { mode }),
@@ -912,212 +958,614 @@ function defaultDeps(currentVersion) {
912
958
  };
913
959
  }
914
960
 
915
- // src/commands/cloud.ts
916
- import { readFile } from "node:fs/promises";
917
- import { existsSync as existsSync8, readFileSync as readFileSync8 } from "node:fs";
918
- import { join as join3 } from "node:path";
919
-
920
- // src/lib/prompt.ts
921
- import { createInterface } from "node:readline";
922
- var COLORS = {
923
- reset: "\x1B[0m",
924
- bold: "\x1B[1m",
925
- dim: "\x1B[2m",
926
- green: "\x1B[32m",
927
- yellow: "\x1B[33m",
928
- red: "\x1B[31m",
929
- cyan: "\x1B[36m"
930
- };
931
- function header(msg) {
932
- process.stdout.write(`
933
- ${COLORS.bold}${msg}${COLORS.reset}
961
+ // src/commands/migrate.ts
962
+ import { lstatSync as lstatSync4 } from "node:fs";
963
+ import { dirname as dirname3, join as join3, resolve as resolve3 } from "node:path";
934
964
 
965
+ // src/lib/migrate.ts
966
+ import {
967
+ closeSync,
968
+ existsSync as existsSync4,
969
+ fsyncSync,
970
+ lstatSync as lstatSync3,
971
+ mkdirSync as mkdirSync2,
972
+ openSync,
973
+ readFileSync as readFileSync5,
974
+ readlinkSync as readlinkSync2,
975
+ realpathSync as realpathSync2,
976
+ renameSync as renameSync2,
977
+ symlinkSync,
978
+ unlinkSync as unlinkSync2,
979
+ writeFileSync as writeFileSync2
980
+ } from "node:fs";
981
+ import { dirname as dirname2, isAbsolute, relative, resolve as resolve2, sep } from "node:path";
982
+ var MIGRATION_PLAN_VERSION = 1;
983
+ var RETIRED_SUFFIX = ".migrated";
984
+ var LOCK_FILE = ".agro-migrate.lock";
985
+ function snapshot(path4) {
986
+ const stats = lstatSync3(path4, { throwIfNoEntry: false });
987
+ if (stats === void 0) return void 0;
988
+ return {
989
+ type: stats.isSymbolicLink() ? "symlink" : stats.isDirectory() ? "directory" : "file",
990
+ mode: stats.mode & 511,
991
+ ino: stats.ino,
992
+ mtimeMs: stats.mtimeMs,
993
+ size: stats.size
994
+ };
995
+ }
996
+ function presentAs(path4, kind) {
997
+ let stats;
998
+ try {
999
+ stats = lstatSync3(path4, { throwIfNoEntry: false });
1000
+ } catch {
1001
+ return false;
1002
+ }
1003
+ if (stats === void 0) return false;
1004
+ if (stats.isSymbolicLink()) return true;
1005
+ return kind === "dir" ? stats.isDirectory() : stats.isFile();
1006
+ }
1007
+ function nearestExistingAncestor(path4) {
1008
+ let cursor = path4;
1009
+ while (!existsSync4(cursor)) {
1010
+ const parent = dirname2(cursor);
1011
+ if (parent === cursor) return cursor;
1012
+ cursor = parent;
1013
+ }
1014
+ return cursor;
1015
+ }
1016
+ function insideRoot(root, candidate) {
1017
+ const realRoot = realpathSync2(root);
1018
+ const anchor = nearestExistingAncestor(dirname2(candidate));
1019
+ const realAnchor = realpathSync2(anchor);
1020
+ const rel = relative(realRoot, realAnchor);
1021
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
1022
+ }
1023
+ function assertInsideRoot(root, path4, label) {
1024
+ if (!isAbsolute(path4)) throw new Error(`migration ${label} must be absolute: ${path4}`);
1025
+ const absolute = resolve2(path4);
1026
+ if (!insideRoot(root, absolute)) {
1027
+ throw new Error(`refusing migration ${label} outside ${root}: ${absolute}`);
1028
+ }
1029
+ return absolute;
1030
+ }
1031
+ function sameSnapshot(a, b) {
1032
+ return b !== void 0 && a.type === b.type && a.mode === b.mode && a.ino === b.ino && a.mtimeMs === b.mtimeMs && a.size === b.size;
1033
+ }
1034
+ function planPair(root, pair) {
1035
+ const legacy = assertInsideRoot(root, pair.legacy, "source");
1036
+ const agro = assertInsideRoot(root, pair.agro, "destination");
1037
+ const legacyPresent = presentAs(legacy, pair.kind);
1038
+ const agroPresent = presentAs(agro, pair.kind);
1039
+ if (!legacyPresent) {
1040
+ return { kind: "noop", path: agro, reason: agroPresent ? "already migrated" : "absent in both generations" };
1041
+ }
1042
+ if (!agroPresent) {
1043
+ if (lstatSync3(agro, { throwIfNoEntry: false }) !== void 0) {
1044
+ return { path: agro, reason: `destination exists but is not a ${pair.kind}` };
1045
+ }
1046
+ return {
1047
+ kind: "rename",
1048
+ from: legacy,
1049
+ to: agro,
1050
+ snapshot: snapshot(legacy),
1051
+ createsParent: !existsSync4(dirname2(agro))
1052
+ };
1053
+ }
1054
+ const differences = compareTrees(legacy, agro);
1055
+ if (differences.length > 0) {
1056
+ return { path: agro, reason: "both generations exist and differ", differences };
1057
+ }
1058
+ const retired = `${legacy}${RETIRED_SUFFIX}`;
1059
+ if (lstatSync3(retired, { throwIfNoEntry: false }) !== void 0) {
1060
+ return { path: retired, reason: "retired copy already exists" };
1061
+ }
1062
+ return { kind: "retire", from: legacy, to: retired, snapshot: snapshot(legacy) };
1063
+ }
1064
+ function planRewrite(root, rewrite) {
1065
+ const path4 = assertInsideRoot(root, rewrite.path, "rewrite target");
1066
+ const current = snapshot(path4);
1067
+ if (current === void 0 || current.type !== "file") {
1068
+ return { kind: "noop", path: path4, reason: "rewrite target is not a regular file" };
1069
+ }
1070
+ const content = readFileSync5(path4, "utf8");
1071
+ const changed = rewrite.replacements.some((r) => r.from !== "" && content.includes(r.from));
1072
+ if (!changed) return { kind: "noop", path: path4, reason: "rewrite already applied" };
1073
+ return { kind: "rewrite", path: path4, replacements: rewrite.replacements, snapshot: current };
1074
+ }
1075
+ function planRelink(root, relink) {
1076
+ const path4 = assertInsideRoot(root, relink.path, "relink target");
1077
+ const current = snapshot(path4);
1078
+ if (current === void 0) return { kind: "noop", path: path4, reason: "link absent" };
1079
+ if (current.type !== "symlink") return { kind: "noop", path: path4, reason: "not a symlink" };
1080
+ const target = readlinkSync2(path4);
1081
+ if (target === relink.to) return { kind: "noop", path: path4, reason: "link already points at the AGRO target" };
1082
+ if (target !== relink.from) return { kind: "noop", path: path4, reason: `link points at ${target}, not ${relink.from}` };
1083
+ return { kind: "relink", path: path4, from: relink.from, to: relink.to, snapshot: current };
1084
+ }
1085
+ function resolved(path4) {
1086
+ try {
1087
+ return realpathSync2(path4);
1088
+ } catch {
1089
+ return void 0;
1090
+ }
1091
+ }
1092
+ function resolvesToPack(path4, packs) {
1093
+ const target = resolved(path4);
1094
+ if (target === void 0) return false;
1095
+ return packs.some((pack) => resolved(pack) === target);
1096
+ }
1097
+ function replacementSurvives(retire) {
1098
+ const current = snapshot(retire.replacement);
1099
+ if (current === void 0 || current.type !== "symlink") return false;
1100
+ if (!retire.replacementTargets.includes(readlinkSync2(retire.replacement))) return false;
1101
+ return resolvesToPack(retire.replacement, retire.packs);
1102
+ }
1103
+ function preserveRetiredLink(root, retire, reason) {
1104
+ const step = planRelink(root, { path: retire.path, ...retire.preserve });
1105
+ return step.kind === "relink" ? step : { kind: "noop", path: resolve2(retire.path), reason };
1106
+ }
1107
+ function planRetireLink(root, retire) {
1108
+ const path4 = assertInsideRoot(root, retire.path, "retired link");
1109
+ const current = snapshot(path4);
1110
+ if (current === void 0) return { kind: "noop", path: path4, reason: "link absent" };
1111
+ if (current.type !== "symlink") return { kind: "noop", path: path4, reason: "not a symlink" };
1112
+ if (!resolvesToPack(path4, retire.packs)) {
1113
+ return preserveRetiredLink(root, retire, "link does not resolve to the Open Harness skill pack");
1114
+ }
1115
+ if (!replacementSurvives(retire)) {
1116
+ return preserveRetiredLink(
1117
+ root,
1118
+ retire,
1119
+ `${retire.replacement} is not an independent skill-pack link that survives the migration`
1120
+ );
1121
+ }
1122
+ const retired = `${path4}${RETIRED_SUFFIX}`;
1123
+ if (lstatSync3(retired, { throwIfNoEntry: false }) !== void 0) {
1124
+ return { path: retired, reason: "retired copy already exists" };
1125
+ }
1126
+ return { kind: "retire", from: path4, to: retired, snapshot: current };
1127
+ }
1128
+ function isStep(entry) {
1129
+ return "kind" in entry;
1130
+ }
1131
+ function planMigration(spec) {
1132
+ const root = resolve2(spec.root);
1133
+ if (!existsSync4(root)) throw new Error(`migration root does not exist: ${root}`);
1134
+ const steps = [];
1135
+ const conflicts = [];
1136
+ for (const pair of spec.pairs) {
1137
+ const planned = planPair(root, pair);
1138
+ if (isStep(planned)) steps.push(planned);
1139
+ else conflicts.push(planned);
1140
+ }
1141
+ for (const retired of spec.retireLinks ?? []) {
1142
+ const planned = planRetireLink(root, retired);
1143
+ if (isStep(planned)) steps.push(planned);
1144
+ else conflicts.push(planned);
1145
+ }
1146
+ for (const rewrite of spec.rewrites ?? []) steps.push(planRewrite(root, rewrite));
1147
+ for (const relink of spec.relinks ?? []) steps.push(planRelink(root, relink));
1148
+ const mutating = steps.some((step) => step.kind !== "noop");
1149
+ const status = conflicts.length > 0 ? "conflict" : mutating ? "ready" : "noop";
1150
+ return { version: MIGRATION_PLAN_VERSION, root, status, steps, conflicts };
1151
+ }
1152
+ function acquireLock(root) {
1153
+ const lock = resolve2(root, LOCK_FILE);
1154
+ let fd;
1155
+ try {
1156
+ fd = openSync(lock, "wx", 384);
1157
+ } catch (error) {
1158
+ const code = error.code;
1159
+ if (code === "EEXIST") return void 0;
1160
+ throw error;
1161
+ }
1162
+ writeFileSync2(fd, `${process.pid}
935
1163
  `);
1164
+ closeSync(fd);
1165
+ return () => {
1166
+ try {
1167
+ unlinkSync2(lock);
1168
+ } catch {
1169
+ }
1170
+ };
936
1171
  }
937
- function bold(s) {
938
- return `${COLORS.bold}${s}${COLORS.reset}`;
1172
+ function revalidateInPlace(step) {
1173
+ if (!sameSnapshot(step.snapshot, snapshot(step.path))) return `${step.path} changed since the plan was made`;
1174
+ if (step.kind === "relink" && readlinkSync2(step.path) !== step.from) {
1175
+ return `${step.path} changed since the plan was made`;
1176
+ }
1177
+ return void 0;
939
1178
  }
940
- function redact(token) {
941
- if (token.length <= 8) return "****";
942
- return token.slice(0, 5) + "*".repeat(token.length - 5);
1179
+ function revalidate(step) {
1180
+ if (step.kind === "noop") return void 0;
1181
+ if (step.kind === "rewrite" || step.kind === "relink") return revalidateInPlace(step);
1182
+ if (!sameSnapshot(step.snapshot, snapshot(step.from))) {
1183
+ return `${step.from} changed since the plan was made`;
1184
+ }
1185
+ if (lstatSync3(step.to, { throwIfNoEntry: false }) !== void 0) {
1186
+ return `${step.to} appeared since the plan was made`;
1187
+ }
1188
+ return void 0;
943
1189
  }
944
- async function ask(question) {
945
- const rl = createInterface({ input: process.stdin, output: process.stdout });
1190
+ function applyRewrite(step) {
1191
+ let content = readFileSync5(step.path, "utf8");
1192
+ for (const { from, to } of step.replacements) {
1193
+ if (from === "") continue;
1194
+ content = content.split(from).join(to);
1195
+ }
1196
+ const tmp = `${step.path}.tmp.${process.pid}`;
1197
+ const fd = openSync(tmp, "w", step.snapshot.mode);
946
1198
  try {
947
- return await new Promise((resolve9) => {
948
- rl.question(` ${question} `, (answer) => resolve9(answer.trim()));
949
- });
1199
+ writeFileSync2(fd, content, "utf8");
1200
+ fsyncSync(fd);
950
1201
  } finally {
951
- rl.close();
1202
+ closeSync(fd);
952
1203
  }
1204
+ renameSync2(tmp, step.path);
953
1205
  }
954
- async function askSecret(question) {
955
- if (!process.stdin.isTTY) return askSecretPiped(question);
956
- process.stdout.write(` ${question} `);
957
- process.stdin.setRawMode(true);
958
- process.stdin.resume();
959
- const bytes = [];
960
- return await new Promise((resolve9) => {
961
- const cleanup = () => {
962
- try {
963
- process.stdin.setRawMode(false);
964
- } catch {
965
- }
966
- process.stdin.pause();
967
- process.stdin.removeListener("data", onData);
968
- };
969
- const onData = (data) => {
970
- for (const byte of data) {
971
- if (byte === 3) {
972
- cleanup();
973
- process.stdout.write("\n");
974
- process.exit(130);
975
- } else if (byte === 13 || byte === 10) {
976
- cleanup();
977
- process.stdout.write("\n");
978
- resolve9(Buffer.from(bytes).toString("utf8").trim());
979
- return;
980
- } else if (byte === 127 || byte === 8) {
981
- if (bytes.length > 0) {
982
- bytes.pop();
983
- process.stdout.write("\b \b");
984
- }
985
- } else if (byte === 21) {
986
- while (bytes.length > 0) {
987
- bytes.pop();
988
- process.stdout.write("\b \b");
989
- }
990
- } else if (byte >= 32) {
991
- bytes.push(byte);
992
- process.stdout.write("\u25CF");
993
- }
994
- }
995
- };
996
- process.stdin.on("data", onData);
997
- });
1206
+ function applyRelink(step) {
1207
+ const tmp = `${step.path}.tmp.${process.pid}`;
1208
+ symlinkSync(step.to, tmp);
1209
+ try {
1210
+ renameSync2(tmp, step.path);
1211
+ } catch (error) {
1212
+ unlinkSync2(tmp);
1213
+ throw error;
1214
+ }
998
1215
  }
999
- async function askSecretPiped(question) {
1000
- const rl = createInterface({ input: process.stdin, output: process.stdout });
1001
- const stdoutWrite = process.stdout.write.bind(process.stdout);
1002
- let prompted = false;
1003
- const patched = (chunk) => {
1004
- if (!prompted) {
1005
- prompted = true;
1006
- return stdoutWrite(chunk);
1216
+ function pendingResults(plan) {
1217
+ return plan.steps.map((step) => ({ ...step, outcome: step.kind === "noop" ? "skipped" : "pending" }));
1218
+ }
1219
+ function refusal(plan) {
1220
+ if (plan.version !== MIGRATION_PLAN_VERSION) return `unsupported plan version ${plan.version}`;
1221
+ if (plan.status === "conflict") {
1222
+ return plan.conflicts.map((c) => `${c.path}: ${c.reason}`).join("; ");
1223
+ }
1224
+ return void 0;
1225
+ }
1226
+ function revalidatePlan(root, plan) {
1227
+ for (const step of plan.steps) {
1228
+ const stale = revalidate(step);
1229
+ if (stale !== void 0) return stale;
1230
+ if (step.kind === "rename" || step.kind === "retire") {
1231
+ assertInsideRoot(root, step.from, "source");
1232
+ assertInsideRoot(root, step.to, "destination");
1007
1233
  }
1008
- return true;
1009
- };
1010
- process.stdout.write = patched;
1011
- let restored = false;
1012
- const restore = () => {
1013
- if (restored) return;
1014
- restored = true;
1015
- process.stdout.write = stdoutWrite;
1234
+ }
1235
+ return void 0;
1236
+ }
1237
+ function applyStep(step) {
1238
+ if (step.kind === "noop") return;
1239
+ if (step.kind === "rewrite") {
1240
+ applyRewrite(step);
1241
+ return;
1242
+ }
1243
+ if (step.kind === "relink") {
1244
+ applyRelink(step);
1245
+ return;
1246
+ }
1247
+ if (step.kind === "rename" && step.createsParent) mkdirSync2(dirname2(step.to), { recursive: true });
1248
+ renameSync2(step.from, step.to);
1249
+ }
1250
+ function applySteps(root, plan) {
1251
+ const results = pendingResults(plan);
1252
+ for (let i = 0; i < plan.steps.length; i += 1) {
1253
+ const step = plan.steps[i];
1254
+ if (step.kind === "noop") continue;
1016
1255
  try {
1017
- rl.close();
1018
- } catch {
1256
+ applyStep(step);
1257
+ results[i] = { ...step, outcome: "done" };
1258
+ } catch (error) {
1259
+ results[i] = { ...step, outcome: "failed", error: error instanceof Error ? error.message : String(error) };
1260
+ return {
1261
+ status: "failed",
1262
+ root,
1263
+ reason: `step ${i + 1} failed; completed steps are recoverable by reversing them in order`,
1264
+ steps: results
1265
+ };
1019
1266
  }
1020
- };
1021
- const onSigint = () => {
1022
- restore();
1023
- process.stdout.write("\n");
1024
- process.exit(130);
1025
- };
1026
- process.on("SIGINT", onSigint);
1267
+ }
1268
+ return { status: "applied", root, steps: results };
1269
+ }
1270
+ function applyMigration(plan) {
1271
+ const root = resolve2(plan.root);
1272
+ const refused = refusal(plan);
1273
+ if (refused !== void 0) return { status: "refused", root, reason: refused, steps: pendingResults(plan) };
1274
+ if (plan.status === "noop") return { status: "noop", root, steps: pendingResults(plan) };
1275
+ const release = acquireLock(root);
1276
+ if (release === void 0) {
1277
+ return {
1278
+ status: "refused",
1279
+ root,
1280
+ reason: `another migration holds ${resolve2(root, LOCK_FILE)}`,
1281
+ steps: pendingResults(plan)
1282
+ };
1283
+ }
1027
1284
  try {
1028
- return await new Promise((resolve9) => {
1029
- rl.question(` ${question} `, (answer) => {
1030
- process.stdout.write("\n");
1031
- resolve9(answer.trim());
1032
- });
1033
- });
1285
+ const stale = revalidatePlan(root, plan);
1286
+ if (stale !== void 0) return { status: "refused", root, reason: stale, steps: pendingResults(plan) };
1287
+ return applySteps(root, plan);
1034
1288
  } finally {
1035
- process.removeListener("SIGINT", onSigint);
1036
- restore();
1289
+ release();
1037
1290
  }
1038
1291
  }
1039
- async function confirm(question, defaultYes = true) {
1040
- const suffix = defaultYes ? "[Y/n]" : "[y/N]";
1041
- const answer = (await ask(`${question} ${suffix}`)).toLowerCase();
1042
- if (answer === "") return defaultYes;
1043
- return /^y/.test(answer);
1292
+ var PROVIDER_LINKS = [
1293
+ { link: ".claude/skills", target: "skills" },
1294
+ { link: ".claude/hooks", target: "hooks" },
1295
+ { link: ".agents/skills", target: "skills" }
1296
+ ];
1297
+ var CANONICAL_SKILL_PACKS = [".oh/skills", ".agro/skills"];
1298
+ var SURVIVING_SKILL_LINK_TARGETS = ["../.oh/skills", "../.agro/skills"];
1299
+ var RETIRED_PROVIDER_LINKS = [
1300
+ {
1301
+ link: ".pi/skills",
1302
+ replacement: ".agents/skills",
1303
+ preserve: { from: "../.oh/skills", to: "../.agro/skills" }
1304
+ },
1305
+ {
1306
+ link: ".codex/skills",
1307
+ replacement: ".agents/skills",
1308
+ preserve: { from: "../.oh/skills", to: "../.agro/skills" }
1309
+ }
1310
+ ];
1311
+ function providerRelinks(root) {
1312
+ const dir = resolve2(root);
1313
+ return PROVIDER_LINKS.map(({ link, target }) => ({
1314
+ path: resolve2(dir, ...link.split("/")),
1315
+ from: `../.oh/${target}`,
1316
+ to: `../.agro/${target}`
1317
+ }));
1318
+ }
1319
+ function retiredProviderLinks(root) {
1320
+ const dir = resolve2(root);
1321
+ const packs = CANONICAL_SKILL_PACKS.map((pack) => resolve2(dir, ...pack.split("/")));
1322
+ return RETIRED_PROVIDER_LINKS.map(({ link, replacement, preserve }) => ({
1323
+ path: resolve2(dir, ...link.split("/")),
1324
+ packs,
1325
+ replacement: resolve2(dir, ...replacement.split("/")),
1326
+ replacementTargets: [...SURVIVING_SKILL_LINK_TARGETS],
1327
+ preserve
1328
+ }));
1329
+ }
1330
+ function projectMigrationSpec(root) {
1331
+ const dir = resolve2(root);
1332
+ return {
1333
+ root: dir,
1334
+ pairs: [
1335
+ { legacy: `${dir}${sep}.oh`, agro: `${dir}${sep}.agro`, kind: "dir" },
1336
+ { legacy: `${dir}${sep}oh.json`, agro: `${dir}${sep}agro.json`, kind: "file" }
1337
+ ],
1338
+ retireLinks: retiredProviderLinks(dir),
1339
+ relinks: providerRelinks(dir)
1340
+ };
1341
+ }
1342
+ function userStateMigrationSpec(home) {
1343
+ const dir = resolve2(home);
1344
+ return {
1345
+ root: dir,
1346
+ pairs: [
1347
+ {
1348
+ legacy: `${dir}${sep}.oh${sep}sandboxes`,
1349
+ agro: `${dir}${sep}.agro${sep}sandboxes`,
1350
+ kind: "dir"
1351
+ }
1352
+ ]
1353
+ };
1044
1354
  }
1045
1355
 
1046
- // src/lib/oh-config.ts
1047
- import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync5, renameSync as renameSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
1048
- import { basename as basename3, dirname as dirname2, resolve as resolve3 } from "node:path";
1049
-
1050
- // src/lib/env-file.ts
1051
- import { existsSync as existsSync4 } from "node:fs";
1052
- import { resolve as resolve2, sep } from "node:path";
1053
- function assertInRoot(dest, root) {
1054
- if (!(dest === root || dest.startsWith(root + sep))) {
1055
- throw new Error(`refusing to write outside the project root: ${dest}`);
1056
- }
1356
+ // src/commands/migrate.ts
1357
+ var ROOT_MARKERS = [".oh", ".agro", "oh.json", "agro.json"];
1358
+ var HELP_FLAGS = ["-h", "--help", "help"];
1359
+ function defaultMigrateDeps() {
1360
+ const registry = resolveRegistryHome();
1361
+ return {
1362
+ cwd: () => process.cwd(),
1363
+ home: () => registry.path,
1364
+ homeConfigured: () => registry.configured
1365
+ };
1057
1366
  }
1058
- function stripQuotes(s) {
1059
- if (s.length >= 2 && (s[0] === '"' && s.endsWith('"') || s[0] === "'" && s.endsWith("'"))) {
1060
- return s.slice(1, -1);
1367
+ function parseMigrateArgs(argv, bin) {
1368
+ const args = { bin, help: false, check: false, home: false, json: false };
1369
+ for (const arg of argv) {
1370
+ if (HELP_FLAGS.includes(arg)) return { ok: true, args: { ...args, help: true } };
1371
+ if (arg === "--check") args.check = true;
1372
+ else if (arg === "--home") args.home = true;
1373
+ else if (arg === "--json") args.json = true;
1374
+ else return { ok: false, error: `${bin} migrate: unexpected argument "${arg}"`, showHelp: true };
1061
1375
  }
1062
- return s;
1376
+ return { ok: true, args };
1063
1377
  }
1064
- var CONFIG_FIELD_BY_ENV_KEY = {
1065
- DOCKER_SOCKET: "access.dockerSocket"
1066
- };
1067
- function setEnvValue(root, key, value) {
1068
- const path4 = CONFIG_FIELD_BY_ENV_KEY[key];
1069
- if (path4 === void 0) {
1070
- throw new Error(
1071
- `${key} is not a settable oh.json field \u2014 run \`oh config set\` for a non-secret, \`oh secret set\` for a credential`
1072
- );
1073
- }
1074
- return setConfigField(root, path4, value);
1378
+ function migrateHelpText(bin) {
1379
+ return `${bin} migrate \u2014 Move a legacy .oh/ project or ~/.oh registry to AGRO names
1380
+
1381
+ Usage:
1382
+ ${bin} migrate [--check] [--home] [--json]
1383
+
1384
+ Project mode (default) finds the nearest ancestor of the current directory that
1385
+ holds .oh/, .agro/, oh.json or agro.json and renames .oh/ -> .agro/ and
1386
+ oh.json -> agro.json wholesale. Provider links (.claude/skills, .claude/hooks,
1387
+ .agents/skills) that point at ../.oh/... are re-pointed at ../.agro/....
1388
+ Retired links (.pi/skills, .codex/skills) move to <path>.migrated only when each
1389
+ resolves to the Open Harness skill pack and .agents/skills independently links
1390
+ to that pack. Otherwise each retired link stays in place, re-pointed at
1391
+ ../.agro/skills when it named the legacy pack. Other links stay unchanged.
1392
+
1393
+ Flags:
1394
+ --check Print the plan and change nothing.
1395
+ --home Migrate the sandbox registry ~/.oh/sandboxes -> ~/.agro/sandboxes instead.
1396
+ --json Emit the plan (--check) or {plan, result} as JSON on stdout.
1397
+
1398
+ Byte-identical legacy and AGRO copies retire the legacy one to <name>.migrated;
1399
+ divergent copies are refused and nothing is merged or deleted. ~/.openharness,
1400
+ .env and .git are never touched.
1401
+
1402
+ Exit codes: 0 applied or nothing to do, 2 refused (conflict or lock), 1 failure.
1403
+ `;
1075
1404
  }
1076
- function setConfigField(root, path4, value) {
1077
- const file = ohConfigPath(root);
1078
- assertInRoot(file, resolve2(root));
1079
- const config = readOhConfig(file);
1080
- const before = getOhConfigValue(config, path4);
1081
- const next = setOhConfigValue(config, path4, value);
1082
- const after = getOhConfigValue(next, path4);
1083
- if (existsSync4(file) && JSON.stringify(before) === JSON.stringify(after)) {
1084
- return "already-set";
1085
- }
1086
- writeOhConfig(root, next);
1087
- return before === void 0 ? "added" : "updated";
1405
+ function printMigrateHelp(bin) {
1406
+ process.stdout.write(migrateHelpText(bin));
1407
+ }
1408
+ function hasMarker(dir) {
1409
+ return ROOT_MARKERS.some((marker) => lstatSync4(join3(dir, marker), { throwIfNoEntry: false }) !== void 0);
1410
+ }
1411
+ function findProjectRoot(cwd) {
1412
+ const start = resolve3(cwd);
1413
+ let cursor = start;
1414
+ while (true) {
1415
+ if (hasMarker(cursor)) return cursor;
1416
+ const parent = dirname3(cursor);
1417
+ if (parent === cursor) return start;
1418
+ cursor = parent;
1419
+ }
1420
+ }
1421
+ function specFor(args, deps) {
1422
+ return args.home ? userStateMigrationSpec(deps.home()) : projectMigrationSpec(findProjectRoot(deps.cwd()));
1423
+ }
1424
+ function describeStep(step) {
1425
+ switch (step.kind) {
1426
+ case "rename":
1427
+ return `rename ${step.from} -> ${step.to}`;
1428
+ case "retire":
1429
+ return `retire ${step.from} -> ${step.to}`;
1430
+ case "rewrite":
1431
+ return `rewrite ${step.path} (${step.replacements.length} replacement${step.replacements.length === 1 ? "" : "s"})`;
1432
+ case "relink":
1433
+ return `relink ${step.path}: ${step.from} -> ${step.to}`;
1434
+ case "noop":
1435
+ return `noop ${step.path} (${step.reason})`;
1436
+ }
1437
+ }
1438
+ function describeConflict(conflict) {
1439
+ const lines = [` ${conflict.path}: ${conflict.reason}`];
1440
+ for (const difference of conflict.differences ?? []) lines.push(` - ${difference}`);
1441
+ return lines;
1442
+ }
1443
+ function planSummary(bin, plan) {
1444
+ const lines = [`${bin} migrate: plan for ${plan.root}`];
1445
+ for (const step of plan.steps) lines.push(` ${describeStep(step)}`);
1446
+ if (plan.conflicts.length > 0) {
1447
+ lines.push("conflicts:");
1448
+ for (const conflict of plan.conflicts) lines.push(...describeConflict(conflict));
1449
+ }
1450
+ lines.push(`status: ${plan.status}`);
1451
+ return `${lines.join("\n")}
1452
+ `;
1088
1453
  }
1089
- function setKeyInEnv(content, key, value) {
1090
- const lines = content.split("\n");
1091
- const live = new RegExp(`^${escapeRegExp(key)}=(.*)$`);
1092
- const commented = new RegExp(`^[ \\t]*#[ \\t]*${escapeRegExp(key)}=(.*)$`);
1093
- for (let i = 0; i < lines.length; i++) {
1094
- const m = live.exec(lines[i]);
1095
- if (m) {
1096
- if (stripQuotes(m[1].trim()) === value) return { content, outcome: "already-set" };
1097
- lines[i] = `${key}=${value}`;
1098
- return { content: lines.join("\n"), outcome: "updated" };
1099
- }
1454
+ function describeOutcome(step) {
1455
+ const suffix = step.error === void 0 ? "" : ` \u2014 ${step.error}`;
1456
+ return ` ${step.outcome.padEnd(7)} ${describeStep(step)}${suffix}`;
1457
+ }
1458
+ function resultSummary(bin, result) {
1459
+ const lines = [`${bin} migrate: ${result.status}${result.reason === void 0 ? "" : ` \u2014 ${result.reason}`}`];
1460
+ for (const step of result.steps) lines.push(describeOutcome(step));
1461
+ return `${lines.join("\n")}
1462
+ `;
1463
+ }
1464
+ function planExitCode(plan) {
1465
+ return plan.status === "conflict" ? 2 : 0;
1466
+ }
1467
+ function resultExitCode(result) {
1468
+ if (result.status === "applied" || result.status === "noop") return 0;
1469
+ return result.status === "refused" ? 2 : 1;
1470
+ }
1471
+ function emitJson(io, value) {
1472
+ io.stdout(`${JSON.stringify(value, null, 2)}
1473
+ `);
1474
+ }
1475
+ function check(args, plan, io) {
1476
+ if (args.json) emitJson(io, plan);
1477
+ else io.stdout(planSummary(args.bin, plan));
1478
+ return planExitCode(plan);
1479
+ }
1480
+ function apply(args, plan, io) {
1481
+ const result = applyMigration(plan);
1482
+ if (args.json) {
1483
+ emitJson(io, { plan, result });
1484
+ } else {
1485
+ io.stdout(planSummary(args.bin, plan));
1486
+ io.stdout(resultSummary(args.bin, result));
1100
1487
  }
1101
- for (let i = 0; i < lines.length; i++) {
1102
- if (commented.test(lines[i])) {
1103
- lines[i] = `${key}=${value}`;
1104
- return { content: lines.join("\n"), outcome: "uncommented" };
1488
+ return resultExitCode(result);
1489
+ }
1490
+ function runMigrate(args, io, deps = defaultMigrateDeps()) {
1491
+ try {
1492
+ if (args.home && deps.homeConfigured?.() === true) {
1493
+ io.stdout(
1494
+ `${args.bin} migrate: the registry home is set explicitly to ${deps.home()}, so it holds no .oh or .agro directory to rename
1495
+ `
1496
+ );
1497
+ io.stdout(`${args.bin} migrate: noop
1498
+ `);
1499
+ return 0;
1105
1500
  }
1501
+ const plan = planMigration(specFor(args, deps));
1502
+ return args.check ? check(args, plan, io) : apply(args, plan, io);
1503
+ } catch (error) {
1504
+ const message = error instanceof Error ? error.message : String(error);
1505
+ io.stderr(`${args.bin} migrate: ${message}
1506
+ `);
1507
+ return 1;
1106
1508
  }
1107
- const body = content.replace(/\n+$/, "");
1509
+ }
1510
+
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 } : {}
1520
+ };
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;
1108
1523
  return {
1109
- content: body === "" ? `${key}=${value}
1110
- ` : `${body}
1111
- ${key}=${value}
1112
- `,
1113
- outcome: "added"
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
1114
1528
  };
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;
1536
+ }
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;
1544
+ }
1545
+ };
1546
+ function assertSpawned(r, what) {
1547
+ if (r.error) {
1548
+ throw new ExecutionSpawnError(what, r.error);
1549
+ }
1115
1550
  }
1116
- function escapeRegExp(s) {
1117
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1551
+ function requireLifecycleScript(root, rel) {
1552
+ const { controlDir } = resolveProjectLayout(root);
1553
+ const script = join4(controlDir, "scripts", rel);
1554
+ if (!existsSync5(script)) {
1555
+ throw new Error(
1556
+ `missing lifecycle script ${script} \u2014 the vendored ${basename3(controlDir)}/ payload looks incomplete; run \`${activeBin()} update\` to re-vendor it`
1557
+ );
1558
+ }
1559
+ return script;
1118
1560
  }
1119
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
+
1120
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";
1121
1569
  var OH_CONFIG_FILE = GENERATIONS.legacy.configFile;
1122
1570
  var OH_CONFIG_MODE = 420;
1123
1571
  var RESERVED_HOME_PATHS = [
@@ -1146,9 +1594,12 @@ var LANGFUSE_PRIVACY_PRESETS = [
1146
1594
  "full-debug"
1147
1595
  ];
1148
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
+ }
1149
1601
  function ohConfigPath(root) {
1150
- const resolved = resolveConfigFile(root);
1151
- return resolved.generation === "agro" ? resolve3(root, GENERATIONS.agro.configFile) : resolve3(root, OH_CONFIG_FILE);
1602
+ return resolveProjectLayout(root).configFile;
1152
1603
  }
1153
1604
  function defaultOhConfig(name) {
1154
1605
  return {
@@ -1167,16 +1618,15 @@ function defaultOhConfig(name) {
1167
1618
  cron: { agentBin: "claude" },
1168
1619
  build: { skipPnpmInstall: false },
1169
1620
  image: { mode: "build", pullPolicy: "missing" },
1170
- cloud: {},
1171
1621
  langfuse: {},
1172
1622
  composeOverrides: []
1173
1623
  };
1174
1624
  }
1175
1625
  function readOhConfig(path4) {
1176
- if (!existsSync5(path4)) return defaultOhConfig(basename3(dirname2(resolve3(path4))));
1626
+ if (!existsSync6(path4)) return defaultOhConfig(basename4(dirname4(resolve4(path4))));
1177
1627
  let raw;
1178
1628
  try {
1179
- raw = readFileSync5(path4, "utf8");
1629
+ raw = readFileSync6(path4, "utf8");
1180
1630
  } catch (error) {
1181
1631
  const detail = error instanceof Error ? error.message : String(error);
1182
1632
  throw new Error(`could not read ${path4}: ${detail}`);
@@ -1185,24 +1635,24 @@ function readOhConfig(path4) {
1185
1635
  try {
1186
1636
  parsed = JSON.parse(raw);
1187
1637
  } catch {
1188
- throw new Error(`${OH_CONFIG_FILE} is not valid JSON: ${path4}`);
1638
+ throw new Error(`${basename4(path4)} is not valid JSON: ${path4}`);
1189
1639
  }
1190
1640
  return validateOhConfig(parsed);
1191
1641
  }
1192
1642
  function writeOhConfig(root, config) {
1193
1643
  const path4 = ohConfigPath(root);
1194
- assertInRoot(path4, resolve3(root));
1644
+ assertInRoot(path4, resolve4(root));
1195
1645
  const validated = validateOhConfig({ ...config, version: 1 });
1196
1646
  const body = `${JSON.stringify(validated, null, 2)}
1197
1647
  `;
1198
1648
  const tmp = `${path4}.tmp.${process.pid}`;
1199
- mkdirSync2(dirname2(path4), { recursive: true });
1649
+ mkdirSync3(dirname4(path4), { recursive: true });
1200
1650
  try {
1201
- writeFileSync2(tmp, body, { mode: OH_CONFIG_MODE, encoding: "utf8" });
1202
- renameSync2(tmp, path4);
1651
+ writeFileSync3(tmp, body, { mode: OH_CONFIG_MODE, encoding: "utf8" });
1652
+ renameSync3(tmp, path4);
1203
1653
  } catch (error) {
1204
1654
  try {
1205
- unlinkSync2(tmp);
1655
+ unlinkSync3(tmp);
1206
1656
  } catch {
1207
1657
  }
1208
1658
  throw error;
@@ -1216,6 +1666,7 @@ function validateOhConfig(value) {
1216
1666
  expectString(record, "name");
1217
1667
  expectEnum(record, "runtime", "", SANDBOX_RUNTIMES);
1218
1668
  expectString(record, "repo");
1669
+ expectString(record, "checkout");
1219
1670
  expectString(record, "timezone");
1220
1671
  const storage = expectSection(record, "storage");
1221
1672
  if (storage) {
@@ -1262,8 +1713,6 @@ function validateOhConfig(value) {
1262
1713
  expectEnum(image, "mode", "image.", ["build", "image"]);
1263
1714
  expectEnum(image, "pullPolicy", "image.", ["missing", "always", "never"]);
1264
1715
  }
1265
- const cloud = expectSection(record, "cloud");
1266
- if (cloud) expectString(cloud, "apiUrl", "cloud.");
1267
1716
  const langfuse = expectSection(record, "langfuse");
1268
1717
  if (langfuse) {
1269
1718
  expectString(langfuse, "baseUrl", "langfuse.");
@@ -1324,6 +1773,7 @@ var OH_CONFIG_FIELDS = [
1324
1773
  { path: "name", type: "string" },
1325
1774
  { path: "runtime", type: "enum", values: SANDBOX_RUNTIMES },
1326
1775
  { path: "repo", type: "string" },
1776
+ { path: "checkout", type: "string" },
1327
1777
  { path: "timezone", type: "string" },
1328
1778
  { path: "git.userName", type: "string" },
1329
1779
  { path: "git.userEmail", type: "string" },
@@ -1340,7 +1790,6 @@ var OH_CONFIG_FIELDS = [
1340
1790
  { path: "image.mode", type: "enum", values: ["build", "image"] },
1341
1791
  { path: "image.pullPolicy", type: "enum", values: ["missing", "always", "never"] },
1342
1792
  { path: "storage.homePath", type: "string" },
1343
- { path: "cloud.apiUrl", type: "string" },
1344
1793
  { path: "langfuse.baseUrl", type: "string" },
1345
1794
  { path: "langfuse.privacyPreset", type: "enum", values: LANGFUSE_PRIVACY_PRESETS },
1346
1795
  { path: "composeOverrides", type: "list" }
@@ -1401,649 +1850,231 @@ function coerceFieldValue(field, raw) {
1401
1850
  return raw;
1402
1851
  }
1403
1852
 
1404
- // src/lib/project.ts
1405
- import { dirname as dirname3, resolve as resolve4 } from "node:path";
1406
- function resolveProjectRoot(startDir = process.cwd()) {
1407
- let dir = resolve4(startDir);
1408
- for (; ; ) {
1409
- if (resolveControlDir(dir).kind !== "absent") return dir;
1410
- const parent = dirname3(dir);
1411
- if (parent === dir) {
1412
- throw new Error("not an OpenHarness-equipped repo \u2014 run `oh update` first");
1413
- }
1414
- dir = parent;
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}`);
1415
1857
  }
1416
1858
  }
1417
-
1418
- // src/lib/secrets.ts
1419
- import { existsSync as existsSync7, readFileSync as readFileSync7 } from "node:fs";
1420
- import { resolve as resolve5 } from "node:path";
1421
-
1422
- // src/lib/env.ts
1423
- import {
1424
- readFileSync as readFileSync6,
1425
- writeFileSync as writeFileSync3,
1426
- existsSync as existsSync6,
1427
- fsyncSync,
1428
- renameSync as renameSync3,
1429
- unlinkSync as unlinkSync3,
1430
- openSync,
1431
- closeSync
1432
- } from "node:fs";
1433
- function loadEnvInto(envPath, env) {
1434
- if (!existsSync6(envPath)) return;
1435
- const content = readFileSync6(envPath, "utf8");
1436
- for (const rawLine of content.split("\n")) {
1437
- const line = rawLine.trimEnd();
1438
- if (line === "" || /^\s*#/.test(line)) continue;
1439
- const eqIdx = line.indexOf("=");
1440
- if (eqIdx < 1) continue;
1441
- const key = line.slice(0, eqIdx).trim();
1442
- const value = line.slice(eqIdx + 1);
1443
- if (key === "") continue;
1444
- if (env[key] === void 0) env[key] = value;
1445
- }
1446
- }
1447
- function writeEnvFile(envPath, content) {
1448
- const tmpPath = `${envPath}.tmp.${process.pid}`;
1449
- let fd;
1450
- try {
1451
- fd = openSync(tmpPath, "w", 384);
1452
- writeFileSync3(tmpPath, content, "utf8");
1453
- fsyncSync(fd);
1454
- closeSync(fd);
1455
- fd = void 0;
1456
- renameSync3(tmpPath, envPath);
1457
- } catch (err) {
1458
- if (fd !== void 0) {
1459
- try {
1460
- closeSync(fd);
1461
- } catch {
1462
- }
1463
- }
1464
- try {
1465
- unlinkSync3(tmpPath);
1466
- } catch {
1467
- }
1468
- throw err;
1469
- }
1470
- }
1471
-
1472
- // src/lib/secrets.ts
1473
- var SECRETS_FILE = ".env";
1474
- var SECRET_KEYS = [
1475
- "GH_TOKEN",
1476
- "XAI_API_KEY",
1477
- "META_API_KEY",
1478
- "SANDBOX_PASSWORD",
1479
- "PI_SLACK_APP_TOKEN",
1480
- "PI_SLACK_BOT_TOKEN",
1481
- "LANGFUSE_PUBLIC_KEY",
1482
- "LANGFUSE_SECRET_KEY",
1483
- "OH_CLOUD_PROVISION_KEY"
1484
- ];
1485
- var ALLOWED = new Set(SECRET_KEYS);
1486
- function secretsFilePath(root) {
1487
- return resolve5(root, SECRETS_FILE);
1488
- }
1489
- function isSecretKey(key) {
1490
- return ALLOWED.has(key);
1491
- }
1492
- function assertSecretKey(key) {
1493
- if (ALLOWED.has(key)) return;
1494
- throw new Error(
1495
- `${key} is not a secret \u2014 non-secret settings live in oh.json; use \`oh config set\` instead`
1496
- );
1497
- }
1498
- function readSecret(root, key) {
1499
- assertSecretKey(key);
1500
- const file = secretsFilePath(root);
1501
- if (!existsSync7(file)) return void 0;
1502
- const env = {};
1503
- loadEnvInto(file, env);
1504
- const value = env[key]?.trim();
1505
- return value === void 0 || value === "" ? void 0 : stripQuotes2(value);
1506
- }
1507
- function setSecret(root, key, value) {
1508
- assertSecretKey(key);
1509
- if (/[\r\n]/.test(value)) {
1510
- throw new Error(`${key} value must not contain a newline`);
1511
- }
1512
- const file = secretsFilePath(root);
1513
- assertInRoot(file, resolve5(root));
1514
- const original = existsSync7(file) ? readFileSync7(file, "utf8") : "";
1515
- const { content } = setKeyInEnv(original, key, value);
1516
- writeEnvFile(file, content);
1517
- }
1518
- function listSecretKeys(root) {
1519
- const file = secretsFilePath(root);
1520
- if (!existsSync7(file)) return [];
1521
- const env = {};
1522
- loadEnvInto(file, env);
1523
- return SECRET_KEYS.filter((key) => {
1524
- const value = env[key]?.trim();
1525
- return value !== void 0 && value !== "";
1526
- });
1527
- }
1528
- function stripQuotes2(s) {
1859
+ function stripQuotes(s) {
1529
1860
  if (s.length >= 2 && (s[0] === '"' && s.endsWith('"') || s[0] === "'" && s.endsWith("'"))) {
1530
1861
  return s.slice(1, -1);
1531
1862
  }
1532
1863
  return s;
1533
1864
  }
1534
-
1535
- // src/commands/cloud.ts
1536
- var DEFAULT_API_URL = "http://127.0.0.1:3000";
1537
- var DEFAULT_TERMINAL_STATUSES = ["running", "failed", "destroyed"];
1538
- var PROVISION_KEY_SECRET = "OH_CLOUD_PROVISION_KEY";
1539
- var CLOUD_HELP = `oh cloud \u2014 Manage OpenHarness Cloud nodes
1540
-
1541
- Usage:
1542
- oh cloud config [--api-url <url>] [--provision-key <key>]
1543
- oh cloud config show
1544
- oh cloud [global options] nodes list
1545
- oh cloud [global options] nodes get <node-id>
1546
- oh cloud [global options] nodes create --ssh-key-id <id> [--name <name>]
1547
- oh cloud [global options] nodes events <node-id>
1548
- oh cloud [global options] nodes watch <node-id> [--interval <seconds>] [--until <statuses>]
1549
- oh cloud [global options] nodes destroy <node-id>
1550
- oh cloud [global options] nodes restart <node-id>
1551
- oh cloud [global options] nodes rebuild <node-id>
1552
- oh cloud [global options] ssh-keys list
1553
- oh cloud [global options] ssh-keys create --name <name> (--public-key <key> | --public-key-file <path>)
1554
-
1555
- Global options:
1556
- --api-url <url> API base URL (default: OH_CLOUD_API_URL, OH_API_URL,
1557
- oh.json cloud.apiUrl, or http://127.0.0.1:3000)
1558
- --provision-key <key> Admin key (default: OH_CLOUD_PROVISION_KEY in the
1559
- environment or in the root .env)
1560
- --output <field> Print one top-level response field instead of JSON
1561
- -h, --help Show this help
1562
-
1563
- Configuration:
1564
- oh cloud config writes cloud.apiUrl to the repository oh.json and securely
1565
- prompts for the current provisioner key, which it stores as
1566
- ${PROVISION_KEY_SECRET} in the gitignored root .env. This is the temporary
1567
- credential model until OpenHarness Cloud issues user API tokens.
1568
-
1569
- Both settings are repository-local, so oh cloud runs inside an
1570
- OpenHarness-equipped repo. Outside one, pass --api-url and --provision-key.
1571
-
1572
- Environment:
1573
- OH_CLOUD_API_URL Override the OpenHarness Cloud API base URL
1574
- OH_CLOUD_PROVISION_KEY Override the stored key (OH_PROVISION_KEY and
1575
- PROVISION_KEY are also accepted)
1576
-
1577
- Examples:
1578
- oh cloud config
1579
- oh cloud ssh-keys create --name laptop --public-key-file ~/.ssh/openharness_node.pub
1580
- oh cloud nodes create --name smoke-1 --ssh-key-id <ssh-key-id>
1581
- oh cloud nodes watch <node-id>
1582
- `;
1583
- var CloudCliError = class extends Error {
1584
- status;
1585
- payload;
1586
- constructor(message, options = {}) {
1587
- super(message);
1588
- this.name = "CloudCliError";
1589
- this.status = options.status;
1590
- this.payload = options.payload;
1591
- }
1865
+ var CONFIG_FIELD_BY_ENV_KEY = {
1866
+ DOCKER_SOCKET: "access.dockerSocket"
1592
1867
  };
1593
- function takeOption(args, name, alias) {
1594
- const names = alias ? [name, alias] : [name];
1595
- for (const candidate of names) {
1596
- const index = args.indexOf(candidate);
1597
- if (index === -1) continue;
1598
- const value = args[index + 1];
1599
- if (value === void 0 || value.startsWith("-")) {
1600
- throw new CloudCliError(`${candidate} requires a value`);
1601
- }
1602
- args.splice(index, 2);
1603
- return value;
1604
- }
1605
- return void 0;
1606
- }
1607
- function requireOption(value, name) {
1608
- if (!value) throw new CloudCliError(`${name} is required`);
1609
- return value;
1610
- }
1611
- function requirePositional(value, name) {
1612
- if (!value || value.startsWith("-")) throw new CloudCliError(`${name} is required`);
1613
- return value;
1614
- }
1615
- function assertNoExtraArgs(args) {
1616
- if (args.length > 0) {
1617
- throw new CloudCliError(
1618
- `unexpected argument${args.length === 1 ? "" : "s"}: ${args.join(" ")}`
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`
1619
1873
  );
1620
1874
  }
1875
+ return setConfigField(root, path4, value);
1621
1876
  }
1622
- function normalizeApiUrl(value) {
1623
- let url;
1624
- try {
1625
- url = new URL(value);
1626
- } catch {
1627
- throw new CloudCliError(`invalid API URL: ${value}`);
1628
- }
1629
- if (url.protocol !== "http:" && url.protocol !== "https:") {
1630
- throw new CloudCliError(`API URL must use http or https: ${value}`);
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";
1631
1886
  }
1632
- return url.toString().replace(/\/$/, "");
1633
- }
1634
- function formatJson(value) {
1635
- return `${JSON.stringify(value, null, 2)}
1636
- `;
1637
- }
1638
- function bodyPreview(body) {
1639
- const compact = body.replace(/\s+/g, " ").trim();
1640
- return compact.length > 180 ? `${compact.slice(0, 177)}...` : compact;
1641
- }
1642
- function responseKind(contentType) {
1643
- return contentType?.split(";", 1)[0]?.trim() || "non-JSON content";
1887
+ writeOhConfig(root, next);
1888
+ return before === void 0 ? "added" : "updated";
1644
1889
  }
1645
- function formatApiFailure(status, payload, contentType) {
1646
- if (payload && typeof payload === "object") {
1647
- const record = payload;
1648
- const message = record.error ?? record.message;
1649
- const code = typeof record.code === "string" ? ` (${record.code})` : "";
1650
- if (typeof message === "string") {
1651
- return `API request failed with ${status}${code}: ${message}`;
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" };
1652
1900
  }
1653
1901
  }
1654
- if (typeof payload === "string" && payload.length > 0) {
1655
- return `API request failed with ${status}: server returned ${responseKind(contentType)} instead of JSON (${bodyPreview(payload)})`;
1656
- }
1657
- return `API request failed with ${status}`;
1658
- }
1659
- async function parseResponse(response) {
1660
- const text = await response.text();
1661
- const contentType = response.headers.get("content-type");
1662
- if (!text) return { payload: null, isJson: true, contentType };
1663
- try {
1664
- return { payload: JSON.parse(text), isJson: true, contentType };
1665
- } catch {
1666
- return { payload: text, isJson: false, contentType };
1667
- }
1668
- }
1669
- function createCloudApiClient(options) {
1670
- const fetchImpl = options.fetch ?? globalThis.fetch;
1671
- if (typeof fetchImpl !== "function") {
1672
- throw new CloudCliError(
1673
- "the cloud CLI requires Node.js 20 or newer (global fetch is unavailable)"
1674
- );
1675
- }
1676
- return async (method, pathname, body) => {
1677
- let response;
1678
- try {
1679
- response = await fetchImpl(`${options.apiUrl}${pathname}`, {
1680
- method,
1681
- headers: {
1682
- "x-provision-key": options.provisionKey,
1683
- ...body === void 0 ? {} : { "content-type": "application/json" }
1684
- },
1685
- ...body === void 0 ? {} : { body: JSON.stringify(body) }
1686
- });
1687
- } catch (error) {
1688
- const detail = error instanceof Error ? error.message : String(error);
1689
- throw new CloudCliError(`could not reach ${options.apiUrl}: ${detail}`);
1690
- }
1691
- const parsed = await parseResponse(response);
1692
- if (!response.ok) {
1693
- throw new CloudCliError(
1694
- formatApiFailure(response.status, parsed.payload, parsed.contentType),
1695
- {
1696
- status: response.status,
1697
- payload: parsed.payload
1698
- }
1699
- );
1700
- }
1701
- if (!parsed.isJson) {
1702
- throw new CloudCliError(
1703
- `API returned ${response.status} ${responseKind(parsed.contentType)}; expected JSON (${bodyPreview(parsed.payload)})`,
1704
- { status: response.status, payload: parsed.payload }
1705
- );
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" };
1706
1906
  }
1707
- return parsed.payload;
1907
+ }
1908
+ const body = content.replace(/\n+$/, "");
1909
+ return {
1910
+ content: body === "" ? `${key}=${value}
1911
+ ` : `${body}
1912
+ ${key}=${value}
1913
+ `,
1914
+ outcome: "added"
1708
1915
  };
1709
1916
  }
1710
- function nodePath(nodeId, suffix = "") {
1711
- return `/api/nodes/${encodeURIComponent(nodeId)}${suffix}`;
1712
- }
1713
- async function readPublicKey(args, readFileImpl) {
1714
- const inline = takeOption(args, "--public-key");
1715
- const file = takeOption(args, "--public-key-file");
1716
- if (inline && file) {
1717
- throw new CloudCliError("use only one of --public-key or --public-key-file");
1718
- }
1719
- if (!inline && !file) {
1720
- throw new CloudCliError("--public-key or --public-key-file is required");
1721
- }
1722
- if (inline) return inline.trim();
1723
- try {
1724
- const value = (await readFileImpl(file, "utf8")).trim();
1725
- if (!value) throw new CloudCliError(`SSH public key file is empty: ${file}`);
1726
- return value;
1727
- } catch (error) {
1728
- if (error instanceof CloudCliError) throw error;
1729
- const detail = error instanceof Error ? error.message : String(error);
1730
- throw new CloudCliError(`could not read SSH public key file ${file}: ${detail}`);
1731
- }
1917
+ function escapeRegExp(s) {
1918
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1732
1919
  }
1733
- async function dispatch(args, options) {
1734
- const resource = args.shift();
1735
- const action = args.shift();
1736
- if (!resource || !action) {
1737
- throw new CloudCliError("a resource and action are required. Run `oh cloud --help` for usage.");
1738
- }
1739
- if (resource === "ssh-keys") {
1740
- if (action === "list") {
1741
- assertNoExtraArgs(args);
1742
- return options.request("GET", "/api/ssh-keys");
1743
- }
1744
- if (action === "create") {
1745
- const name = requireOption(takeOption(args, "--name"), "--name");
1746
- const publicKey = await readPublicKey(args, options.readFile);
1747
- assertNoExtraArgs(args);
1748
- return options.request("POST", "/api/ssh-keys", { name, publicKey });
1749
- }
1750
- throw new CloudCliError(`unknown ssh-keys action: ${action}`);
1751
- }
1752
- if (resource !== "nodes") throw new CloudCliError(`unknown resource: ${resource}`);
1753
- if (action === "list") {
1754
- assertNoExtraArgs(args);
1755
- return options.request("GET", "/api/nodes");
1756
- }
1757
- if (action === "create") {
1758
- const name = takeOption(args, "--name");
1759
- const sshKeyId = requireOption(takeOption(args, "--ssh-key-id"), "--ssh-key-id");
1760
- assertNoExtraArgs(args);
1761
- return options.request("POST", "/api/nodes", {
1762
- ...name ? { name } : {},
1763
- sshKeyId
1764
- });
1765
- }
1766
- const nodeIdActions = ["get", "events", "destroy", "restart", "rebuild", "watch"];
1767
- if (!nodeIdActions.includes(action)) {
1768
- throw new CloudCliError(`unknown nodes action: ${action}`);
1769
- }
1770
- const nodeId = requirePositional(args.shift(), "<node-id>");
1771
- if (action === "get") {
1772
- assertNoExtraArgs(args);
1773
- return options.request("GET", nodePath(nodeId));
1774
- }
1775
- if (action === "events") {
1776
- assertNoExtraArgs(args);
1777
- return options.request("GET", nodePath(nodeId, "/events"));
1778
- }
1779
- if (action === "destroy") {
1780
- assertNoExtraArgs(args);
1781
- return options.request("DELETE", nodePath(nodeId));
1782
- }
1783
- if (action === "restart" || action === "rebuild") {
1784
- assertNoExtraArgs(args);
1785
- return options.request("POST", nodePath(nodeId, `/${action}`));
1786
- }
1787
- if (action === "watch") {
1788
- const intervalValue = takeOption(args, "--interval") ?? "5";
1789
- const intervalSeconds = Number(intervalValue);
1790
- if (!Number.isFinite(intervalSeconds) || intervalSeconds < 0) {
1791
- throw new CloudCliError(`--interval must be a non-negative number: ${intervalValue}`);
1792
- }
1793
- const untilValue = takeOption(args, "--until");
1794
- const terminalStatuses = new Set(
1795
- (untilValue ? untilValue.split(",") : DEFAULT_TERMINAL_STATUSES).map((status) => status.trim()).filter(Boolean)
1796
- );
1797
- if (terminalStatuses.size === 0) {
1798
- throw new CloudCliError("--until must contain at least one status");
1799
- }
1800
- assertNoExtraArgs(args);
1801
- let lastStatus;
1802
- while (true) {
1803
- const node = await options.request("GET", nodePath(nodeId));
1804
- if (!node || typeof node !== "object" || typeof node.status !== "string") {
1805
- throw new CloudCliError("node response did not include a status");
1806
- }
1807
- const status = node.status;
1808
- if (status !== lastStatus) {
1809
- options.write(node);
1810
- lastStatus = status;
1811
- }
1812
- if (terminalStatuses.has(status)) return void 0;
1813
- await options.sleep(intervalSeconds * 1e3);
1920
+
1921
+ // src/lib/project.ts
1922
+ import { dirname as dirname5, resolve as resolve6 } from "node:path";
1923
+ function resolveProjectRoot(startDir = process.cwd()) {
1924
+ let dir = resolve6(startDir);
1925
+ for (; ; ) {
1926
+ if (resolveControlDir(dir).kind !== "absent") return dir;
1927
+ const parent = dirname5(dir);
1928
+ if (parent === dir) {
1929
+ throw new Error(`not an OpenHarness-equipped repo \u2014 run \`${activeBin()} update\` first`);
1814
1930
  }
1931
+ dir = parent;
1815
1932
  }
1816
- throw new CloudCliError(`unknown nodes action: ${action}`);
1817
- }
1818
- var OUTSIDE_REPO_HINT = "; pass --api-url and --provision-key to run `oh cloud` outside a repo";
1819
- function requireRoot(io) {
1820
- try {
1821
- return resolveProjectRoot(io.cwd);
1822
- } catch (error) {
1823
- const detail = error instanceof Error ? error.message : String(error);
1824
- throw new CloudCliError(`${detail}${OUTSIDE_REPO_HINT}`);
1825
- }
1826
- }
1827
- function legacyCloudConfigPath(env) {
1828
- const home = env.HOME ?? env.USERPROFILE;
1829
- if (!home) return void 0;
1830
- return join3(home, ".config", "openharness", "cloud.json");
1831
- }
1832
- function readLegacyCloudConfig(path4) {
1833
- let parsed;
1834
- try {
1835
- parsed = JSON.parse(readFileSync8(path4, "utf8"));
1836
- } catch {
1837
- return void 0;
1838
- }
1839
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
1840
- const record = parsed;
1841
- return {
1842
- ...typeof record.apiUrl === "string" ? { apiUrl: record.apiUrl } : {},
1843
- ...typeof record.provisionKey === "string" ? { provisionKey: record.provisionKey } : {}
1844
- };
1845
1933
  }
1846
- function migrateLegacyCloudConfig(root, io, env) {
1847
- const path4 = legacyCloudConfigPath(env);
1848
- if (!path4 || !existsSync8(path4)) return;
1849
- const config = readOhConfig(ohConfigPath(root));
1850
- if (config.cloud?.apiUrl !== void 0) return;
1851
- if (readSecret(root, PROVISION_KEY_SECRET) !== void 0) return;
1852
- const legacy = readLegacyCloudConfig(path4);
1853
- if (!legacy || !legacy.apiUrl && !legacy.provisionKey) return;
1854
- if (legacy.apiUrl) {
1855
- const next = {
1856
- ...config,
1857
- cloud: { ...config.cloud, apiUrl: normalizeApiUrl(legacy.apiUrl) }
1858
- };
1859
- writeOhConfig(root, next);
1860
- }
1861
- if (legacy.provisionKey) setSecret(root, PROVISION_KEY_SECRET, legacy.provisionKey);
1862
- io.stdout(`Migrated OpenHarness Cloud settings out of ${path4}
1863
- `);
1864
- if (legacy.apiUrl) io.stdout(` cloud.apiUrl -> ${ohConfigPath(root)}
1865
- `);
1866
- if (legacy.provisionKey) io.stdout(` ${PROVISION_KEY_SECRET} -> ${join3(root, ".env")}
1867
- `);
1868
- io.stdout(`${path4} is no longer read; delete it when you no longer need the copy.
1934
+
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}
1949
+
1869
1950
  `);
1870
1951
  }
1871
- function loadCloudSettings(root, io, env) {
1872
- migrateLegacyCloudConfig(root, io, env);
1873
- const config = readOhConfig(ohConfigPath(root));
1874
- return {
1875
- ...config.cloud?.apiUrl ? { apiUrl: config.cloud.apiUrl } : {},
1876
- ...(() => {
1877
- const key = readSecret(root, PROVISION_KEY_SECRET);
1878
- return key ? { provisionKey: key } : {};
1879
- })()
1880
- };
1952
+ function bold(s) {
1953
+ return `${COLORS.bold}${s}${COLORS.reset}`;
1881
1954
  }
1882
- function envProvisionKey(env) {
1883
- return env.OH_CLOUD_PROVISION_KEY ?? env.OH_PROVISION_KEY ?? env.PROVISION_KEY;
1955
+ function redact(token) {
1956
+ if (token.length <= 8) return "****";
1957
+ return token.slice(0, 5) + "*".repeat(token.length - 5);
1884
1958
  }
1885
- async function configureCloud(args, io, env) {
1886
- const root = requireRoot(io);
1887
- const current = loadCloudSettings(root, io, env);
1888
- if (args[0] === "show") {
1889
- args.shift();
1890
- assertNoExtraArgs(args);
1891
- io.stdout(`Config: ${ohConfigPath(root)}
1892
- `);
1893
- io.stdout(`Secrets: ${join3(root, ".env")}
1894
- `);
1895
- io.stdout(`API URL: ${current.apiUrl ?? DEFAULT_API_URL}
1896
- `);
1897
- io.stdout(
1898
- `Provision credential: ${current.provisionKey ? redact(current.provisionKey) : "not configured"}
1899
- `
1900
- );
1901
- return;
1959
+ async function ask(question) {
1960
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1961
+ try {
1962
+ return await new Promise((resolve11) => {
1963
+ rl.question(` ${question} `, (answer) => resolve11(answer.trim()));
1964
+ });
1965
+ } finally {
1966
+ rl.close();
1902
1967
  }
1903
- const apiUrl = normalizeApiUrl(
1904
- takeOption(args, "--api-url") ?? current.apiUrl ?? env.OH_CLOUD_API_URL ?? env.OH_API_URL ?? DEFAULT_API_URL
1905
- );
1906
- const providedKey = takeOption(args, "--provision-key");
1907
- assertNoExtraArgs(args);
1908
- const provisionKey = (providedKey ?? await (io.askSecret ?? askSecret)("OpenHarness Cloud provisioner key:")).trim();
1909
- if (!provisionKey) throw new CloudCliError("provisioner key cannot be empty");
1910
- const config = readOhConfig(ohConfigPath(root));
1911
- writeOhConfig(root, { ...config, cloud: { ...config.cloud, apiUrl } });
1912
- setSecret(root, PROVISION_KEY_SECRET, provisionKey);
1913
- io.stdout(`Saved cloud.apiUrl to ${ohConfigPath(root)}
1914
- `);
1915
- io.stdout(`Saved ${PROVISION_KEY_SECRET} to ${join3(root, ".env")}
1916
- `);
1917
- io.stdout("Provision credential: configured\n");
1918
1968
  }
1919
- async function executeCloud(argv, io) {
1920
- const args = [...argv];
1921
- const env = io.env ?? process.env;
1922
- if (args.includes("--help") || args.includes("-h") || args[0] === "help") {
1923
- io.stdout(CLOUD_HELP);
1924
- return;
1925
- }
1926
- if (args[0] === "config") {
1927
- args.shift();
1928
- await configureCloud(args, io, env);
1929
- return;
1930
- }
1931
- const flagApiUrl = takeOption(args, "--api-url");
1932
- const flagProvisionKey = takeOption(args, "--provision-key");
1933
- const stored = flagProvisionKey && flagApiUrl ? {} : loadCloudSettings(requireRoot(io), io, env);
1934
- const apiUrl = normalizeApiUrl(
1935
- flagApiUrl ?? env.OH_CLOUD_API_URL ?? env.OH_API_URL ?? stored.apiUrl ?? DEFAULT_API_URL
1936
- );
1937
- const provisionKey = flagProvisionKey ?? envProvisionKey(env) ?? stored.provisionKey;
1938
- if (!provisionKey) {
1939
- throw new CloudCliError(
1940
- `no provision credential found. Run \`oh cloud config\`, set ${PROVISION_KEY_SECRET}, or pass --api-url and --provision-key.`
1941
- );
1942
- }
1943
- const outputField = takeOption(args, "--output");
1944
- const request = createCloudApiClient({
1945
- apiUrl,
1946
- provisionKey,
1947
- fetch: io.fetch
1948
- });
1949
- const write = (value) => io.stdout(formatJson(value));
1950
- const result = await dispatch(args, {
1951
- request,
1952
- readFile: io.readFile ?? readFile,
1953
- sleep: io.sleep ?? ((milliseconds) => new Promise((resolve9) => setTimeout(resolve9, milliseconds))),
1954
- write
1955
- });
1956
- if (result === void 0) return;
1957
- if (outputField) {
1958
- if (!result || typeof result !== "object" || !(outputField in result)) {
1959
- throw new CloudCliError(`response does not contain top-level field: ${outputField}`);
1960
- }
1961
- const value = result[outputField];
1962
- io.stdout(typeof value === "object" ? formatJson(value) : `${String(value)}
1963
- `);
1964
- return;
1965
- }
1966
- write(result);
1967
- }
1968
- async function runCloud(argv, io) {
1969
- try {
1970
- await executeCloud(argv, io);
1971
- return 0;
1972
- } catch (error) {
1973
- const message = error instanceof Error ? error.message : String(error);
1974
- io.stderr(`oh cloud: ${message}
1975
- `);
1976
- return 1;
1977
- }
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 = () => {
1977
+ try {
1978
+ process.stdin.setRawMode(false);
1979
+ } catch {
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
+ });
1978
2013
  }
1979
-
1980
- // src/lib/execution/runner.ts
1981
- import { spawnSync as spawnSync2 } from "node:child_process";
1982
- import { existsSync as existsSync9 } from "node:fs";
1983
- import { join as join4 } from "node:path";
1984
- var spawnRunner = (cmd, args, opts) => {
1985
- const common = {
1986
- env: opts.env,
1987
- ...opts.cwd !== void 0 ? { cwd: opts.cwd } : {},
1988
- ...opts.timeoutMs !== void 0 ? { timeout: opts.timeoutMs } : {}
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);
2022
+ }
2023
+ return true;
1989
2024
  };
1990
- const r = opts.stdio === "capture" ? spawnSync2(cmd, args, { ...common, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }) : spawnSync2(cmd, args, { ...common, stdio: "inherit" });
1991
- const err = r.error;
1992
- return {
1993
- status: r.status,
1994
- error: err ? { code: err.code, message: err.message } : void 0,
1995
- stdout: typeof r.stdout === "string" ? r.stdout : void 0,
1996
- stderr: typeof r.stderr === "string" ? r.stderr : void 0
2025
+ process.stdout.write = patched;
2026
+ let restored = false;
2027
+ const restore = () => {
2028
+ if (restored) return;
2029
+ restored = true;
2030
+ process.stdout.write = stdoutWrite;
2031
+ try {
2032
+ rl.close();
2033
+ } catch {
2034
+ }
1997
2035
  };
1998
- };
1999
- var ExecutionSpawnError = class extends Error {
2000
- code;
2001
- constructor(what, error) {
2002
- super(`failed to run ${what}${error?.message ? ` (${error.message})` : ""}`);
2003
- this.name = "ExecutionSpawnError";
2004
- this.code = error?.code;
2005
- }
2006
- };
2007
- var ExecutionExitError = class extends Error {
2008
- exitCode;
2009
- constructor(what, exitCode) {
2010
- super(`${what} exited ${exitCode}`);
2011
- this.name = "ExecutionExitError";
2012
- this.exitCode = exitCode;
2013
- }
2014
- };
2015
- function assertSpawned(r, what) {
2016
- if (r.error) {
2017
- throw new ExecutionSpawnError(what, r.error);
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();
2018
2052
  }
2019
2053
  }
2020
- function requireLifecycleScript(root, rel) {
2021
- const script = join4(root, ".oh", "scripts", rel);
2022
- if (!existsSync9(script)) {
2023
- throw new Error(
2024
- `missing lifecycle script ${script} \u2014 the vendored .oh/ payload looks incomplete; run \`oh update\` to re-vendor it`
2025
- );
2026
- }
2027
- return script;
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);
2028
2059
  }
2029
2060
 
2030
2061
  // src/lib/registry.ts
2031
- import { existsSync as existsSync10, mkdirSync as mkdirSync3, readdirSync as readdirSync3, statSync as statSync4, writeFileSync as writeFileSync4 } from "node:fs";
2032
- import { dirname as dirname4, join as join5, resolve as resolve6, sep as sep2 } 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";
2033
2064
 
2034
- // oh-asset:/home/runner/work/openharness/openharness/.devcontainer/docker-compose.yml
2035
- var docker_compose_default = 'name: ${SANDBOX_NAME:-openharness}\n\nservices:\n sandbox:\n image: ${OH_SANDBOX_IMAGE:-sandbox-${SANDBOX_NAME:-openharness}}\n pull_policy: ${OH_PULL_POLICY:-missing}\n container_name: ${SANDBOX_NAME:-openharness}\n build:\n context: ${OH_REPO_DIR:-..}\n dockerfile: .devcontainer/Dockerfile\n volumes:\n - ${OH_HOME_MOUNT:-workspace}:/home/sandbox\n - ${OH_REPO_DIR:-..}:/home/sandbox/harness\n extra_hosts:\n - "host.docker.internal:host-gateway"\n environment:\n - SANDBOX_NAME=${SANDBOX_NAME:-openharness}\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", "/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';
2065
+ // oh-asset:/home/runner/work/agro/agro/.devcontainer/docker-compose.yml
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';
2036
2067
 
2037
- // oh-asset:/home/runner/work/openharness/openharness/.devcontainer/docker-compose.image-only.yml
2038
- var docker_compose_image_only_default = 'name: ${SANDBOX_NAME:-openharness}\n\nservices:\n sandbox:\n image: ${OH_SANDBOX_IMAGE:-ghcr.io/mifunedev/openharness:latest}\n pull_policy: ${OH_PULL_POLICY:-always}\n container_name: ${SANDBOX_NAME:-openharness}\n volumes:\n - ${OH_HOME_MOUNT:-workspace}:/home/sandbox\n extra_hosts:\n - "host.docker.internal:host-gateway"\n environment:\n - SANDBOX_NAME=${SANDBOX_NAME:-openharness}\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", "/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';
2068
+ // oh-asset:/home/runner/work/agro/agro/.devcontainer/docker-compose.image-only.yml
2069
+ var docker_compose_image_only_default = 'name: ${SANDBOX_NAME:-agro}\n\nservices:\n sandbox:\n image: ${AGRO_SANDBOX_IMAGE:-${OH_SANDBOX_IMAGE:-ghcr.io/mifunedev/agro:latest}}\n pull_policy: ${AGRO_PULL_POLICY:-${OH_PULL_POLICY:-always}}\n container_name: ${SANDBOX_NAME:-agro}\n volumes:\n - ${AGRO_HOME_MOUNT:-${OH_HOME_MOUNT:-workspace}}:/home/sandbox\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';
2039
2070
 
2040
- // oh-asset:/home/runner/work/openharness/openharness/.devcontainer/docker-compose.ssh.yml
2071
+ // oh-asset:/home/runner/work/agro/agro/.devcontainer/docker-compose.ssh.yml
2041
2072
  var docker_compose_ssh_default = 'services:\n sandbox:\n ports:\n - "127.0.0.1:${SANDBOX_SSH_PORT:-2222}:22"\n';
2042
2073
 
2043
- // oh-asset:/home/runner/work/openharness/openharness/.devcontainer/docker-compose.docker-sock.yml
2074
+ // oh-asset:/home/runner/work/agro/agro/.devcontainer/docker-compose.docker-sock.yml
2044
2075
  var docker_compose_docker_sock_default = "services:\n sandbox:\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n";
2045
2076
 
2046
- // oh-asset:/home/runner/work/openharness/openharness/.oh/scripts/docker-compose.sh
2077
+ // oh-asset:/home/runner/work/agro/agro/.agro/scripts/docker-compose.sh
2047
2078
  var docker_compose_default2 = `#!/usr/bin/env bash
2048
2079
 
2049
2080
  set -euo pipefail
@@ -2059,10 +2090,10 @@ Usage: scripts/docker-compose.sh [--repo-dir DIR] [--extra-env-file FILE]
2059
2090
  [--print-argv] <docker-compose-args...>
2060
2091
 
2061
2092
  Builds the harness docker compose argv from the repository dotenv and the
2062
- composeOverrides[] list in oh.json, then executes \`docker compose ...\` with the
2093
+ composeOverrides[] list in agro.json, then executes \`docker compose ...\` with the
2063
2094
  provided args.
2064
2095
  --extra-env-file adds a lower-precedence --env-file ahead of the dotenv; \`oh\`
2065
- renders the non-secret settings of oh.json into that file.
2096
+ renders the non-secret settings of agro.json into that file.
2066
2097
  --print-argv prints one argv entry per line instead of executing; useful for
2067
2098
  safe diagnostics and tests.
2068
2099
  EOF
@@ -2102,7 +2133,7 @@ done
2102
2133
 
2103
2134
  COMPAT="$SCRIPT_DIR/compat.sh"
2104
2135
  if [ ! -f "$COMPAT" ]; then
2105
- printf 'error: %s is missing \u2014 the vendored .oh/scripts payload is incomplete; run \`oh update\`\\n' "$COMPAT" >&2
2136
+ printf 'error: %s is missing \u2014 the vendored .agro/scripts payload is incomplete; run \`oh update\`\\n' "$COMPAT" >&2
2106
2137
  exit 2
2107
2138
  fi
2108
2139
  # shellcheck source=compat.sh
@@ -2166,7 +2197,7 @@ fi
2166
2197
 
2167
2198
  CONFIG_JSON="$(compat_selected_path compat_config_file "$REPO_DIR")" || exit 2
2168
2199
 
2169
- if [ -z "$EXTRA_ENV_FILE" ] && [ -n "$CONFIG_JSON" ]; then
2200
+ if [ -z "$EXTRA_ENV_FILE" ] && [ -f "$CONFIG_JSON" ]; then
2170
2201
  printf 'note: non-secret config comes from %s via \`oh\`; this direct run uses only %s and the compose-file defaults\\n' \\
2171
2202
  "\${CONFIG_JSON#"$REPO_DIR"/}" "\${ENV_FILE#"$REPO_DIR"/}" >&2
2172
2203
  fi
@@ -2199,7 +2230,7 @@ if truthy "$ssh_value"; then
2199
2230
  port_check="$SCRIPT_DIR/check-host-port.sh"
2200
2231
  if [ -x "$port_check" ] || [ -f "$port_check" ]; then
2201
2232
  sandbox_name=\${SANDBOX_NAME:-$(read_env_value SANDBOX_NAME)}
2202
- [ -n "$sandbox_name" ] || sandbox_name=openharness
2233
+ sandbox_name="$(compat_sandbox_name "$sandbox_name")"
2203
2234
  own_port=0
2204
2235
  if command -v docker >/dev/null 2>&1; then
2205
2236
  docker ps --format '{{.Names}}\\t{{.Ports}}' 2>/dev/null \\
@@ -2219,7 +2250,7 @@ if truthy "$ssh_value"; then
2219
2250
  fi
2220
2251
  fi
2221
2252
 
2222
- [ -n "$CONFIG_JSON" ] || CONFIG_JSON="$REPO_DIR/.oh/config.json"
2253
+ [ -f "$CONFIG_JSON" ] || CONFIG_JSON="$REPO_DIR/.agro/config.json"
2223
2254
  [ -f "$CONFIG_JSON" ] || CONFIG_JSON="$REPO_DIR/config.json"
2224
2255
  if command -v jq >/dev/null 2>&1 && [ -f "$CONFIG_JSON" ]; then
2225
2256
  while IFS= read -r override; do
@@ -2235,11 +2266,11 @@ fi
2235
2266
  exec docker compose "\${args[@]}" "$@"
2236
2267
  `;
2237
2268
 
2238
- // oh-asset:/home/runner/work/openharness/openharness/.oh/scripts/compat.sh
2269
+ // oh-asset:/home/runner/work/agro/agro/.agro/scripts/compat.sh
2239
2270
  var compat_default = `#!/usr/bin/env bash
2240
2271
  # Boot-safe dual-generation resolution: bash + coreutils only, no node, no jq.
2241
- # The TypeScript contract is .oh/cli/src/lib/compat.ts; both consume the same
2242
- # vectors in .oh/cli/src/lib/__tests__/fixtures/compat-vectors.json.
2272
+ # The TypeScript contract is .agro/cli/src/lib/compat.ts; both consume the same
2273
+ # vectors in .agro/cli/src/lib/__tests__/fixtures/compat-vectors.json.
2243
2274
 
2244
2275
  COMPAT_LEGACY_CONTROL_DIR=".oh"
2245
2276
  COMPAT_AGRO_CONTROL_DIR=".agro"
@@ -2247,6 +2278,8 @@ COMPAT_LEGACY_CONFIG_FILE="oh.json"
2247
2278
  COMPAT_AGRO_CONFIG_FILE="agro.json"
2248
2279
  COMPAT_LEGACY_SEED_DIR="/opt/oh-seed"
2249
2280
  COMPAT_AGRO_SEED_DIR="/opt/agro-seed"
2281
+ COMPAT_DEFAULT_GENERATION=agro
2282
+ COMPAT_DEFAULT_SANDBOX_NAME=agro
2250
2283
  COMPAT_CONFLICT_STATUS=3
2251
2284
 
2252
2285
  compat_tree_manifest() {
@@ -2294,6 +2327,14 @@ compat_tree_diff() {
2294
2327
  return "$rc"
2295
2328
  }
2296
2329
 
2330
+ compat_sandbox_name() {
2331
+ if [ -n "\${1:-}" ]; then
2332
+ printf '%s\\n' "$1"
2333
+ else
2334
+ printf '%s\\n' "$COMPAT_DEFAULT_SANDBOX_NAME"
2335
+ fi
2336
+ }
2337
+
2297
2338
  compat_present() {
2298
2339
  case "$1" in
2299
2340
  dir) [ -d "$2" ] ;;
@@ -2308,7 +2349,11 @@ compat_resolve_pair() {
2308
2349
  compat_present "$entry_kind" "$legacy" || legacy_present=0
2309
2350
  compat_present "$entry_kind" "$agro" || agro_present=0
2310
2351
  if [ "$legacy_present" = 0 ] && [ "$agro_present" = 0 ]; then
2311
- printf 'absent\\t\\n'
2352
+ if [ "$COMPAT_DEFAULT_GENERATION" = agro ]; then
2353
+ printf 'absent\\t%s\\n' "$agro"
2354
+ else
2355
+ printf 'absent\\t%s\\n' "$legacy"
2356
+ fi
2312
2357
  return 0
2313
2358
  fi
2314
2359
  if [ "$legacy_present" = 1 ] && [ "$agro_present" = 0 ]; then
@@ -2374,10 +2419,10 @@ compat_seed_src() {
2374
2419
  printf '%s\\n' "$configured"
2375
2420
  return 0
2376
2421
  fi
2377
- if [ -d "$prefix$COMPAT_AGRO_SEED_DIR" ] && [ ! -d "$prefix$COMPAT_LEGACY_SEED_DIR" ]; then
2378
- printf '%s\\n' "$prefix$COMPAT_AGRO_SEED_DIR"
2379
- else
2422
+ if [ -d "$prefix$COMPAT_LEGACY_SEED_DIR" ]; then
2380
2423
  printf '%s\\n' "$prefix$COMPAT_LEGACY_SEED_DIR"
2424
+ else
2425
+ printf '%s\\n' "$prefix$COMPAT_AGRO_SEED_DIR"
2381
2426
  fi
2382
2427
  }
2383
2428
 
@@ -2385,12 +2430,11 @@ compat_marker_file() {
2385
2430
  local root="$1" line dir
2386
2431
  line="$(compat_control_dir "$root")" || return $?
2387
2432
  dir="\${line#* }"
2388
- [ -n "$dir" ] || return 1
2389
2433
  printf '%s\\n' "$dir/.image-seeded"
2390
2434
  }
2391
2435
  `;
2392
2436
 
2393
- // oh-asset:/home/runner/work/openharness/openharness/.oh/scripts/check-host-port.sh
2437
+ // oh-asset:/home/runner/work/agro/agro/.agro/scripts/check-host-port.sh
2394
2438
  var check_host_port_default = `#!/usr/bin/env bash
2395
2439
 
2396
2440
  set -eu
@@ -2476,7 +2520,7 @@ exit 0
2476
2520
 
2477
2521
  // src/lib/registry.ts
2478
2522
  var SANDBOX_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
2479
- var DEFAULT_NAME_PREFIX = "oh-sbx-";
2523
+ var DEFAULT_NAME_PREFIX = "agro-sbx-";
2480
2524
  function ohHome() {
2481
2525
  return resolveUserStateHome(process.env);
2482
2526
  }
@@ -2495,17 +2539,17 @@ function entryRoot(name) {
2495
2539
  return join5(registryRoot(), name);
2496
2540
  }
2497
2541
  function isDirectory(path4) {
2498
- return statSync4(path4, { throwIfNoEntry: false })?.isDirectory() === true;
2542
+ return statSync3(path4, { throwIfNoEntry: false })?.isDirectory() === true;
2499
2543
  }
2500
2544
  function listEntries() {
2501
2545
  const root = registryRoot();
2502
2546
  if (!isDirectory(root)) return [];
2503
- return readdirSync3(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).filter((name) => SANDBOX_NAME_PATTERN.test(name)).filter((name) => existsSync10(ohConfigPath(join5(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();
2504
2548
  }
2505
2549
  function entryRepo(name) {
2506
2550
  const config = readOhConfig(ohConfigPath(entryRoot(name)));
2507
- const repo = config.repo;
2508
- return typeof repo === "string" && repo !== "" ? resolve6(repo) : void 0;
2551
+ const checkout = configCheckout(config);
2552
+ return checkout === void 0 ? void 0 : resolve7(checkout);
2509
2553
  }
2510
2554
  function runningContainerNames(run) {
2511
2555
  const names = /* @__PURE__ */ new Set();
@@ -2531,25 +2575,26 @@ function nextDefaultName(run = spawnRunner) {
2531
2575
  }
2532
2576
  }
2533
2577
  function materialize(root, opts = {}) {
2534
- const entry = resolve6(root);
2578
+ const entry = resolve7(root);
2579
+ const scripts = join5(resolveProjectLayout(entry).controlDir, "scripts");
2535
2580
  const files = [
2536
2581
  {
2537
- rel: ".devcontainer/docker-compose.yml",
2538
- 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,
2539
2584
  mode: 420
2540
2585
  },
2541
- { rel: ".devcontainer/docker-compose.ssh.yml", body: docker_compose_ssh_default, mode: 420 },
2542
- { rel: ".devcontainer/docker-compose.docker-sock.yml", body: docker_compose_docker_sock_default, mode: 420 },
2543
- { rel: ".oh/scripts/docker-compose.sh", body: docker_compose_default2, mode: 493 },
2544
- { rel: ".oh/scripts/compat.sh", body: compat_default, mode: 420 },
2545
- { rel: ".oh/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 }
2546
2591
  ];
2547
2592
  for (const file of files) {
2548
- const dest = resolve6(entry, file.rel);
2549
- if (!dest.startsWith(entry + sep2)) {
2593
+ const dest = resolve7(file.dest);
2594
+ if (!dest.startsWith(entry + sep3)) {
2550
2595
  throw new Error(`refusing to materialize outside the sandbox entry: ${dest}`);
2551
2596
  }
2552
- mkdirSync3(dirname4(dest), { recursive: true });
2597
+ mkdirSync4(dirname6(dest), { recursive: true });
2553
2598
  writeFileSync4(dest, file.body, { mode: file.mode, encoding: "utf8" });
2554
2599
  }
2555
2600
  }
@@ -2558,23 +2603,23 @@ function resolveSandboxRoot(opts = {}) {
2558
2603
  const root = entryRoot(opts.name);
2559
2604
  if (!isDirectory(root)) {
2560
2605
  throw new Error(
2561
- `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}\``
2562
2607
  );
2563
2608
  }
2564
2609
  return root;
2565
2610
  }
2566
2611
  const names = listEntries();
2567
2612
  if (names.length === 1) return entryRoot(names[0]);
2568
- const cwd = resolve6(opts.cwd ?? process.cwd());
2613
+ const cwd = resolve7(opts.cwd ?? process.cwd());
2569
2614
  for (const name of names) {
2570
2615
  const repo = entryRepo(name);
2571
- if (repo !== void 0 && (cwd === repo || cwd.startsWith(repo + sep2))) {
2616
+ if (repo !== void 0 && (cwd === repo || cwd.startsWith(repo + sep3))) {
2572
2617
  return entryRoot(name);
2573
2618
  }
2574
2619
  }
2575
2620
  if (names.length === 0) {
2576
2621
  throw new Error(
2577
- `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\``
2578
2623
  );
2579
2624
  }
2580
2625
  throw new Error(
@@ -2582,333 +2627,131 @@ function resolveSandboxRoot(opts = {}) {
2582
2627
  );
2583
2628
  }
2584
2629
 
2585
- // src/commands/config.ts
2586
- function configRoot(opts) {
2587
- return opts.sandbox === void 0 ? resolveProjectRoot(opts.cwd) : resolveSandboxRoot({ name: opts.sandbox });
2588
- }
2589
- function configFieldList() {
2590
- return ohConfigFieldPaths().map((path4) => ` ${path4}`).join("\n");
2591
- }
2592
- async function runConfigShow(opts, io) {
2593
- const root = configRoot(opts);
2594
- const config = readOhConfig(ohConfigPath(root));
2595
- io.stdout(`${JSON.stringify(config, null, 2)}
2596
- `);
2597
- return 0;
2598
- }
2599
- async function runConfigSet(key, value, opts, io) {
2600
- if (isSecretKey(key) || isSecretKey(key.toUpperCase())) {
2601
- io.stderr(
2602
- `oh config set: ${key.toUpperCase()} is a secret \u2014 oh.json is tracked by git.
2603
- Set it with \`oh secret set ${key.toUpperCase()}\` instead.
2604
- `
2605
- );
2606
- return 1;
2607
- }
2608
- if (!findOhConfigField(key)) {
2609
- 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";
2610
2633
 
2611
- Fields:
2612
- ${configFieldList()}
2613
- `);
2614
- return 1;
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;
2615
2657
  }
2616
- const root = configRoot(opts);
2617
- let outcome;
2658
+ }
2659
+ function writeEnvFile(envPath, content) {
2660
+ const tmpPath = `${envPath}.tmp.${process.pid}`;
2661
+ let fd;
2618
2662
  try {
2619
- outcome = setConfigField(root, key, value);
2620
- } catch (error) {
2621
- io.stderr(`oh config set: ${error instanceof Error ? error.message : String(error)}
2622
- `);
2623
- return 1;
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 {
2674
+ }
2675
+ }
2676
+ try {
2677
+ unlinkSync4(tmpPath);
2678
+ } catch {
2679
+ }
2680
+ throw err;
2624
2681
  }
2625
- io.stdout(
2626
- outcome === "already-set" ? `oh.json: ${key} already ${value}
2627
- ` : `oh.json: set ${key}=${value} (${outcome})
2628
- `
2629
- );
2630
- return 0;
2631
2682
  }
2632
- var UPSTREAM_REMOTE = "openharness";
2633
- function repoUrl(owner, name) {
2634
- return `git@github.com:${owner}/${name}.git`;
2683
+
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);
2635
2699
  }
2636
- function shellLine(step) {
2637
- return `${step.cmd} ${step.args.join(" ")}`;
2700
+ function isSecretKey(key) {
2701
+ return ALLOWED.has(key);
2638
2702
  }
2639
- function manualSteps(owner, name, visibility) {
2640
- return [
2641
- { cmd: "gh", args: ["repo", "create", `${owner}/${name}`, `--${visibility}`] },
2642
- { cmd: "git", args: ["remote", "rename", "origin", UPSTREAM_REMOTE] },
2643
- { cmd: "git", args: ["remote", "add", "origin", repoUrl(owner, name)] },
2644
- { cmd: "git", args: ["push", "-u", "origin", "HEAD"] }
2645
- ];
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
+ );
2646
2708
  }
2647
- function printManual(io, steps, lead) {
2648
- io.stdout(`
2649
- ${lead}
2650
-
2651
- `);
2652
- for (const step of steps) io.stdout(` ${shellLine(step)}
2653
- `);
2654
- io.stdout("\n");
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);
2655
2717
  }
2656
- function validateSegment(kind, value) {
2657
- if (value === "") return `${kind} is required`;
2658
- if (value.startsWith("-")) return `invalid ${kind} "${value}" (must not start with "-")`;
2659
- if (!/^[A-Za-z0-9._-]+$/.test(value)) {
2660
- return `invalid ${kind} "${value}" (use letters, digits, ".", "_" or "-")`;
2661
- }
2662
- return void 0;
2663
- }
2664
- async function askOn(io, question) {
2665
- const askFn = io.ask ?? ask;
2666
- return (await askFn(question)).trim();
2667
- }
2668
- async function confirmOn(io, question, defaultYes) {
2669
- const answer = (await askOn(io, `${question} ${defaultYes ? "[Y/n]" : "[y/N]"}`)).toLowerCase();
2670
- if (answer === "") return defaultYes;
2671
- return /^y/.test(answer);
2672
- }
2673
- async function askVisibility(io) {
2674
- io.stdout(" Repository visibility\n 1) private (recommended)\n 2) public\n");
2675
- for (; ; ) {
2676
- const answer = await askOn(io, "Choose [1-2] (default 1):");
2677
- if (answer === "" || answer === "1") return "private";
2678
- if (answer === "2") return "public";
2679
- io.stderr(" Invalid choice. Pick 1 or 2.\n");
2680
- }
2681
- }
2682
- async function runConfigRepo(opts, io) {
2683
- const run = opts.run ?? spawnRunner;
2684
- const isTTY = io.isTTY ?? process.stdin.isTTY === true;
2685
- if (!isTTY) {
2686
- io.stderr(
2687
- "oh config repo: creating a repo on your GitHub account needs an interactive terminal; re-run it from a TTY.\n"
2688
- );
2689
- return 1;
2690
- }
2691
- io.stdout(`
2692
- ${bold("Version this harness in your own repo")}
2693
- `);
2694
- io.stdout(
2695
- ` Creates a repo on your GitHub account, keeps the upstream you cloned from
2696
- as the "${UPSTREAM_REMOTE}" remote, and pushes this branch to yours.
2697
- `
2698
- );
2699
- if (!await confirmOn(io, "Version this harness in your own repo?", false)) {
2700
- printManual(
2701
- io,
2702
- manualSteps("<your-user>", "<repo>", "private"),
2703
- "Skipped \u2014 nothing was created and no remote was touched. To do it later, run `oh config repo`, or by hand:"
2704
- );
2705
- return 0;
2706
- }
2707
- const owner = await askOn(io, "GitHub owner (user or org):");
2708
- const ownerError = validateSegment("GitHub owner", owner);
2709
- if (ownerError) {
2710
- io.stderr(`oh config repo: ${ownerError}
2711
- `);
2712
- return 1;
2713
- }
2714
- const name = await askOn(io, "Repository name [openharness]:");
2715
- const repoName = name === "" ? "openharness" : name;
2716
- const nameError = validateSegment("repository name", repoName);
2717
- if (nameError) {
2718
- io.stderr(`oh config repo: ${nameError}
2719
- `);
2720
- return 1;
2721
- }
2722
- const visibility = await askVisibility(io);
2723
- const url = repoUrl(owner, repoName);
2724
- const manual = manualSteps(owner, repoName, visibility);
2725
- let root;
2726
- try {
2727
- root = resolveProjectRoot(opts.cwd);
2728
- } catch (error) {
2729
- io.stderr(`oh config repo: ${error instanceof Error ? error.message : String(error)}
2730
- `);
2731
- printManual(io, manual, "Nothing was changed. Finish this by hand from your checkout:");
2732
- return 1;
2733
- }
2734
- const capture2 = { stdio: "capture", cwd: root };
2735
- const auth = run("gh", ["auth", "status"], capture2);
2736
- if (auth.error?.code === "ENOENT") {
2737
- printManual(io, manual, "gh is not on PATH \u2014 finish this by hand:");
2738
- return 0;
2739
- }
2740
- assertSpawned(auth, "gh auth status");
2741
- if (auth.status !== 0) {
2742
- printManual(
2743
- io,
2744
- manual,
2745
- "gh is not authenticated (`gh auth login`) \u2014 finish this by hand once it is:"
2746
- );
2747
- return 0;
2748
- }
2749
- const remotesResult = run("git", ["remote"], capture2);
2750
- assertSpawned(remotesResult, "git remote");
2751
- if (remotesResult.status !== 0) {
2752
- printManual(io, manual, "`git remote` failed \u2014 is this a git repo? Finish this by hand:");
2753
- return 0;
2754
- }
2755
- const remotes = (remotesResult.stdout ?? "").split("\n").map((line) => line.trim()).filter((line) => line !== "");
2756
- let originUrl = "";
2757
- if (remotes.includes("origin")) {
2758
- const shown = run("git", ["remote", "get-url", "origin"], capture2);
2759
- assertSpawned(shown, "git remote get-url origin");
2760
- originUrl = (shown.stdout ?? "").trim();
2761
- }
2762
- const originIsTarget = originUrl !== "" && sameRemote(originUrl, owner, repoName);
2763
- if (!originIsTarget && remotes.includes("origin") && remotes.includes(UPSTREAM_REMOTE)) {
2764
- io.stderr(
2765
- `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:
2766
- `
2767
- );
2768
- printManual(io, manual.slice(2), "Remaining steps:");
2769
- return 1;
2770
- }
2771
- const steps = [];
2772
- const exists = run("gh", ["repo", "view", `${owner}/${repoName}`], capture2);
2773
- assertSpawned(exists, "gh repo view");
2774
- if (exists.status === 0) {
2775
- io.stdout(` ${owner}/${repoName} already exists \u2014 skipping creation.
2776
- `);
2777
- } else {
2778
- steps.push(manual[0]);
2779
- }
2780
- if (originIsTarget) {
2781
- io.stdout(" origin already points at your repo \u2014 leaving the remotes alone.\n");
2782
- } else {
2783
- if (remotes.includes("origin")) steps.push(manual[1]);
2784
- steps.push(manual[2]);
2785
- }
2786
- steps.push(manual[3]);
2787
- for (let i = 0; i < steps.length; i++) {
2788
- const step = steps[i];
2789
- io.stdout(` $ ${shellLine(step)}
2790
- `);
2791
- const result = run(step.cmd, step.args, { stdio: "inherit", cwd: root });
2792
- if (result.error || result.status !== 0) {
2793
- const detail = result.error?.message ?? `exit ${result.status}`;
2794
- io.stderr(`oh config repo: ${shellLine(step)} failed (${detail}) \u2014 stopping here.
2795
- `);
2796
- const remaining = steps.slice(i + 1);
2797
- if (remaining.length > 0) {
2798
- printManual(io, remaining, "Not run. Finish by hand once the failure is fixed:");
2799
- }
2800
- return 1;
2801
- }
2802
- }
2803
- io.stdout(
2804
- `
2805
- Done \u2014 origin is ${url}${remotes.includes("origin") && !originIsTarget ? `, upstream kept as "${UPSTREAM_REMOTE}"` : ""}.
2806
- `
2807
- );
2808
- return 0;
2809
- }
2810
- function sameRemote(url, owner, name) {
2811
- const normalized = url.replace(/\.git$/, "").toLowerCase();
2812
- const slug = `${owner}/${name}`.toLowerCase();
2813
- return normalized.endsWith(`:${slug}`) || normalized.endsWith(`/${slug}`);
2814
- }
2815
-
2816
- // src/commands/secret.ts
2817
- import { existsSync as existsSync12 } from "node:fs";
2818
- import { join as join7 } from "node:path";
2819
-
2820
- // src/lib/gitignore.ts
2821
- import { existsSync as existsSync11, mkdirSync as mkdirSync4, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
2822
- import { dirname as dirname5, join as join6 } from "node:path";
2823
- function appendGitignoreLines(root, lines) {
2824
- const target = join6(root, ".gitignore");
2825
- const existing = existsSync11(target) ? readFileSync9(target, "utf8") : "";
2826
- const present = new Set(existing.split("\n").map((line) => line.trimEnd()));
2827
- const added = [];
2828
- for (const line of lines) {
2829
- const key = line.trimEnd();
2830
- if (key === "" || present.has(key)) continue;
2831
- present.add(key);
2832
- added.push(line);
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`);
2833
2722
  }
2834
- if (added.length === 0) return added;
2835
- let output = existing;
2836
- if (output.length > 0 && !output.endsWith("\n")) output += "\n";
2837
- output += `${added.join("\n")}
2838
- `;
2839
- mkdirSync4(dirname5(target), { recursive: true });
2840
- writeFileSync5(target, output, "utf8");
2841
- return added;
2842
- }
2843
-
2844
- // src/commands/secret.ts
2845
- function secretsRoot(opts) {
2846
- return opts.sandbox === void 0 ? resolveProjectRoot(opts.cwd) : resolveSandboxRoot({ name: opts.sandbox });
2847
- }
2848
- function ignoreSecretsFile(root) {
2849
- if (!existsSync12(join7(root, ".git"))) return;
2850
- appendGitignoreLines(root, [".env"]);
2851
- }
2852
- function secretKeyList() {
2853
- return SECRET_KEYS.map((key) => ` ${key}`).join("\n");
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);
2854
2728
  }
2855
- async function runSecretSet(key, opts, io) {
2856
- if (!isSecretKey(key)) {
2857
- io.stderr(
2858
- `oh secret set: ${key} is not a secret \u2014 non-secret settings live in oh.json.
2859
- Set it with \`oh config set ${key}\` instead.
2860
-
2861
- Keys:
2862
- ${secretKeyList()}
2863
- `
2864
- );
2865
- return 1;
2866
- }
2867
- const root = secretsRoot(opts);
2868
- const askSecret2 = io.askSecret ?? askSecret;
2869
- const value = (await askSecret2(`Value for ${bold(key)} (input hidden):`)).trim();
2870
- if (value === "") {
2871
- io.stderr(`oh secret set: no value entered \u2014 ${key} unchanged
2872
- `);
2873
- return 1;
2874
- }
2875
- try {
2876
- setSecret(root, key, value);
2877
- ignoreSecretsFile(root);
2878
- } catch (error) {
2879
- io.stderr(`oh secret set: ${error instanceof Error ? error.message : String(error)}
2880
- `);
2881
- return 1;
2882
- }
2883
- io.stdout(`.env: set ${key}=${redact(value)}
2884
- `);
2885
- return 0;
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
+ });
2886
2738
  }
2887
- async function runSecretList(opts, io) {
2888
- const root = secretsRoot(opts);
2889
- const keys = listSecretKeys(root);
2890
- if (keys.length === 0) {
2891
- io.stdout("no secrets set \u2014 write one with `oh secret set <KEY>`\n");
2892
- return 0;
2893
- }
2894
- const width = Math.max(...keys.map((key) => key.length));
2895
- for (const key of keys) {
2896
- const value = readSecret(root, key);
2897
- io.stdout(`${key.padEnd(width)} ${value === void 0 ? "\u2014" : redact(value)}
2898
- `);
2739
+ function stripQuotes2(s) {
2740
+ if (s.length >= 2 && (s[0] === '"' && s.endsWith('"') || s[0] === "'" && s.endsWith("'"))) {
2741
+ return s.slice(1, -1);
2899
2742
  }
2900
- return 0;
2743
+ return s;
2901
2744
  }
2902
2745
 
2903
2746
  // src/commands/lifecycle.ts
2904
- import { existsSync as existsSync14, mkdtempSync, readFileSync as readFileSync10, rmSync, writeFileSync as writeFileSync6 } from "node:fs";
2747
+ import { existsSync as existsSync12, mkdtempSync, readFileSync as readFileSync9, rmSync, writeFileSync as writeFileSync6 } from "node:fs";
2905
2748
  import { tmpdir } from "node:os";
2906
- import { basename as basename4, join as join8, sep as sep3 } from "node:path";
2749
+ import { basename as basename5, join as join6, sep as sep4 } from "node:path";
2907
2750
 
2908
2751
  // src/lib/execution/detect.ts
2909
- import { existsSync as existsSync13 } from "node:fs";
2752
+ import { existsSync as existsSync11 } from "node:fs";
2910
2753
  var SANDBOX_MARKER_FILE = "/.dockerenv";
2911
- function runningInsideSandbox(env = process.env, fileExists = existsSync13) {
2754
+ function runningInsideSandbox(env = process.env, fileExists = existsSync11) {
2912
2755
  const override = resolveAliasedEnv(env, "EXECUTION_TARGET").value;
2913
2756
  if (override === "local") return true;
2914
2757
  if (override === "docker-compose") return false;
@@ -3065,10 +2908,10 @@ var LocalExecutionTarget = class {
3065
2908
  this.workspace = { hostRoot: opts.projectRoot, targetRoot: opts.projectRoot };
3066
2909
  }
3067
2910
  async provision() {
3068
- throw new HostOnlyError("`oh sandbox`");
2911
+ throw new HostOnlyError(`\`${activeBin()} sandbox\``);
3069
2912
  }
3070
2913
  async destroy() {
3071
- throw new HostOnlyError("`oh destroy`");
2914
+ throw new HostOnlyError(`\`${activeBin()} destroy\``);
3072
2915
  }
3073
2916
  async status() {
3074
2917
  return "ready";
@@ -3151,6 +2994,7 @@ var RETIRED_KEYS = [
3151
2994
  "PROJECTS_DIR",
3152
2995
  "CRONS_DIR",
3153
2996
  "OH_PROJECT_ROOT",
2997
+ "AGRO_PROJECT_ROOT",
3154
2998
  "INSTALL_DEEPAGENTS",
3155
2999
  "INSTALL_OPENCODE",
3156
3000
  "INSTALL_GROK_BUILD",
@@ -3174,24 +3018,24 @@ function renderComposeVars(config) {
3174
3018
  };
3175
3019
  put("SANDBOX_NAME", config.name);
3176
3020
  put("TZ", config.timezone);
3177
- put("OH_HOME_MOUNT", config.storage?.homePath);
3178
- put("OH_REPO_DIR", config.repo);
3021
+ put("AGRO_HOME_MOUNT", config.storage?.homePath);
3022
+ put("AGRO_REPO_DIR", configCheckout(config));
3179
3023
  put("GIT_USER_NAME", config.git?.userName);
3180
3024
  put("GIT_USER_EMAIL", config.git?.userEmail);
3181
3025
  put("DOCKER_SOCKET", config.access?.dockerSocket);
3182
3026
  put("SANDBOX_SSH", config.access?.ssh);
3183
3027
  put("SANDBOX_SSH_PORT", config.access?.sshPort);
3184
- put("OH_SANDBOX_IMAGE", config.image?.ref);
3185
- put("OH_PULL_POLICY", config.image?.pullPolicy);
3028
+ put("AGRO_SANDBOX_IMAGE", config.image?.ref);
3029
+ put("AGRO_PULL_POLICY", config.image?.pullPolicy);
3186
3030
  for (const { key, value } of out) {
3187
3031
  if (isSecretKey(key)) {
3188
- throw new Error(`refusing to render secret ${key} from oh.json`);
3032
+ throw new Error(`refusing to render secret ${key} from the sandbox config`);
3189
3033
  }
3190
3034
  if (RETIRED_KEYS.includes(key)) {
3191
3035
  throw new Error(`refusing to render retired variable ${key}`);
3192
3036
  }
3193
3037
  if (/[\r\n]/.test(value)) {
3194
- throw new Error(`oh.json value for ${key} must not contain a newline`);
3038
+ throw new Error(`config value for ${key} must not contain a newline`);
3195
3039
  }
3196
3040
  }
3197
3041
  return out;
@@ -3205,10 +3049,10 @@ function renderComposeEnv(config) {
3205
3049
  // src/commands/lifecycle.ts
3206
3050
  var COMPOSE_ENV_DIR_PREFIX = "oh-compose-env-";
3207
3051
  function composeEnvDir() {
3208
- return mkdtempSync(join8(tmpdir(), COMPOSE_ENV_DIR_PREFIX));
3052
+ return mkdtempSync(join6(tmpdir(), COMPOSE_ENV_DIR_PREFIX));
3209
3053
  }
3210
3054
  function writeComposeEnv(root, dir) {
3211
- const file = join8(dir, "compose.env");
3055
+ const file = join6(dir, "compose.env");
3212
3056
  writeFileSync6(file, renderComposeEnv(readOhConfig(ohConfigPath(root))), {
3213
3057
  mode: 384,
3214
3058
  encoding: "utf8"
@@ -3216,7 +3060,7 @@ function writeComposeEnv(root, dir) {
3216
3060
  return file;
3217
3061
  }
3218
3062
  function withComposeEnvFile(root, fn) {
3219
- if (!existsSync14(ohConfigPath(root))) return fn([]);
3063
+ if (!existsSync12(ohConfigPath(root))) return fn([]);
3220
3064
  const dir = composeEnvDir();
3221
3065
  try {
3222
3066
  return fn(["--extra-env-file", writeComposeEnv(root, dir)]);
@@ -3225,7 +3069,7 @@ function withComposeEnvFile(root, fn) {
3225
3069
  }
3226
3070
  }
3227
3071
  async function withComposeEnvFileAsync(root, fn) {
3228
- if (!existsSync14(ohConfigPath(root))) return await fn([]);
3072
+ if (!existsSync12(ohConfigPath(root))) return await fn([]);
3229
3073
  const dir = composeEnvDir();
3230
3074
  try {
3231
3075
  return await fn(["--extra-env-file", writeComposeEnv(root, dir)]);
@@ -3235,17 +3079,17 @@ async function withComposeEnvFileAsync(root, fn) {
3235
3079
  }
3236
3080
  function sandboxRoot(opts) {
3237
3081
  const root = resolveSandboxRoot({ name: opts.name, cwd: opts.cwd });
3238
- if (existsSync14(ohConfigPath(root))) {
3082
+ if (existsSync12(ohConfigPath(root))) {
3239
3083
  const repo = configuredString(root, "repo");
3240
3084
  materialize(root, repo === void 0 ? {} : { repo });
3241
3085
  }
3242
3086
  return root;
3243
3087
  }
3244
- var DEFAULT_CONTAINER_NAME = "openharness";
3245
- var DEFAULT_SANDBOX_IMAGE = "ghcr.io/mifunedev/openharness:latest";
3088
+ var DEFAULT_CONTAINER_NAME = DEFAULT_SANDBOX_NAME;
3089
+ var DEFAULT_SANDBOX_IMAGE = "ghcr.io/mifunedev/agro:latest";
3246
3090
  function configuredField(root, path4) {
3247
3091
  const file = ohConfigPath(root);
3248
- if (!existsSync14(file)) return void 0;
3092
+ if (!existsSync12(file)) return void 0;
3249
3093
  try {
3250
3094
  return getOhConfigValue(readOhConfig(file), path4);
3251
3095
  } catch {
@@ -3263,18 +3107,18 @@ function fromProcessEnv(key) {
3263
3107
  function dockerSocketConfigured(root) {
3264
3108
  return configuredField(root, "access.dockerSocket") !== void 0;
3265
3109
  }
3266
- async function maybePromptDockerSocket(root, io) {
3110
+ async function maybePromptDockerSocket(root, io, bin) {
3267
3111
  if (dockerSocketConfigured(root)) return;
3268
3112
  const interactive = process.stdin.isTTY === true || io.ask !== void 0;
3269
3113
  if (!interactive) return;
3270
- const envDir = join8(root, ".devcontainer");
3271
- if (!existsSync14(envDir)) return;
3114
+ const envDir = join6(root, ".devcontainer");
3115
+ if (!existsSync12(envDir)) return;
3272
3116
  const askFn = io.ask ?? ask;
3273
3117
  const answer = (await askFn(
3274
3118
  "Mount host Docker socket into the sandbox? (effectively host root \u2014 enable only if the agent must drive Docker) [y/N]"
3275
3119
  )).trim().toLowerCase();
3276
3120
  const enabled = answer === "y" || answer === "yes";
3277
- setEnvValue(root, "DOCKER_SOCKET", enabled ? "true" : "false");
3121
+ setEnvValue(root, "DOCKER_SOCKET", enabled ? "true" : "false", bin);
3278
3122
  io.stdout(
3279
3123
  enabled ? "DOCKER_SOCKET=true \u2014 host Docker socket will be mounted\n" : "DOCKER_SOCKET=false \u2014 host Docker socket stays unmounted\n"
3280
3124
  );
@@ -3288,7 +3132,7 @@ async function runSandbox(opts, io) {
3288
3132
  const useImage = opts.image === true || opts.imageRef !== void 0;
3289
3133
  const useNoBuild = useImage || opts.noBuild === true;
3290
3134
  const imageRef = useImage ? opts.imageRef ?? configuredImage(root) ?? DEFAULT_SANDBOX_IMAGE : void 0;
3291
- const env = imageRef === void 0 ? void 0 : { ...process.env, OH_SANDBOX_IMAGE: imageRef };
3135
+ const env = imageRef === void 0 ? void 0 : { ...process.env, ...aliasedEnvPair("SANDBOX_IMAGE", imageRef) };
3292
3136
  if (opts.printArgv === true) {
3293
3137
  const script = requireLifecycleScript(root, "docker-compose.sh");
3294
3138
  return withComposeEnvFile(root, (extraArgs) => {
@@ -3311,11 +3155,11 @@ async function runSandbox(opts, io) {
3311
3155
  });
3312
3156
  }
3313
3157
  if (runningInsideSandbox()) {
3314
- io.stderr(`${new HostOnlyError("`oh sandbox`").message}
3158
+ io.stderr(`${new HostOnlyError(`\`${opts.bin} sandbox\``).message}
3315
3159
  `);
3316
3160
  return 1;
3317
3161
  }
3318
- await maybePromptDockerSocket(root, io);
3162
+ await maybePromptDockerSocket(root, io, opts.bin);
3319
3163
  requireLifecycleScript(root, "docker-compose.sh");
3320
3164
  if (imageRef !== void 0) {
3321
3165
  io.stdout(`image mode: ${imageRef} (skipping local build)
@@ -3358,142 +3202,487 @@ function runShell(opts, io) {
3358
3202
  code = target.attach({ argv: ["zsh"], user: "sandbox" });
3359
3203
  } catch (err) {
3360
3204
  if (err instanceof ExecutionSpawnError && err.code === "ENOENT") {
3361
- throw new Error("docker is required for `oh shell` but was not found on PATH");
3205
+ throw new Error(`docker is required for \`${opts.bin} shell\` but was not found on PATH`);
3206
+ }
3207
+ throw err;
3208
+ }
3209
+ if (code !== 0) {
3210
+ io.stderr(
3211
+ `container \`${name}\` not running? start it with \`${opts.bin} sandbox install docker\`
3212
+ `
3213
+ );
3214
+ }
3215
+ return code;
3216
+ }
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"
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 [];
3255
+ }
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;
3266
+ }
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;
3281
+ }
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
+ );
3309
+ }
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;
3316
+ }
3317
+ }
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;
3325
+ }
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;
3336
+ }
3337
+
3338
+ // src/commands/config.ts
3339
+ var HOME_PATH_FIELD = "storage.homePath";
3340
+ function currentHomePath(root) {
3341
+ return readOhConfig(ohConfigPath(root)).storage?.homePath;
3342
+ }
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;
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;
3363
+ }
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;
3372
+ }
3373
+ if (!findOhConfigField(key)) {
3374
+ io.stderr(`${opts.bin} config set: unknown field "${key}"
3375
+
3376
+ Fields:
3377
+ ${configFieldList()}
3378
+ `);
3379
+ return 1;
3380
+ }
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;
3395
+ try {
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;
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;
3408
+ }
3409
+ var UPSTREAM_REMOTE = "openharness";
3410
+ function repoUrl(owner, name) {
3411
+ return `git@github.com:${owner}/${name}.git`;
3412
+ }
3413
+ function shellLine(step) {
3414
+ return `${step.cmd} ${step.args.join(" ")}`;
3415
+ }
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
+ ];
3423
+ }
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) {
3442
+ const askFn = io.ask ?? ask;
3443
+ return (await askFn(question)).trim();
3444
+ }
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);
3449
+ }
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) {
3460
+ const run = opts.run ?? spawnRunner;
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;
3468
+ }
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}
3489
+ `);
3490
+ return 1;
3491
+ }
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}
3497
+ `);
3498
+ return 1;
3499
+ }
3500
+ const visibility = await askVisibility(io);
3501
+ const url = repoUrl(owner, repoName);
3502
+ const manual = manualSteps(owner, repoName, visibility);
3503
+ let root;
3504
+ try {
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:");
3516
+ return 0;
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.
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
+ }
3578
+ return 1;
3362
3579
  }
3363
- throw err;
3364
3580
  }
3365
- if (code !== 0) {
3366
- io.stderr(
3367
- `container \`${name}\` not running? start it with \`oh sandbox install docker\`
3581
+ io.stdout(
3582
+ `
3583
+ Done \u2014 origin is ${url}${remotes.includes("origin") && !originIsTarget ? `, upstream kept as "${UPSTREAM_REMOTE}"` : ""}.
3368
3584
  `
3369
- );
3370
- }
3371
- return code;
3585
+ );
3586
+ return 0;
3372
3587
  }
3373
- var COMPOSE_VERBS = Object.freeze({
3374
- stop: Object.freeze(["stop"]),
3375
- restart: Object.freeze(["restart"]),
3376
- logs: Object.freeze(["logs", "-f"]),
3377
- ps: Object.freeze(["ps"]),
3378
- destroy: Object.freeze(["down", "-v"])
3379
- });
3380
- function composeVerbs() {
3381
- return Object.keys(COMPOSE_VERBS);
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}`);
3382
3592
  }
3383
- function runComposeVerb(verb, opts, extra = []) {
3384
- const run = opts.run ?? spawnRunner;
3385
- const root = sandboxRoot(opts);
3386
- const script = requireLifecycleScript(root, "docker-compose.sh");
3387
- return withComposeEnvFile(root, (extraArgs) => {
3388
- const r = run("bash", [script, ...extraArgs, ...COMPOSE_VERBS[verb], ...extra], {
3389
- stdio: "inherit"
3390
- });
3391
- assertSpawned(r, `bash ${script} ${verb}`);
3392
- return r.status ?? 1;
3393
- });
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);
3611
+ }
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;
3394
3620
  }
3395
- function runComposeConfig(opts, extra = []) {
3396
- const run = opts.run ?? spawnRunner;
3397
- const root = sandboxRoot(opts);
3398
- const script = requireLifecycleScript(root, "docker-compose.sh");
3399
- return withComposeEnvFile(root, (extraArgs) => {
3400
- const r = run("bash", [script, ...extraArgs, "config", ...extra], { stdio: "inherit" });
3401
- assertSpawned(r, `bash ${script} config`);
3402
- return r.status ?? 1;
3403
- });
3621
+
3622
+ // src/commands/secret.ts
3623
+ function secretsRoot(opts) {
3624
+ return opts.sandbox === void 0 ? resolveProjectRoot(opts.cwd) : resolveSandboxRoot({ name: opts.sandbox });
3404
3625
  }
3405
- function namedVolumes(root) {
3406
- let text;
3407
- try {
3408
- text = readFileSync10(join8(root, ".devcontainer", "docker-compose.yml"), "utf8");
3409
- } catch {
3410
- return [];
3411
- }
3412
- const lines = text.split("\n");
3413
- const start = lines.findIndex((line) => /^volumes:\s*$/.test(line));
3414
- if (start === -1) return [];
3415
- const names = [];
3416
- for (const line of lines.slice(start + 1)) {
3417
- if (/^\S/.test(line)) break;
3418
- const match = /^ {2}([A-Za-z0-9][A-Za-z0-9_.-]*):\s*$/.exec(line);
3419
- if (match) names.push(match[1]);
3420
- }
3421
- return names;
3626
+ function ignoreSecretsFile(root) {
3627
+ if (!existsSync14(join8(root, ".git"))) return;
3628
+ appendGitignoreLines(root, [".env"]);
3422
3629
  }
3423
- function destroyConfirmationPhrase(root) {
3424
- return configuredContainerName(root) ?? DEFAULT_CONTAINER_NAME;
3630
+ function secretKeyList() {
3631
+ return SECRET_KEYS.map((key) => ` ${key}`).join("\n");
3425
3632
  }
3426
- async function runDestroy(opts, io) {
3427
- const root = sandboxRoot(opts);
3428
- const name = destroyConfirmationPhrase(root);
3429
- if (opts.yes !== true) {
3430
- const interactive = process.stdin.isTTY === true || io.ask !== void 0;
3431
- if (!interactive) {
3432
- io.stderr(
3433
- `oh destroy: refusing to destroy \`${name}\` without a terminal \u2014 re-run with --yes to confirm non-interactively
3434
- `
3435
- );
3436
- return 1;
3437
- }
3438
- const homePath = readOhConfig(ohConfigPath(root)).storage?.homePath;
3439
- io.stdout(`
3440
- ${bold(`oh destroy \u2014 ${name}`)}
3441
-
3442
- `);
3443
- if (homePath !== void 0 && homePath !== "") {
3444
- io.stdout("`docker compose down -v` removes the containers.\n");
3445
- io.stdout(
3446
- `This sandbox keeps its home on a host bind at ${homePath}, which \`down -v\`
3447
- does not touch \u2014 every agent CLI login, the gh CLI token, and the SSH keys
3448
- stay there. Delete that directory yourself if you also want them gone.
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.
3449
3638
 
3639
+ Keys:
3640
+ ${secretKeyList()}
3450
3641
  `
3451
- );
3452
- } else {
3453
- const volumes = namedVolumes(root).map((volume) => `${name}_${volume}`);
3454
- io.stdout("`docker compose down -v` removes the containers and deletes\n");
3455
- io.stdout(
3456
- volumes.length > 0 ? `these named volumes with everything in them:
3457
-
3458
- ${volumes.join("\n ")}
3459
-
3460
- ` : "every named volume this project owns, with everything in them.\n\n"
3461
- );
3462
- io.stdout(
3463
- "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"
3464
- );
3465
- }
3466
- const askFn = io.ask ?? ask;
3467
- const answer = (await askFn(`Type the sandbox name \`${name}\` to destroy it:`)).trim();
3468
- if (answer !== name) {
3469
- io.stderr("oh destroy: aborted \u2014 nothing was removed\n");
3470
- return 1;
3471
- }
3642
+ );
3643
+ return 1;
3472
3644
  }
3473
- const code = runComposeVerb("destroy", { ...opts, name: basename4(root) });
3474
- if (code === 0 && root.startsWith(registryRoot() + sep3)) {
3475
- rmSync(root, { recursive: true, force: true });
3476
- io.stdout(`removed the sandbox entry ${root}
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
3477
3650
  `);
3651
+ return 1;
3478
3652
  }
3479
- return code;
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;
3480
3664
  }
3481
- function runGateway(args, opts) {
3482
- const run = opts.run ?? spawnRunner;
3483
- const root = resolveProjectRoot(opts.cwd);
3484
- const script = requireLifecycleScript(root, "gateway.sh");
3485
- const r = run("bash", [script, ...args], {
3486
- stdio: "inherit",
3487
- env: { ...process.env, OH_PROJECT_ROOT: root }
3488
- });
3489
- assertSpawned(r, `bash ${script}`);
3490
- return r.status ?? 1;
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>\`
3670
+ `);
3671
+ return 0;
3672
+ }
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)}
3677
+ `);
3678
+ }
3679
+ return 0;
3491
3680
  }
3492
3681
 
3493
3682
  // src/commands/sandbox.ts
3494
- import { existsSync as existsSync15, mkdirSync as mkdirSync5, 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";
3495
3684
  import { tmpdir as tmpdir2 } from "node:os";
3496
- import { join as join9, resolve as resolve7 } from "node:path";
3685
+ import { join as join9, resolve as resolve9 } from "node:path";
3497
3686
 
3498
3687
  // src/lib/runtimes/catalog.ts
3499
3688
  var RUNTIME_CATALOG = Object.freeze([
@@ -3511,7 +3700,7 @@ var RUNTIME_CATALOG = Object.freeze([
3511
3700
  tier: "microvm",
3512
3701
  state: "planned",
3513
3702
  provisionable: false,
3514
- 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\`.`,
3515
3704
  docsPath: "docs/runtimes/microsandbox.md"
3516
3705
  })
3517
3706
  ]);
@@ -3537,26 +3726,70 @@ function gitIdentity(run, key) {
3537
3726
  if (result.error || result.status !== 0) return "";
3538
3727
  return (result.stdout ?? "").trim();
3539
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
+ }
3540
3743
  function readSeedConfig(repo) {
3541
3744
  if (repo === void 0) return void 0;
3542
3745
  const file = ohConfigPath(repo);
3543
3746
  return existsSync15(file) ? readOhConfig(file) : void 0;
3544
3747
  }
3545
- function seedConfig(name, repo, seed, run) {
3546
- const config = defaultOhConfig(name);
3748
+ function readEntryConfig(name) {
3749
+ const file = ohConfigPath(entryRoot(name));
3750
+ return existsSync15(file) ? readOhConfig(file) : void 0;
3751
+ }
3752
+ function assignSetting(target, path4, value) {
3753
+ const segments = path4.split(".");
3754
+ let cursor = target;
3755
+ for (const segment of segments.slice(0, -1)) {
3756
+ const section = cursor[segment];
3757
+ if (!section || typeof section !== "object" || Array.isArray(section)) cursor[segment] = {};
3758
+ cursor = cursor[segment];
3759
+ }
3760
+ cursor[segments[segments.length - 1]] = value;
3761
+ }
3762
+ function overlaySettings(target, source) {
3763
+ if (source === void 0) return target;
3764
+ for (const field of OH_CONFIG_FIELDS) {
3765
+ const value = getOhConfigValue(source, field.path);
3766
+ if (value !== void 0) assignSetting(target, field.path, value);
3767
+ }
3768
+ return target;
3769
+ }
3770
+ function mergeSettings(...sources) {
3771
+ const present = sources.filter((source) => source !== void 0);
3772
+ if (present.length === 0) return void 0;
3773
+ const merged = { version: 1 };
3774
+ for (const source of present) overlaySettings(merged, source);
3775
+ return merged;
3776
+ }
3777
+ function nonEmpty(value) {
3778
+ return value === void 0 || value === "" ? void 0 : value;
3779
+ }
3780
+ function seedConfig(name, checkout, seed, run) {
3781
+ const config = overlaySettings(defaultOhConfig(name), seed);
3782
+ config.name = name;
3547
3783
  config.runtime = "docker";
3548
- if (repo !== void 0) config.repo = repo;
3549
- config.timezone = seed?.timezone ?? hostTimezone();
3784
+ if (checkout !== void 0) config.checkout = checkout;
3785
+ config.timezone = nonEmpty(seed?.timezone) ?? hostTimezone();
3550
3786
  config.git = {
3551
- userName: seed?.git?.userName ?? gitIdentity(run, "user.name"),
3552
- userEmail: seed?.git?.userEmail ?? gitIdentity(run, "user.email")
3787
+ userName: nonEmpty(seed?.git?.userName) ?? gitIdentity(run, "user.name"),
3788
+ userEmail: nonEmpty(seed?.git?.userEmail) ?? gitIdentity(run, "user.email")
3553
3789
  };
3554
- if (seed?.storage?.homePath !== void 0) config.storage = { homePath: seed.storage.homePath };
3555
- config.access = { ...config.access, ...seed?.access ?? {} };
3556
3790
  config.image = {
3557
3791
  ...config.image,
3558
- ...seed?.image ?? {},
3559
- mode: seed?.image?.mode ?? (repo === void 0 ? "image" : "build")
3792
+ mode: seed?.image?.mode ?? (isBuildCapable(configCheckout(config)) ? "build" : "image")
3560
3793
  };
3561
3794
  return config;
3562
3795
  }
@@ -3596,48 +3829,89 @@ async function runWizard(config, io) {
3596
3829
  config.access?.dockerSocket === true
3597
3830
  );
3598
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) };
3599
3838
  }
3600
3839
  async function runSandboxInstall(opts, io) {
3601
3840
  const run = opts.run ?? spawnRunner;
3602
3841
  const runtime = findRuntime(opts.runtime);
3603
3842
  if (runtime === void 0) {
3604
3843
  io.stderr(
3605
- `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(", ")}
3606
3845
  `
3607
3846
  );
3608
3847
  return 1;
3609
3848
  }
3610
3849
  if (!runtime.provisionable) {
3611
- io.stderr(`oh sandbox install: ${runtime.notProvisionableReason}
3850
+ io.stderr(`${opts.bin} sandbox install: ${runtime.notProvisionableReason?.(opts.bin) ?? ""}
3612
3851
  `);
3613
3852
  return 1;
3614
3853
  }
3615
- const repo = opts.repo === void 0 ? void 0 : resolve7(opts.repo);
3616
- if (repo !== void 0 && !existsSync15(repo)) {
3617
- 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}
3618
3857
  `);
3619
3858
  return 1;
3620
3859
  }
3621
- const seed = readSeedConfig(repo);
3622
- const name = opts.name ?? seed?.name ?? nextDefaultName(run);
3860
+ const repoSeed = readSeedConfig(checkout);
3861
+ const name = opts.name ?? repoSeed?.name ?? nextDefaultName(run);
3623
3862
  try {
3624
3863
  assertSandboxName(name);
3625
3864
  } catch (error) {
3626
- 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)}
3627
3866
  `);
3628
3867
  return 1;
3629
3868
  }
3630
- const config = seedConfig(name, repo, seed, 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
+ }
3631
3888
  const interactive = opts.yes !== true && (process.stdin.isTTY === true || io.ask !== void 0);
3889
+ const homePathBeforeWizard = config.storage?.homePath;
3632
3890
  if (interactive) {
3633
3891
  header("Configure the sandbox (press Enter to accept the shown default)");
3634
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
+ }
3635
3905
  }
3636
3906
  if (opts.imageRef !== void 0) {
3637
3907
  config.image = { ...config.image, ref: opts.imageRef, mode: "image" };
3638
3908
  }
3639
- 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");
3640
3913
  const sandboxOpts = {
3914
+ bin: opts.bin,
3641
3915
  run,
3642
3916
  ...opts.image === true ? { image: true } : {},
3643
3917
  ...opts.imageRef !== void 0 ? { imageRef: opts.imageRef } : {},
@@ -3647,18 +3921,18 @@ async function runSandboxInstall(opts, io) {
3647
3921
  const preview = mkdtempSync2(join9(tmpdir2(), "oh-sandbox-preview-"));
3648
3922
  try {
3649
3923
  writeOhConfig(preview, config);
3650
- materialize(preview, { ...config.repo !== void 0 ? { repo: config.repo } : {} });
3924
+ materialize(preview, { ...configCheckout(config) !== void 0 ? { checkout: configCheckout(config) } : {} });
3651
3925
  return await runSandbox({ ...sandboxOpts, cwd: preview, printArgv: true }, io);
3652
3926
  } finally {
3653
3927
  rmSync2(preview, { recursive: true, force: true });
3654
3928
  }
3655
3929
  }
3656
3930
  const root = entryRoot(config.name);
3657
- mkdirSync5(root, { recursive: true });
3931
+ mkdirSync6(root, { recursive: true });
3658
3932
  writeOhConfig(root, config);
3659
- materialize(root, { ...config.repo !== void 0 ? { repo: config.repo } : {} });
3933
+ materialize(root, { ...configCheckout(config) !== void 0 ? { checkout: configCheckout(config) } : {} });
3660
3934
  const code = await runSandbox({ ...sandboxOpts, cwd: root }, io);
3661
- if (code === 0) io.stdout(`next: oh shell ${config.name}
3935
+ if (code === 0) io.stdout(`next: ${opts.bin} shell ${config.name}
3662
3936
  `);
3663
3937
  return code;
3664
3938
  }
@@ -3676,10 +3950,12 @@ async function runSandboxList(opts, io) {
3676
3950
  for (const name of listEntries()) {
3677
3951
  const root = entryRoot(name);
3678
3952
  const config = readOhConfig(ohConfigPath(root));
3953
+ const checkout = configCheckout(config) ?? "-";
3679
3954
  rows.push({
3680
3955
  name,
3681
3956
  runtime: config.runtime ?? "docker",
3682
- repo: config.repo ?? "-",
3957
+ checkout,
3958
+ repo: checkout,
3683
3959
  status: await entryStatus(root, name, run)
3684
3960
  });
3685
3961
  }
@@ -3690,7 +3966,7 @@ async function runSandboxList(opts, io) {
3690
3966
  }
3691
3967
  if (rows.length === 0) {
3692
3968
  io.stdout(
3693
- `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\`
3694
3970
  `
3695
3971
  );
3696
3972
  return 0;
@@ -3701,7 +3977,7 @@ async function runSandboxList(opts, io) {
3701
3977
  const statusWidth = width((row) => row.status);
3702
3978
  for (const row of rows) {
3703
3979
  io.stdout(
3704
- `${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}
3705
3981
  `
3706
3982
  );
3707
3983
  }
@@ -3709,7 +3985,7 @@ async function runSandboxList(opts, io) {
3709
3985
  }
3710
3986
 
3711
3987
  // src/lib/docs.ts
3712
- var SOURCE_DOCS_BASE = "https://github.com/mifunedev/openharness/blob/main/";
3988
+ var SOURCE_DOCS_BASE = "https://github.com/mifunedev/agro/blob/main/";
3713
3989
  function sourceDocsUrl(docsPath) {
3714
3990
  const segments = docsPath.split("/");
3715
3991
  if (segments[0] !== "docs" || segments.length < 2 || segments.some((segment) => segment === "" || segment === "." || segment === "..") || docsPath.includes("\\") || docsPath.includes("?") || docsPath.includes("#")) {
@@ -3831,6 +4107,20 @@ var HARNESS_CATALOG = [
3831
4107
  docsPath: "docs/harnesses/muse-code.md",
3832
4108
  kind: "installable"
3833
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
+ },
3834
4124
  {
3835
4125
  id: "t3code",
3836
4126
  title: "T3 Code",
@@ -3904,7 +4194,7 @@ function cell(value, absent) {
3904
4194
  if (value === null) return absent;
3905
4195
  return value ? "yes" : "no";
3906
4196
  }
3907
- function renderTable(states, io) {
4197
+ function renderTable(states, io, bin) {
3908
4198
  const header2 = ["HARNESS", "KIND", "INSTALLED"];
3909
4199
  const rows = states.map((s) => [s.id, s.kind, cell(s.installed, "?")]);
3910
4200
  const widths = header2.map(
@@ -3914,7 +4204,9 @@ function renderTable(states, io) {
3914
4204
  io.stdout(line(header2));
3915
4205
  for (const row of rows) io.stdout(line(row));
3916
4206
  if (states.some((s) => s.installed === null)) {
3917
- 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
+ `);
3918
4210
  }
3919
4211
  }
3920
4212
  async function runHarnessList(opts, io) {
@@ -3925,12 +4217,12 @@ async function runHarnessList(opts, io) {
3925
4217
  io.stdout(`${JSON.stringify(states, null, 2)}
3926
4218
  `);
3927
4219
  } else {
3928
- renderTable(states, io);
4220
+ renderTable(states, io, opts.bin);
3929
4221
  }
3930
4222
  return 0;
3931
4223
  }
3932
- function unknownHarness(name, io) {
3933
- io.stderr(`oh harness: unknown harness "${name}"
4224
+ function unknownHarness(name, io, bin) {
4225
+ io.stderr(`${bin} harness: unknown harness "${name}"
3934
4226
 
3935
4227
  `);
3936
4228
  io.stderr(`Known harnesses:
@@ -3944,30 +4236,30 @@ async function runHarnessStatus(name, opts, io) {
3944
4236
  let only;
3945
4237
  if (name !== void 0) {
3946
4238
  only = findHarness(name);
3947
- if (!only) return unknownHarness(name, io);
4239
+ if (!only) return unknownHarness(name, io, opts.bin);
3948
4240
  }
3949
4241
  const states = await collectStates(root, run, opts.env, only ? [only] : void 0);
3950
4242
  if (opts.json) {
3951
4243
  io.stdout(`${JSON.stringify(only ? states[0] : states, null, 2)}
3952
4244
  `);
3953
4245
  } else {
3954
- renderTable(states, io);
4246
+ renderTable(states, io, opts.bin);
3955
4247
  }
3956
4248
  return 0;
3957
4249
  }
3958
4250
  function hermesTargetRoot(target) {
3959
4251
  return target.kind === "docker-compose" ? "/home/sandbox/harness" : target.workspace.targetRoot;
3960
4252
  }
3961
- async function reconcileHermes(target, io) {
4253
+ async function reconcileHermes(target, io, bin) {
3962
4254
  const root = hermesTargetRoot(target);
3963
4255
  const result = await target.exec({
3964
- argv: ["bash", `${root}/.oh/scripts/link-providers.sh`, "--init", "--hermes-only"],
3965
- env: { OH_PROJECT_ROOT: root },
4256
+ argv: remoteControlDirScript(root, "scripts/link-providers.sh", ["--init", "--hermes-only"]),
4257
+ env: aliasedEnvPair("PROJECT_ROOT", root),
3966
4258
  user: "sandbox",
3967
4259
  stdio: "inherit"
3968
4260
  });
3969
4261
  if (result.exitCode !== 0) {
3970
- 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.
3971
4263
  `);
3972
4264
  }
3973
4265
  return result.exitCode;
@@ -3980,7 +4272,7 @@ async function runHarnessInstall(name, opts, io) {
3980
4272
  name === "hermes" && runningInsideSandbox(env) && projectRoot !== void 0 ? projectRoot : opts.cwd
3981
4273
  );
3982
4274
  const entry = findHarness(name);
3983
- if (!entry) return unknownHarness(name, io);
4275
+ if (!entry) return unknownHarness(name, io, opts.bin);
3984
4276
  const target = targetFor(root, run, opts.env);
3985
4277
  let status;
3986
4278
  try {
@@ -3994,19 +4286,19 @@ async function runHarnessInstall(name, opts, io) {
3994
4286
  }
3995
4287
  if (!isReachable(status)) {
3996
4288
  io.stderr(
3997
- `oh harness: the sandbox is not running (${status}).
3998
- 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.
3999
4291
  `
4000
4292
  );
4001
4293
  return 1;
4002
4294
  }
4003
4295
  const hermes = entry.id === "hermes";
4004
4296
  const installEnv = hermes ? {
4005
- OH_PROJECT_ROOT: hermesTargetRoot(target),
4297
+ ...aliasedEnvPair("PROJECT_ROOT", hermesTargetRoot(target)),
4006
4298
  HERMES_HOME: `${hermesTargetRoot(target)}/.hermes`
4007
4299
  } : void 0;
4008
4300
  if (hermes) {
4009
- const code = await reconcileHermes(target, io);
4301
+ const code = await reconcileHermes(target, io, opts.bin);
4010
4302
  if (code !== 0) return code;
4011
4303
  }
4012
4304
  const already = await probeInstalled(target, entry, installEnv);
@@ -4028,16 +4320,17 @@ Start it with \`oh sandbox\`, then re-run this command.
4028
4320
  ...installEnv ? { env: installEnv } : {}
4029
4321
  });
4030
4322
  if (r.exitCode !== 0) {
4031
- io.stderr(`oh harness: installing ${entry.id} failed (exit ${r.exitCode}).
4323
+ io.stderr(`${opts.bin} harness: installing ${entry.id} failed (exit ${r.exitCode}).
4032
4324
  `);
4033
4325
  return r.exitCode;
4034
4326
  }
4035
4327
  if (hermes) {
4036
4328
  if (await probeInstalled(target, entry, installEnv) !== true) {
4037
- 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
+ `);
4038
4331
  return 1;
4039
4332
  }
4040
- const code = await reconcileHermes(target, io);
4333
+ const code = await reconcileHermes(target, io, opts.bin);
4041
4334
  if (code !== 0) return code;
4042
4335
  }
4043
4336
  io.stdout(`${entry.id}: installed \u2014 see ${sourceDocsUrl(entry.docsPath)} for authentication
@@ -4158,7 +4451,7 @@ var TOOL_CATALOG = Object.freeze([
4158
4451
  binary: "docker",
4159
4452
  verifyArgv: Object.freeze(["bash", "-lc", "command -v docker >/dev/null"]),
4160
4453
  versionArgv: Object.freeze(["docker", "--version"]),
4161
- 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.`,
4162
4455
  docsPath: TOOLS_DOC
4163
4456
  }),
4164
4457
  Object.freeze({
@@ -4168,7 +4461,7 @@ var TOOL_CATALOG = Object.freeze([
4168
4461
  binary: "gh",
4169
4462
  verifyArgv: Object.freeze(["bash", "-lc", "command -v gh >/dev/null"]),
4170
4463
  versionArgv: Object.freeze(["gh", "--version"]),
4171
- 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.",
4172
4465
  docsPath: TOOLS_DOC
4173
4466
  }),
4174
4467
  Object.freeze({
@@ -4275,7 +4568,7 @@ function cell2(value, absent) {
4275
4568
  if (value === null) return absent;
4276
4569
  return value ? "yes" : "no";
4277
4570
  }
4278
- function renderTable2(rows, io) {
4571
+ function renderTable2(rows, io, bin) {
4279
4572
  const header2 = ["TOOL", "KIND", "INSTALLED"];
4280
4573
  const body = rows.map((r) => [r.id, r.kind, cell2(r.installed, "?")]);
4281
4574
  const widths = header2.map(
@@ -4285,11 +4578,13 @@ function renderTable2(rows, io) {
4285
4578
  io.stdout(line(header2));
4286
4579
  for (const row of body) io.stdout(line(row));
4287
4580
  if (rows.some((r) => r.installed === null)) {
4288
- 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
+ `);
4289
4584
  }
4290
4585
  }
4291
- function renderDetail(rows, io) {
4292
- renderTable2(rows, io);
4586
+ function renderDetail(rows, io, bin) {
4587
+ renderTable2(rows, io, bin);
4293
4588
  for (const r of rows) {
4294
4589
  io.stdout(`
4295
4590
  ${r.id} \u2014 ${r.title}
@@ -4310,12 +4605,12 @@ async function runToolList(opts, io) {
4310
4605
  io.stdout(`${JSON.stringify(rows, null, 2)}
4311
4606
  `);
4312
4607
  } else {
4313
- renderTable2(rows, io);
4608
+ renderTable2(rows, io, opts.bin);
4314
4609
  }
4315
4610
  return 0;
4316
4611
  }
4317
- function unknownTool(name, io) {
4318
- io.stderr(`oh tool: unknown tool "${name}"
4612
+ function unknownTool(name, io, bin) {
4613
+ io.stderr(`${bin} tool: unknown tool "${name}"
4319
4614
 
4320
4615
  `);
4321
4616
  io.stderr(`Known tools:
@@ -4329,14 +4624,14 @@ async function runToolStatus(name, opts, io) {
4329
4624
  let only;
4330
4625
  if (name !== void 0) {
4331
4626
  only = findTool(name);
4332
- if (!only) return unknownTool(name, io);
4627
+ if (!only) return unknownTool(name, io, opts.bin);
4333
4628
  }
4334
4629
  const rows = await collectRows(root, run, opts.env, only ? [only] : void 0);
4335
4630
  if (opts.json) {
4336
4631
  io.stdout(`${JSON.stringify(only ? rows[0] : rows, null, 2)}
4337
4632
  `);
4338
4633
  } else {
4339
- renderDetail(rows, io);
4634
+ renderDetail(rows, io, opts.bin);
4340
4635
  }
4341
4636
  return 0;
4342
4637
  }
@@ -4357,12 +4652,12 @@ async function runToolInstall(name, opts, io) {
4357
4652
  const run = opts.run ?? spawnRunner;
4358
4653
  const root = resolveProjectRoot(opts.cwd);
4359
4654
  const entry = findTool(name);
4360
- if (!entry) return unknownTool(name, io);
4655
+ if (!entry) return unknownTool(name, io, opts.bin);
4361
4656
  if (entry.installArgv === void 0) {
4362
- 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.
4363
4658
 
4364
4659
  `);
4365
- io.stderr(`${entry.notInstallableReason ?? ""}
4660
+ io.stderr(`${entry.notInstallableReason?.(opts.bin) ?? ""}
4366
4661
 
4367
4662
  `);
4368
4663
  io.stderr(`Installable tools:
@@ -4383,8 +4678,8 @@ ${installableToolIds().map((t) => ` ${t}`).join("\n")}
4383
4678
  }
4384
4679
  if (!isReachable2(status)) {
4385
4680
  io.stderr(
4386
- `oh tool: the sandbox is not running (${status}).
4387
- 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.
4388
4683
  `
4389
4684
  );
4390
4685
  return 1;
@@ -4408,7 +4703,7 @@ Start it with \`oh sandbox\`, then re-run this command.
4408
4703
  stdio: "inherit"
4409
4704
  });
4410
4705
  if (r.exitCode !== 0) {
4411
- io.stderr(`oh tool: installing ${entry.id} failed (exit ${r.exitCode}).
4706
+ io.stderr(`${opts.bin} tool: installing ${entry.id} failed (exit ${r.exitCode}).
4412
4707
  `);
4413
4708
  return r.exitCode;
4414
4709
  }
@@ -4419,7 +4714,7 @@ Start it with \`oh sandbox\`, then re-run this command.
4419
4714
 
4420
4715
  // src/lib/remote.ts
4421
4716
  import { spawnSync as spawnSync3 } from "node:child_process";
4422
- var DEFAULT_REPO_URL = "https://github.com/mifunedev/openharness";
4717
+ var DEFAULT_REPO_URL = "https://github.com/mifunedev/agro";
4423
4718
  var DEFAULT_CLONE_TIMEOUT_MS = 12e4;
4424
4719
  var spawnRunner2 = (cmd, args, opts) => {
4425
4720
  const r = spawnSync3(cmd, args, {
@@ -4436,7 +4731,7 @@ var spawnRunner2 = (cmd, args, opts) => {
4436
4731
  };
4437
4732
  };
4438
4733
  function fallbackHint() {
4439
- return "use --from <dir> to point at a local OpenHarness checkout instead";
4734
+ return "use --from <dir> to point at a local AGRO checkout instead";
4440
4735
  }
4441
4736
  function fetchRemoteSource(opts) {
4442
4737
  const repoUrl2 = opts.repoUrl ?? DEFAULT_REPO_URL;
@@ -4481,9 +4776,9 @@ function fetchRemoteSource(opts) {
4481
4776
  }
4482
4777
 
4483
4778
  // src/cli.ts
4484
- var VERSION = true ? "0.9.0" : "0.0.0-dev";
4485
- var DEFAULT_SOURCE_OH_DIR = resolve8(
4486
- dirname6(fileURLToPath(import.meta.url)),
4779
+ var VERSION = true ? "0.11.0" : "0.0.0-dev";
4780
+ var DEFAULT_SOURCE_OH_DIR = resolve10(
4781
+ dirname8(fileURLToPath(import.meta.url)),
4487
4782
  "../.."
4488
4783
  );
4489
4784
  var INTEGRATIONS = {};
@@ -4506,14 +4801,16 @@ function compatibilityNote(product) {
4506
4801
  }
4507
4802
  function printOhHelp(product = LEGACY_PRODUCT) {
4508
4803
  const { bin, title } = product;
4804
+ const updateSummary = bin === AGRO_PRODUCT.bin ? `Upgrade the installed ${bin} CLI` : `Vendor or upgrade the ${stateNames(bin).controlDir}/ control plane`;
4509
4805
  process.stdout.write(`${bin} \u2014 ${title} (v${VERSION})
4510
4806
 
4511
4807
  Usage:
4512
4808
  ${bin} sandbox <args...> Create and list sandboxes (install|list)
4513
4809
  ${bin} shell [name] Open a zsh shell in the running sandbox container
4514
- ${bin} config <args...> Read and write oh.json (show|set), or run a wizard
4810
+ ${bin} config <args...> Read and write ${stateNames(bin).configFile} (show|set), or run a wizard
4515
4811
  ${bin} secret <args...> Read and write the gitignored root .env (set|list)
4516
- ${bin} update Vendor or upgrade the .oh/ control plane
4812
+ ${bin} update ${updateSummary}
4813
+ ${bin} migrate Move a legacy .oh/ project or ~/.oh registry to AGRO names (--check|--home)
4517
4814
  ${bin} stop [name] Stop the sandbox, preserving volumes
4518
4815
  ${bin} restart [name] Restart the sandbox service
4519
4816
  ${bin} logs [name] Tail sandbox logs (follows)
@@ -4523,7 +4820,6 @@ Usage:
4523
4820
  ${bin} harness <args...> Install and inspect agent CLI harnesses
4524
4821
  ${bin} tool <args...> Install and inspect sandbox tooling
4525
4822
  ${bin} gateway <args...> Manage a messaging client session (pi|hermes)
4526
- ${bin} cloud <args...> Manage OpenHarness Cloud nodes
4527
4823
  ${bin} --version Print version
4528
4824
  ${bin} --help Show this help
4529
4825
 
@@ -4532,21 +4828,25 @@ ${integrationLines()}
4532
4828
  ${compatibilityNote(product)}`);
4533
4829
  }
4534
4830
  function printConfigHelp(bin = LEGACY_PRODUCT.bin) {
4535
- process.stdout.write(`${bin} config \u2014 Read and write oh.json, the tracked non-secret settings
4831
+ process.stdout.write(`${bin} config \u2014 Read and write ${stateNames(bin).configFile}, the tracked non-secret settings
4536
4832
 
4537
4833
  Usage:
4538
4834
  ${bin} config show [--sandbox <name>]
4539
- ${bin} config set <field> <value> [--sandbox <name>]
4835
+ ${bin} config set <field> <value> [--sandbox <name>] [--force]
4540
4836
  ${bin} config repo Create your own GitHub repo and point origin at it
4541
4837
  ${bin} config <integration> Run an integration wizard
4542
4838
  ${bin} config <integration> --help
4543
4839
 
4544
- oh.json holds every non-secret setting and is tracked by git. Credentials live
4840
+ ${stateNames(bin).configFile} holds every non-secret setting and is tracked by git. Credentials live
4545
4841
  in the gitignored root .env \u2014 write those with \`${bin} secret set <KEY>\`. Apply a
4546
4842
  change with \`${bin} stop <name> && ${bin} sandbox install docker --name <name>\`. Field
4547
4843
  reference:
4548
4844
  ${sourceDocsUrl("docs/configuration.md")}
4549
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
+
4550
4850
  Fields:
4551
4851
  ${configFieldList()}
4552
4852
 
@@ -4566,7 +4866,7 @@ the registry entry \`${bin} sandbox list\` names.
4566
4866
 
4567
4867
  The value is never read from the command line \u2014 an argument would land in your
4568
4868
  shell history. \`${bin} secret list\` never prints a raw value. .env is mode 0600 and
4569
- gitignored; every non-secret setting belongs in oh.json (\`${bin} config set\`).
4869
+ gitignored; every non-secret setting belongs in ${stateNames(bin).configFile} (\`${bin} config set\`).
4570
4870
 
4571
4871
  Keys:
4572
4872
  ${secretKeyList()}
@@ -4579,7 +4879,7 @@ Usage:
4579
4879
  ${bin} update [--dry-run]
4580
4880
 
4581
4881
  Upgrades exactly one thing: the ${bin} executable that is running. It writes no
4582
- project file \u2014 no .oh/, no oh.json, no .env \u2014 and it never asks for sudo.
4882
+ project file \u2014 no ${stateNames(bin).controlDir}/, no ${stateNames(bin).configFile}, no .env \u2014 and it never asks for sudo.
4583
4883
 
4584
4884
  The upgrade follows whichever mechanism installed this executable:
4585
4885
  npm-managed realpath under node_modules/${AGRO_PRODUCT.packageName}/: reads the registry
@@ -4603,7 +4903,7 @@ Flags:
4603
4903
  --dry-run Report the installation kind, target, and versions without
4604
4904
  changing anything.
4605
4905
 
4606
- Project payload vendoring (.oh/ + crons/) is \`oh update\` during the
4906
+ Project payload vendoring (${stateNames(bin).controlDir}/ + crons/) is \`oh update\` during the
4607
4907
  compatibility window; its --from, --from-remote, --ref and --force flags are
4608
4908
  not accepted here.
4609
4909
  `);
@@ -4613,22 +4913,22 @@ function printUpdateHelp(bin = LEGACY_PRODUCT.bin) {
4613
4913
  printSelfUpgradeHelp(bin);
4614
4914
  return;
4615
4915
  }
4616
- process.stdout.write(`${bin} update \u2014 Vendor or upgrade the .oh/ control plane
4916
+ process.stdout.write(`${bin} update \u2014 Vendor or upgrade the ${stateNames(bin).controlDir}/ control plane
4617
4917
 
4618
4918
  Usage:
4619
4919
  ${bin} update [--from <dir> | --from-remote [--ref <ref>]] [--dry-run] [--force]
4620
4920
 
4621
- Writes ONLY the .oh/ control plane and crons/ (skills, scripts, CLI) into the
4921
+ Writes ONLY the ${stateNames(bin).controlDir}/ control plane and crons/ (skills, scripts, CLI) into the
4622
4922
  current directory. An empty directory is equipped from scratch; everything else
4623
- in your project is left untouched \u2014 it writes no oh.json, .env, AGENTS.md,
4923
+ in your project is left untouched \u2014 it writes no ${stateNames(bin).configFile}, .env, AGENTS.md,
4624
4924
  .gitignore or .devcontainer/.
4625
4925
 
4626
4926
  Payload source precedence: --from <dir> > --from-remote > the CLI's own bundled
4627
- .oh/ payload > a remote fetch announced on one line.
4927
+ ${stateNames(bin).controlDir}/ payload > a remote fetch announced on one line.
4628
4928
 
4629
4929
  Flags:
4630
- --from <dir> A built OpenHarness checkout to vendor from.
4631
- --from-remote Fetch the source checkout from the public OpenHarness repo
4930
+ --from <dir> A built AGRO checkout to vendor from.
4931
+ --from-remote Fetch the source checkout from the public AGRO repo
4632
4932
  instead (shallow git clone into a temp dir, removed after
4633
4933
  the run). Conflicts with --from.
4634
4934
  --ref <ref> Branch or tag for --from-remote (default: the clone's
@@ -4647,28 +4947,38 @@ function printSandboxHelp(bin = LEGACY_PRODUCT.bin) {
4647
4947
  process.stdout.write(`${bin} sandbox \u2014 Create and list sandboxes
4648
4948
 
4649
4949
  Usage:
4650
- ${bin} sandbox install <runtime> [--name <name>] [--repo <dir>] [--yes]
4950
+ ${bin} sandbox install <runtime> [--name <name>] [--checkout <dir>]
4951
+ [--home-mount <dir>] [--yes]
4651
4952
  [--image[=<ref>]] [--no-build] [--print-argv]
4652
4953
  ${bin} sandbox list [--json]
4653
4954
 
4654
- \`install\` writes a sandbox entry under \${OH_HOME:-~/.oh}/sandboxes/<name>/,
4955
+ \`install\` writes a sandbox entry under \${${stateNames(bin).envPrefix}HOME:-~/${stateNames(bin).userStateDir}}/sandboxes/<name>/,
4655
4956
  materialises the compose files and the compose wrapper into it, then starts the
4656
4957
  container. It needs no project checkout and runs from any directory. Without
4657
- --repo the sandbox runs the prebuilt image; with --repo <dir> that checkout is
4658
- 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.
4659
4961
 
4660
4962
  On a terminal without --yes it asks for the sandbox name, the timezone, the git
4661
- identity, SSH (with its host port), and the host Docker socket. With --repo it
4662
- seeds those answers from that checkout's oh.json.
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}.
4663
4966
 
4664
4967
  Flags:
4665
- --name <name> Registry entry name (default: the lowest free oh-sbx-<n>)
4666
- --repo <dir> Bind this checkout into the sandbox and seed the defaults
4667
- from its oh.json
4968
+ --name <name> Registry entry name (default: the lowest free ${DEFAULT_NAME_PREFIX}<n>)
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
4668
4978
  --yes Non-interactive: keep every default and ask nothing
4669
4979
  --image[=<ref>] Run the prebuilt image instead of building (implies
4670
4980
  --no-build). Ref resolves last-wins: --image=<ref> >
4671
- oh.json image.ref > ghcr.io/mifunedev/openharness:latest.
4981
+ ${stateNames(bin).configFile} image.ref > ${DEFAULT_SANDBOX_IMAGE}.
4672
4982
  --no-build Suppress the local build and reuse an existing image
4673
4983
  --print-argv Print the docker compose argv that would run, then exit
4674
4984
  without writing an entry
@@ -4702,7 +5012,7 @@ Usage:
4702
5012
  ${bin} harness status [name] Show installed state
4703
5013
 
4704
5014
  \`install\` is the only door: it probes the running sandbox, installs the harness
4705
- into the persistent home volume, and reports. It reads and writes no \`oh.json\`
5015
+ into the persistent home volume, and reports. It reads and writes no \`${stateNames(bin).configFile}\`
4706
5016
  field, and it never rebuilds or restarts the sandbox. It requires a running
4707
5017
  sandbox \u2014 start one with \`${bin} sandbox\` first.
4708
5018
 
@@ -4723,7 +5033,7 @@ Usage:
4723
5033
  ${bin} gateway status show both sessions
4724
5034
 
4725
5035
  Only a LEADING --help/-h is intercepted here; everything else passes through
4726
- verbatim to the vendored .oh/scripts/gateway.sh with OH_PROJECT_ROOT set to
5036
+ verbatim to the vendored ${stateNames(bin).controlDir}/scripts/gateway.sh with ${stateNames(bin).envPrefix}PROJECT_ROOT set to
4727
5037
  the equipped project root. Exits with the script's exit code.
4728
5038
  `);
4729
5039
  }
@@ -4751,7 +5061,7 @@ Most tools are baked into the image and are report-only; \`install\` works on:
4751
5061
  ${installableToolIds().map((t) => ` ${t}`).join("\n")}
4752
5062
 
4753
5063
  \`install\` is the only door: it probes the running sandbox, installs the tool
4754
- into the persistent home volume, and reports. It reads and writes no \`oh.json\`
5064
+ into the persistent home volume, and reports. It reads and writes no \`${stateNames(bin).configFile}\`
4755
5065
  field, and it never rebuilds or restarts the sandbox. A large download is
4756
5066
  confirmed first, and a non-interactive run without --yes installs nothing.
4757
5067
 
@@ -4776,7 +5086,7 @@ function printComposeVerbHelp(verb, bin = LEGACY_PRODUCT.bin) {
4776
5086
  Usage:
4777
5087
  ${bin} ${verb} [name] [-- <extra docker compose args>]
4778
5088
 
4779
- Runs .oh/scripts/docker-compose.sh inside the sandbox entry, the single
5089
+ Runs ${stateNames(bin).controlDir}/scripts/docker-compose.sh inside the sandbox entry, the single
4780
5090
  implementation. \`${bin}\` is the only lifecycle door. [name] is a sandbox entry from
4781
5091
  \`${bin} sandbox list\`; with exactly one registered sandbox it can be omitted.
4782
5092
 
@@ -4789,7 +5099,7 @@ function printDestroyHelp(bin = LEGACY_PRODUCT.bin) {
4789
5099
  Usage:
4790
5100
  ${bin} destroy [name] [--yes]
4791
5101
 
4792
- Runs .oh/scripts/docker-compose.sh with \`down -v\`. This is the one destructive
5102
+ Runs ${stateNames(bin).controlDir}/scripts/docker-compose.sh with \`down -v\`. This is the one destructive
4793
5103
  lifecycle verb: \`-v\` deletes the named
4794
5104
  volumes, and those volumes hold every agent CLI login, the gh CLI token, and
4795
5105
  the SSH keys. Use \`${bin} stop\` when you only want the containers gone.
@@ -4812,11 +5122,11 @@ Usage:
4812
5122
  ${bin} compose config [-- <extra docker compose args>]
4813
5123
 
4814
5124
  Subcommands:
4815
- config Print the compose configuration .oh/scripts/docker-compose.sh
4816
- resolves from .devcontainer/.env and .oh/config.json
5125
+ config Print the compose configuration ${stateNames(bin).controlDir}/scripts/docker-compose.sh
5126
+ resolves from .devcontainer/.env and ${stateNames(bin).controlDir}/config.json
4817
5127
 
4818
5128
  Namespaced under \`${bin} compose\` because \`${bin} config <integration>\` already means
4819
- "run an integration wizard", and \`${bin} config show/set\` reads and writes oh.json.
5129
+ "run an integration wizard", and \`${bin} config show/set\` reads and writes ${stateNames(bin).configFile}.
4820
5130
 
4821
5131
  See ${sourceDocsUrl("docs/lifecycle-commands.md")} for every verb.
4822
5132
  `);
@@ -4842,11 +5152,13 @@ var CONFIG_VERBS = ["show", "set", "repo"];
4842
5152
  function parseConfigArgs(input, bin = LEGACY_PRODUCT.bin) {
4843
5153
  const scoped = extractSandboxFlag(`${bin} config`, input);
4844
5154
  if (!scoped.ok) return scoped;
4845
- const rest = scoped.args.rest;
5155
+ const forced = scoped.args.rest.includes("--force");
5156
+ const rest = scoped.args.rest.filter((arg) => arg !== "--force");
4846
5157
  const args = {
4847
5158
  help: false,
4848
5159
  integrationHelp: false,
4849
- ...scoped.args.sandbox === void 0 ? {} : { sandbox: scoped.args.sandbox }
5160
+ ...scoped.args.sandbox === void 0 ? {} : { sandbox: scoped.args.sandbox },
5161
+ ...forced ? { force: true } : {}
4850
5162
  };
4851
5163
  if (rest.length === 0 || isHelpFlag(rest[0])) {
4852
5164
  return { ok: true, args: { ...args, help: true } };
@@ -4962,14 +5274,14 @@ function parseComposeArgs(rest, bin = LEGACY_PRODUCT.bin) {
4962
5274
  args.subcommand = "config";
4963
5275
  const tail = rest.slice(1);
4964
5276
  if (isHelpFlag(tail[0])) return { ok: true, args: { ...args, help: true } };
4965
- const sep4 = tail.indexOf("--");
4966
- if (sep4 === -1 && tail.length > 0) {
5277
+ const sep5 = tail.indexOf("--");
5278
+ if (sep5 === -1 && tail.length > 0) {
4967
5279
  return {
4968
5280
  ok: false,
4969
5281
  error: `${bin} compose config: unexpected argument "${tail[0]}" \u2014 pass extra docker compose args after \`--\``
4970
5282
  };
4971
5283
  }
4972
- if (sep4 !== -1) args.passthrough = tail.slice(sep4 + 1);
5284
+ if (sep5 !== -1) args.passthrough = tail.slice(sep5 + 1);
4973
5285
  return { ok: true, args };
4974
5286
  }
4975
5287
  var PAYLOAD_UPDATE_FLAGS = ["--from", "--from-remote", "--ref", "--force"];
@@ -5033,7 +5345,9 @@ function parseUpdateArgs(rest, bin = LEGACY_PRODUCT.bin) {
5033
5345
  }
5034
5346
  var SANDBOX_VALUE_FLAGS = {
5035
5347
  "--name": "name",
5036
- "--repo": "repo"
5348
+ "--checkout": "checkout",
5349
+ "--repo": "checkout",
5350
+ "--home-mount": "homeMount"
5037
5351
  };
5038
5352
  function parseSandboxArgs(rest, bin = LEGACY_PRODUCT.bin) {
5039
5353
  const args = {
@@ -5058,6 +5372,7 @@ function parseSandboxArgs(rest, bin = LEGACY_PRODUCT.bin) {
5058
5372
  args.subcommand = head;
5059
5373
  if (isHelpFlag(tail[0])) return { ok: true, args: { ...args, help: true } };
5060
5374
  const positionals = [];
5375
+ const checkoutSpellings = /* @__PURE__ */ new Set();
5061
5376
  for (let i = 0; i < tail.length; i++) {
5062
5377
  const token = tail[i];
5063
5378
  const valueFlag = SANDBOX_VALUE_FLAGS[token];
@@ -5066,6 +5381,7 @@ function parseSandboxArgs(rest, bin = LEGACY_PRODUCT.bin) {
5066
5381
  if (value === void 0) {
5067
5382
  return { ok: false, error: `${bin} sandbox ${head}: ${token} requires a value` };
5068
5383
  }
5384
+ if (valueFlag === "checkout") checkoutSpellings.add(token);
5069
5385
  args[valueFlag] = value;
5070
5386
  i++;
5071
5387
  } else if (token === "--yes") {
@@ -5091,6 +5407,12 @@ function parseSandboxArgs(rest, bin = LEGACY_PRODUCT.bin) {
5091
5407
  positionals.push(token);
5092
5408
  }
5093
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
+ }
5094
5416
  if (head === "list") {
5095
5417
  if (positionals.length > 0) {
5096
5418
  return { ok: false, error: `${bin} sandbox list: unexpected argument "${positionals[0]}"` };
@@ -5209,13 +5531,13 @@ function bundledPayloadExists(bundled, exists = existsSync16) {
5209
5531
  function resolveUpdateSource(args, bundled, bin = LEGACY_PRODUCT.bin) {
5210
5532
  const exists = bundled.exists ?? existsSync16;
5211
5533
  if (args.fromDir !== void 0) {
5212
- return { kind: "local", fromDir: resolve8(args.fromDir) };
5534
+ return { kind: "local", fromDir: resolve10(args.fromDir) };
5213
5535
  }
5214
5536
  if (args.fromRemote) {
5215
5537
  return { kind: "remote", ref: args.ref };
5216
5538
  }
5217
5539
  if (bundledPayloadExists(bundled, exists)) {
5218
- return { kind: "local", fromDir: resolve8(bundled.sourceOhDir, "..") };
5540
+ return { kind: "local", fromDir: resolve10(bundled.sourceOhDir, "..") };
5219
5541
  }
5220
5542
  return {
5221
5543
  kind: "remote",
@@ -5227,7 +5549,7 @@ function resolveUpdateSource(args, bundled, bin = LEGACY_PRODUCT.bin) {
5227
5549
  function readPayloadVersion(checkoutDir) {
5228
5550
  try {
5229
5551
  const parsed = JSON.parse(
5230
- readFileSync11(join10(checkoutDir, ".oh", "cli", "package.json"), "utf8")
5552
+ readFileSync11(join10(resolveControlDir(checkoutDir).path, "cli", "package.json"), "utf8")
5231
5553
  );
5232
5554
  if (parsed && typeof parsed.version === "string") return parsed.version;
5233
5555
  } catch {
@@ -5280,9 +5602,13 @@ async function main(argv) {
5280
5602
  stdout: (s) => process.stdout.write(s),
5281
5603
  stderr: (s) => process.stderr.write(s)
5282
5604
  };
5283
- 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
+ };
5284
5610
  if (a.verb === "show") return await runConfigShow(scope, io);
5285
- if (a.verb === "repo") return await runConfigRepo({}, io);
5611
+ if (a.verb === "repo") return await runConfigRepo({ bin }, io);
5286
5612
  return await runConfigSet(a.key, a.value, scope, io);
5287
5613
  }
5288
5614
  const name = a.integration;
@@ -5317,10 +5643,28 @@ async function main(argv) {
5317
5643
  stdout: (s) => process.stdout.write(s),
5318
5644
  stderr: (s) => process.stderr.write(s)
5319
5645
  };
5320
- const scope = a.sandbox === void 0 ? {} : { sandbox: a.sandbox };
5646
+ const scope = a.sandbox === void 0 ? { bin } : { bin, sandbox: a.sandbox };
5321
5647
  if (a.verb === "list") return await runSecretList(scope, io);
5322
5648
  return await runSecretSet(a.key, scope, io);
5323
5649
  }
5650
+ if (first === "migrate") {
5651
+ const parsed = parseMigrateArgs(argv.slice(1), bin);
5652
+ if (!parsed.ok) {
5653
+ process.stderr.write(`${parsed.error}
5654
+ `);
5655
+ if (parsed.showHelp) printMigrateHelp(bin);
5656
+ return 1;
5657
+ }
5658
+ if (parsed.args.help) {
5659
+ printMigrateHelp(bin);
5660
+ return 0;
5661
+ }
5662
+ const io = {
5663
+ stdout: (s) => process.stdout.write(s),
5664
+ stderr: (s) => process.stderr.write(s)
5665
+ };
5666
+ return runMigrate(parsed.args, io);
5667
+ }
5324
5668
  if (first === "update") {
5325
5669
  const parsed = parseUpdateArgs(argv.slice(1), bin);
5326
5670
  if (!parsed.ok) {
@@ -5344,12 +5688,12 @@ async function main(argv) {
5344
5688
  const targetDir = process.cwd();
5345
5689
  const source = resolveUpdateSource(parsed.args, { sourceOhDir: DEFAULT_SOURCE_OH_DIR }, bin);
5346
5690
  if (source.kind === "local") {
5347
- return await runUpdate({ targetDir, fromDir: source.fromDir, force, dryRun }, io);
5691
+ return await runUpdate({ bin, targetDir, fromDir: source.fromDir, force, dryRun }, io);
5348
5692
  }
5349
5693
  if (source.notice) process.stdout.write(source.notice);
5350
5694
  return await runWithRemoteSource(
5351
5695
  { ref: source.ref },
5352
- (checkoutDir) => runUpdate({ targetDir, fromDir: checkoutDir, force, dryRun }, io)
5696
+ (checkoutDir) => runUpdate({ bin, targetDir, fromDir: checkoutDir, force, dryRun }, io)
5353
5697
  );
5354
5698
  }
5355
5699
  if (first === "sandbox") {
@@ -5366,12 +5710,14 @@ async function main(argv) {
5366
5710
  return a.subcommand === void 0 ? 1 : 0;
5367
5711
  }
5368
5712
  const io = lifecycleIo();
5369
- if (a.subcommand === "list") return await runSandboxList({ json: a.json }, io);
5713
+ if (a.subcommand === "list") return await runSandboxList({ bin, json: a.json }, io);
5370
5714
  return await runSandboxInstall(
5371
5715
  {
5716
+ bin,
5372
5717
  runtime: a.runtime,
5373
5718
  ...a.name !== void 0 ? { name: a.name } : {},
5374
- ...a.repo !== void 0 ? { repo: a.repo } : {},
5719
+ ...a.checkout !== void 0 ? { checkout: a.checkout } : {},
5720
+ ...a.homeMount !== void 0 ? { homeMount: a.homeMount } : {},
5375
5721
  yes: a.yes,
5376
5722
  image: a.image,
5377
5723
  ...a.imageRef !== void 0 ? { imageRef: a.imageRef } : {},
@@ -5393,7 +5739,7 @@ async function main(argv) {
5393
5739
  return 0;
5394
5740
  }
5395
5741
  return runShell(
5396
- parsed.args.name === void 0 ? {} : { name: parsed.args.name },
5742
+ parsed.args.name === void 0 ? { bin } : { bin, name: parsed.args.name },
5397
5743
  lifecycleIo()
5398
5744
  );
5399
5745
  }
@@ -5410,6 +5756,7 @@ async function main(argv) {
5410
5756
  }
5411
5757
  return await runDestroy(
5412
5758
  {
5759
+ bin,
5413
5760
  yes: parsed.args.yes,
5414
5761
  ...parsed.args.name !== void 0 ? { name: parsed.args.name } : {}
5415
5762
  },
@@ -5428,7 +5775,7 @@ async function main(argv) {
5428
5775
  printComposeHelp(bin);
5429
5776
  return 0;
5430
5777
  }
5431
- return runComposeConfig({}, parsed.args.passthrough);
5778
+ return runComposeConfig({ bin }, parsed.args.passthrough);
5432
5779
  }
5433
5780
  if (composeVerbs().includes(first)) {
5434
5781
  const verb = first;
@@ -5437,9 +5784,9 @@ async function main(argv) {
5437
5784
  printComposeVerbHelp(verb, bin);
5438
5785
  return 0;
5439
5786
  }
5440
- const sep4 = rest.indexOf("--");
5441
- const head = sep4 === -1 ? rest : rest.slice(0, sep4);
5442
- const extra = sep4 === -1 ? [] : rest.slice(sep4 + 1);
5787
+ const sep5 = rest.indexOf("--");
5788
+ const head = sep5 === -1 ? rest : rest.slice(0, sep5);
5789
+ const extra = sep5 === -1 ? [] : rest.slice(sep5 + 1);
5443
5790
  if (head.length > 0 && head[0].startsWith("-")) {
5444
5791
  process.stderr.write(`${bin} ${verb}: unknown flag "${head[0]}"
5445
5792
  `);
@@ -5452,7 +5799,7 @@ async function main(argv) {
5452
5799
  );
5453
5800
  return 1;
5454
5801
  }
5455
- return runComposeVerb(verb, head.length === 0 ? {} : { name: head[0] }, extra);
5802
+ return runComposeVerb(verb, head.length === 0 ? { bin } : { bin, name: head[0] }, extra);
5456
5803
  }
5457
5804
  if (first === "harness") {
5458
5805
  const parsed = parseHarnessArgs(argv.slice(1), bin);
@@ -5472,12 +5819,12 @@ async function main(argv) {
5472
5819
  stderr: (s) => process.stderr.write(s)
5473
5820
  };
5474
5821
  if (a.subcommand === "list") {
5475
- return await runHarnessList({ json: a.json }, io);
5822
+ return await runHarnessList({ bin, json: a.json }, io);
5476
5823
  }
5477
5824
  if (a.subcommand === "status") {
5478
- return await runHarnessStatus(a.name, { json: a.json }, io);
5825
+ return await runHarnessStatus(a.name, { bin, json: a.json }, io);
5479
5826
  }
5480
- return await runHarnessInstall(a.name, {}, io);
5827
+ return await runHarnessInstall(a.name, { bin }, io);
5481
5828
  }
5482
5829
  if (first === "tool") {
5483
5830
  const parsed = parseToolArgs(argv.slice(1), bin);
@@ -5497,18 +5844,12 @@ async function main(argv) {
5497
5844
  stderr: (s) => process.stderr.write(s)
5498
5845
  };
5499
5846
  if (a.subcommand === "list") {
5500
- return await runToolList({ json: a.json }, io);
5847
+ return await runToolList({ bin, json: a.json }, io);
5501
5848
  }
5502
5849
  if (a.subcommand === "status") {
5503
- return await runToolStatus(a.name, { json: a.json }, io);
5850
+ return await runToolStatus(a.name, { bin, json: a.json }, io);
5504
5851
  }
5505
- return await runToolInstall(a.name, { yes: a.yes }, io);
5506
- }
5507
- if (first === "cloud") {
5508
- return await runCloud(argv.slice(1), {
5509
- stdout: (s) => process.stdout.write(s),
5510
- stderr: (s) => process.stderr.write(s)
5511
- });
5852
+ return await runToolInstall(a.name, { bin, yes: a.yes }, io);
5512
5853
  }
5513
5854
  if (first === "gateway") {
5514
5855
  const parsed = parseGatewayArgs(argv.slice(1));
@@ -5521,7 +5862,7 @@ async function main(argv) {
5521
5862
  printGatewayHelp(bin);
5522
5863
  return 0;
5523
5864
  }
5524
- return runGateway(parsed.args.passthrough, {});
5865
+ return runGateway(parsed.args.passthrough, { bin });
5525
5866
  }
5526
5867
  process.stderr.write(`${bin}: unknown command "${first}"
5527
5868