@getmonoceros/workbench 1.20.2 → 1.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -1415,6 +1415,12 @@ var init_global = __esm({
1415
1415
  hostPort: z2.number().int().min(1).max(65535).optional().describe(
1416
1416
  "Host port the Traefik singleton binds. Default 80. Set this when 80 is held by another service on your machine \u2014 URLs then become http://<name>.localhost:<port>/."
1417
1417
  )
1418
+ }).nullish(),
1419
+ // Tool-freshness settings (ADR 0018). One machine-global knob.
1420
+ upgrade: z2.object({
1421
+ staleDays: z2.number().int().min(1).optional().describe(
1422
+ "Days after the last `monoceros upgrade` before `apply` nudges you to refresh tooling. Default 30."
1423
+ )
1418
1424
  }).nullish()
1419
1425
  });
1420
1426
  DEFAULT_PROXY_HOST_PORT = 80;
@@ -2098,9 +2104,84 @@ var init_service_doc = __esm({
2098
2104
  }
2099
2105
  });
2100
2106
 
2101
- // src/create/scaffold.ts
2102
- import { existsSync as existsSync5, readFileSync as readFileSync4, promises as fs7 } from "fs";
2107
+ // src/create/claude-settings.ts
2108
+ import { existsSync as existsSync5, promises as fsp2 } from "fs";
2103
2109
  import path8 from "path";
2110
+ function resolveClaudeDefaultMode(raw) {
2111
+ switch ((raw ?? "auto").trim()) {
2112
+ case "ask":
2113
+ case "default":
2114
+ return "default";
2115
+ case "edits":
2116
+ case "acceptEdits":
2117
+ return "acceptEdits";
2118
+ case "plan":
2119
+ return "plan";
2120
+ case "bypass":
2121
+ case "bypassPermissions":
2122
+ return "bypassPermissions";
2123
+ case "auto":
2124
+ case "":
2125
+ return "auto";
2126
+ default:
2127
+ return "auto";
2128
+ }
2129
+ }
2130
+ async function writeClaudePermissionMode(targetDir, features) {
2131
+ if (!features) return;
2132
+ const entry2 = Object.entries(features).find(
2133
+ ([ref]) => matchMonocerosFeature(ref)?.name === "claude-code"
2134
+ );
2135
+ if (!entry2) return;
2136
+ const raw = entry2[1]?.permissionMode;
2137
+ const mode = resolveClaudeDefaultMode(
2138
+ typeof raw === "string" ? raw : void 0
2139
+ );
2140
+ const file = path8.join(targetDir, "home", ".claude", "settings.json");
2141
+ await fsp2.mkdir(path8.dirname(file), { recursive: true });
2142
+ let config = {};
2143
+ if (existsSync5(file)) {
2144
+ try {
2145
+ const txt = await fsp2.readFile(file, "utf8");
2146
+ if (txt.trim()) {
2147
+ const parsed = JSON.parse(txt);
2148
+ if (typeof parsed === "object" && parsed !== null) {
2149
+ config = parsed;
2150
+ }
2151
+ }
2152
+ } catch {
2153
+ config = {};
2154
+ }
2155
+ }
2156
+ const permissions = typeof config.permissions === "object" && config.permissions !== null ? config.permissions : {};
2157
+ permissions.defaultMode = mode;
2158
+ config.permissions = permissions;
2159
+ const env = typeof config.env === "object" && config.env !== null ? config.env : {};
2160
+ if (mode === "auto") {
2161
+ env.CLAUDE_CODE_ENABLE_AUTO_MODE = "1";
2162
+ } else {
2163
+ delete env.CLAUDE_CODE_ENABLE_AUTO_MODE;
2164
+ }
2165
+ if (Object.keys(env).length > 0) config.env = env;
2166
+ else delete config.env;
2167
+ if (mode === "bypassPermissions") {
2168
+ config.skipDangerousModePermissionPrompt = true;
2169
+ } else {
2170
+ delete config.skipDangerousModePermissionPrompt;
2171
+ }
2172
+ await fsp2.writeFile(file, `${JSON.stringify(config, null, 2)}
2173
+ `);
2174
+ }
2175
+ var init_claude_settings = __esm({
2176
+ "src/create/claude-settings.ts"() {
2177
+ "use strict";
2178
+ init_ref();
2179
+ }
2180
+ });
2181
+
2182
+ // src/create/scaffold.ts
2183
+ import { existsSync as existsSync6, readFileSync as readFileSync4, promises as fs7 } from "fs";
2184
+ import path9 from "path";
2104
2185
  function deriveRepoName(url) {
2105
2186
  const lastSep = Math.max(url.lastIndexOf("/"), url.lastIndexOf(":"));
2106
2187
  const tail = url.slice(lastSep + 1);
@@ -2230,7 +2311,7 @@ function featuresSourceRoot() {
2230
2311
  const override2 = process.env.MONOCEROS_FEATURES_DIR_OVERRIDE?.trim();
2231
2312
  if (override2 && override2.length > 0) return override2;
2232
2313
  const checkout = workbenchCheckoutRoot();
2233
- return checkout ? path8.join(checkout, "images", "features") : null;
2314
+ return checkout ? path9.join(checkout, "images", "features") : null;
2234
2315
  }
2235
2316
  function resolveFeatures(opts) {
2236
2317
  const resolved = [];
@@ -2265,8 +2346,8 @@ function resolveFeatures(opts) {
2265
2346
  if (match) {
2266
2347
  const name = match.name;
2267
2348
  const sourceRoot = featuresSourceRoot();
2268
- const localSourceDir = sourceRoot ? path8.join(sourceRoot, name) : null;
2269
- if (localSourceDir && existsSync5(localSourceDir)) {
2349
+ const localSourceDir = sourceRoot ? path9.join(sourceRoot, name) : null;
2350
+ if (localSourceDir && existsSync6(localSourceDir)) {
2270
2351
  const { paths: paths2, files: files2 } = readPersistentHomeEntries(localSourceDir);
2271
2352
  resolved.push({
2272
2353
  devcontainerKey: `./features/${name}`,
@@ -2298,7 +2379,7 @@ function resolveFeatures(opts) {
2298
2379
  return resolved;
2299
2380
  }
2300
2381
  function readPersistentHomeEntries(localSourceDir) {
2301
- const manifestPath = path8.join(localSourceDir, "devcontainer-feature.json");
2382
+ const manifestPath = path9.join(localSourceDir, "devcontainer-feature.json");
2302
2383
  try {
2303
2384
  const text = readFileSync4(manifestPath, "utf8");
2304
2385
  const parsed = JSON.parse(text);
@@ -2312,7 +2393,7 @@ function readPersistentHomeEntries(localSourceDir) {
2312
2393
  }
2313
2394
  function readBundledPersistentHomeEntries(name) {
2314
2395
  try {
2315
- return readPersistentHomeEntries(path8.join(bundledFeaturesDir(), name));
2396
+ return readPersistentHomeEntries(path9.join(bundledFeaturesDir(), name));
2316
2397
  } catch {
2317
2398
  return { paths: [], files: [] };
2318
2399
  }
@@ -2718,7 +2799,7 @@ function buildPostCreateScript(opts) {
2718
2799
  return lines.join("\n") + "\n";
2719
2800
  }
2720
2801
  async function writePostCreateScript(devcontainerDir, opts) {
2721
- const dest = path8.join(devcontainerDir, "post-create.sh");
2802
+ const dest = path9.join(devcontainerDir, "post-create.sh");
2722
2803
  await fs7.writeFile(dest, buildPostCreateScript(opts));
2723
2804
  await fs7.chmod(dest, 493);
2724
2805
  }
@@ -2732,11 +2813,11 @@ async function writeIfChanged(filePath, content) {
2732
2813
  }
2733
2814
  async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2734
2815
  const dockerMode = scaffoldOpts.dockerMode ?? "rootful";
2735
- const devcontainerDir = path8.join(targetDir, ".devcontainer");
2736
- const monocerosDir = path8.join(targetDir, ".monoceros");
2737
- const projectsDir = path8.join(targetDir, "projects");
2738
- const homeDir = path8.join(targetDir, "home");
2739
- const dataDir = path8.join(targetDir, "data");
2816
+ const devcontainerDir = path9.join(targetDir, ".devcontainer");
2817
+ const monocerosDir = path9.join(targetDir, ".monoceros");
2818
+ const projectsDir = path9.join(targetDir, "projects");
2819
+ const homeDir = path9.join(targetDir, "home");
2820
+ const dataDir = path9.join(targetDir, "data");
2740
2821
  await fs7.mkdir(devcontainerDir, { recursive: true });
2741
2822
  await fs7.mkdir(monocerosDir, { recursive: true });
2742
2823
  await fs7.mkdir(projectsDir, { recursive: true });
@@ -2746,59 +2827,60 @@ async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2746
2827
  for (const svc of opts.services) {
2747
2828
  const hasDataVolume = svc.volumes.some((v) => v.split(":")[0] === "data");
2748
2829
  if (hasDataVolume) {
2749
- await fs7.mkdir(path8.join(dataDir, svc.name), { recursive: true });
2830
+ await fs7.mkdir(path9.join(dataDir, svc.name), { recursive: true });
2750
2831
  }
2751
2832
  }
2752
2833
  }
2753
- const containerGitignore = path8.join(targetDir, ".gitignore");
2834
+ const containerGitignore = path9.join(targetDir, ".gitignore");
2754
2835
  await fs7.writeFile(
2755
2836
  containerGitignore,
2756
2837
  "/home/\n/.monoceros/\n/data/\n/AGENTS.md\n/CLAUDE.md\n"
2757
2838
  );
2758
- const gitkeep = path8.join(projectsDir, ".gitkeep");
2759
- if (!existsSync5(gitkeep)) {
2839
+ const gitkeep = path9.join(projectsDir, ".gitkeep");
2840
+ if (!existsSync6(gitkeep)) {
2760
2841
  await fs7.writeFile(gitkeep, "");
2761
2842
  }
2762
2843
  await fs7.writeFile(
2763
- path8.join(monocerosDir, ".gitignore"),
2844
+ path9.join(monocerosDir, ".gitignore"),
2764
2845
  "git-credentials*\ngitconfig\n"
2765
2846
  );
2766
2847
  const devcontainerJson = buildDevcontainerJson(opts, dockerMode);
2767
2848
  await writeIfChanged(
2768
- path8.join(devcontainerDir, "devcontainer.json"),
2849
+ path9.join(devcontainerDir, "devcontainer.json"),
2769
2850
  JSON.stringify(devcontainerJson, null, 2) + "\n"
2770
2851
  );
2771
- const featuresDir = path8.join(devcontainerDir, "features");
2772
- if (existsSync5(featuresDir)) {
2852
+ const featuresDir = path9.join(devcontainerDir, "features");
2853
+ if (existsSync6(featuresDir)) {
2773
2854
  await fs7.rm(featuresDir, { recursive: true, force: true });
2774
2855
  }
2775
2856
  const resolvedFeatures = resolveFeatures(opts);
2776
2857
  for (const f of resolvedFeatures) {
2777
2858
  if (!f.localSourceDir || !f.localName) continue;
2778
- const dest = path8.join(featuresDir, f.localName);
2859
+ const dest = path9.join(featuresDir, f.localName);
2779
2860
  await fs7.mkdir(dest, { recursive: true });
2780
2861
  await fs7.cp(f.localSourceDir, dest, { recursive: true });
2781
2862
  }
2782
2863
  for (const f of resolvedFeatures) {
2783
2864
  for (const sub of f.persistentHomePaths) {
2784
- await fs7.mkdir(path8.join(homeDir, sub), { recursive: true });
2865
+ await fs7.mkdir(path9.join(homeDir, sub), { recursive: true });
2785
2866
  }
2786
2867
  for (const entry2 of f.persistentHomeFiles) {
2787
- const filePath = path8.join(homeDir, entry2.path);
2788
- await fs7.mkdir(path8.dirname(filePath), { recursive: true });
2789
- if (!existsSync5(filePath)) {
2868
+ const filePath = path9.join(homeDir, entry2.path);
2869
+ await fs7.mkdir(path9.dirname(filePath), { recursive: true });
2870
+ if (!existsSync6(filePath)) {
2790
2871
  await fs7.writeFile(filePath, entry2.initialContent);
2791
2872
  }
2792
2873
  }
2793
2874
  }
2875
+ await writeClaudePermissionMode(targetDir, opts.features);
2794
2876
  await writePostCreateScript(devcontainerDir, opts);
2795
- const composePath = path8.join(devcontainerDir, "compose.yaml");
2877
+ const composePath = path9.join(devcontainerDir, "compose.yaml");
2796
2878
  if (needsCompose(opts)) {
2797
2879
  await writeIfChanged(composePath, buildComposeYaml(opts, dockerMode));
2798
- } else if (existsSync5(composePath)) {
2880
+ } else if (existsSync6(composePath)) {
2799
2881
  await fs7.rm(composePath);
2800
2882
  }
2801
- const workspacePath = path8.join(targetDir, `${opts.name}.code-workspace`);
2883
+ const workspacePath = path9.join(targetDir, `${opts.name}.code-workspace`);
2802
2884
  let existingWorkspace;
2803
2885
  try {
2804
2886
  const raw = await fs7.readFile(workspacePath, "utf8");
@@ -2809,8 +2891,8 @@ async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2809
2891
  const generated = buildCodeWorkspaceJson(opts);
2810
2892
  const merged = mergeCodeWorkspace(existingWorkspace, generated);
2811
2893
  await fs7.writeFile(workspacePath, JSON.stringify(merged, null, 2) + "\n");
2812
- const vscodeDir = path8.join(targetDir, ".vscode");
2813
- const settingsPath = path8.join(vscodeDir, "settings.json");
2894
+ const vscodeDir = path9.join(targetDir, ".vscode");
2895
+ const settingsPath = path9.join(vscodeDir, "settings.json");
2814
2896
  let existingSettings;
2815
2897
  try {
2816
2898
  existingSettings = JSON.parse(await fs7.readFile(settingsPath, "utf8"));
@@ -2829,6 +2911,7 @@ var init_scaffold = __esm({
2829
2911
  "use strict";
2830
2912
  init_paths();
2831
2913
  init_ref();
2914
+ init_claude_settings();
2832
2915
  init_catalog();
2833
2916
  APT_PACKAGE_NAME_RE2 = /^[a-z0-9][a-z0-9.+-]*$/;
2834
2917
  FEATURE_REF_RE2 = /^[a-z0-9.-]+(\/[a-z0-9._-]+)+:[a-z0-9._-]+$/;
@@ -3259,8 +3342,8 @@ function removeRepoFromDoc(doc, urlOrPath) {
3259
3342
  if (!isMap2(item)) return false;
3260
3343
  const url = item.get("url");
3261
3344
  if (url === urlOrPath) return true;
3262
- const path25 = item.get("path");
3263
- const effectivePath = typeof path25 === "string" ? path25 : typeof url === "string" ? deriveRepoName(url) : void 0;
3345
+ const path27 = item.get("path");
3346
+ const effectivePath = typeof path27 === "string" ? path27 : typeof url === "string" ? deriveRepoName(url) : void 0;
3264
3347
  return effectivePath === urlOrPath;
3265
3348
  });
3266
3349
  if (idx < 0) return false;
@@ -3297,7 +3380,7 @@ var init_yml = __esm({
3297
3380
  import { promises as fs8 } from "fs";
3298
3381
  import { consola } from "consola";
3299
3382
  import { createPatch } from "diff";
3300
- import path9 from "path";
3383
+ import path10 from "path";
3301
3384
  function runAddLanguage(input) {
3302
3385
  if (!BUILTIN_LANGUAGES.has(input.language) && !LANGUAGE_CATALOG[input.language]) {
3303
3386
  throw new Error(
@@ -3372,7 +3455,7 @@ async function runAddRepo(input) {
3372
3455
  "Missing repo URL. Usage: monoceros add-repo <containername> <url>."
3373
3456
  );
3374
3457
  }
3375
- const path25 = (input.path ?? deriveRepoName(url)).trim();
3458
+ const path27 = (input.path ?? deriveRepoName(url)).trim();
3376
3459
  const hasName = typeof input.gitName === "string" && input.gitName.trim().length > 0;
3377
3460
  const hasEmail = typeof input.gitEmail === "string" && input.gitEmail.trim().length > 0;
3378
3461
  if (hasName !== hasEmail) {
@@ -3409,7 +3492,7 @@ async function runAddRepo(input) {
3409
3492
  const providerToWrite = !canonical && explicitProvider ? explicitProvider : void 0;
3410
3493
  const entry2 = {
3411
3494
  url,
3412
- path: path25,
3495
+ path: path27,
3413
3496
  ...hasName && hasEmail ? {
3414
3497
  gitUser: {
3415
3498
  name: input.gitName.trim(),
@@ -3541,7 +3624,7 @@ async function tryCloneInRunningContainer(input, entry2) {
3541
3624
  logger.info(
3542
3625
  `Cloned ${entry2.url} into /workspaces/${containerName2}/${targetRel} inside the running container.`
3543
3626
  );
3544
- void path9;
3627
+ void path10;
3545
3628
  }
3546
3629
  function shquote(value) {
3547
3630
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -4323,7 +4406,7 @@ var init_add_service = __esm({
4323
4406
 
4324
4407
  // src/config/state.ts
4325
4408
  import { promises as fs9 } from "fs";
4326
- import path10 from "path";
4409
+ import path11 from "path";
4327
4410
  function buildStateFile(opts) {
4328
4411
  return {
4329
4412
  schemaVersion: CONFIG_SCHEMA_VERSION,
@@ -4334,7 +4417,7 @@ function buildStateFile(opts) {
4334
4417
  };
4335
4418
  }
4336
4419
  function stateFilePath(targetDir) {
4337
- return path10.join(targetDir, ".monoceros", "state.json");
4420
+ return path11.join(targetDir, ".monoceros", "state.json");
4338
4421
  }
4339
4422
  async function readStateFile(targetDir) {
4340
4423
  try {
@@ -4345,7 +4428,7 @@ async function readStateFile(targetDir) {
4345
4428
  }
4346
4429
  }
4347
4430
  async function writeStateFile(targetDir, state) {
4348
- const monocerosDir = path10.join(targetDir, ".monoceros");
4431
+ const monocerosDir = path11.join(targetDir, ".monoceros");
4349
4432
  await fs9.mkdir(monocerosDir, { recursive: true });
4350
4433
  await fs9.writeFile(
4351
4434
  stateFilePath(targetDir),
@@ -4436,7 +4519,7 @@ var init_transform = __esm({
4436
4519
 
4437
4520
  // src/apply/apply-log.ts
4438
4521
  import { createWriteStream, mkdirSync } from "fs";
4439
- import path11 from "path";
4522
+ import path12 from "path";
4440
4523
  import { Writable } from "stream";
4441
4524
  function safeIsoStamp(d) {
4442
4525
  return d.toISOString().replace(/[:.]/g, "-");
@@ -4446,7 +4529,7 @@ function createApplyLog(opts) {
4446
4529
  const dir = containerLogsDir(opts.name, opts.home);
4447
4530
  mkdirSync(dir, { recursive: true });
4448
4531
  const file = `apply-${opts.name}-${safeIsoStamp(now)}.log`;
4449
- const fullPath = path11.join(dir, file);
4532
+ const fullPath = path12.join(dir, file);
4450
4533
  const stream = createWriteStream(fullPath, { flags: "w" });
4451
4534
  const header = [
4452
4535
  `# monoceros apply log`,
@@ -5290,7 +5373,7 @@ var init_markers = __esm({
5290
5373
 
5291
5374
  // src/briefing/index.ts
5292
5375
  import { promises as fs10 } from "fs";
5293
- import path12 from "path";
5376
+ import path13 from "path";
5294
5377
  async function writeBriefing(input) {
5295
5378
  const subCommands = input.subCommands ?? await loadSubCommandsDynamic();
5296
5379
  const manifestLoader = input.manifestLoader ?? ((ref) => loadFeatureManifestSummary(ref));
@@ -5303,12 +5386,12 @@ async function writeBriefing(input) {
5303
5386
  );
5304
5387
  const claudeBody = generateClaudeMd();
5305
5388
  const commandsBody = generateCommandsMd(subCommands);
5306
- await writeMarkerAware(path12.join(input.targetDir, "AGENTS.md"), agentsBody);
5307
- await writeMarkerAware(path12.join(input.targetDir, "CLAUDE.md"), claudeBody);
5308
- const monocerosDir = path12.join(input.targetDir, ".monoceros");
5389
+ await writeMarkerAware(path13.join(input.targetDir, "AGENTS.md"), agentsBody);
5390
+ await writeMarkerAware(path13.join(input.targetDir, "CLAUDE.md"), claudeBody);
5391
+ const monocerosDir = path13.join(input.targetDir, ".monoceros");
5309
5392
  await fs10.mkdir(monocerosDir, { recursive: true });
5310
5393
  await fs10.writeFile(
5311
- path12.join(monocerosDir, "commands.md"),
5394
+ path13.join(monocerosDir, "commands.md"),
5312
5395
  commandsBody,
5313
5396
  "utf8"
5314
5397
  );
@@ -5467,7 +5550,7 @@ var init_runtime_pull_hint = __esm({
5467
5550
  import { spawn as spawn4 } from "child_process";
5468
5551
  import { readFileSync as readFileSync5 } from "fs";
5469
5552
  import { createRequire } from "module";
5470
- import path13 from "path";
5553
+ import path14 from "path";
5471
5554
  function devcontainerCliPath() {
5472
5555
  if (cachedBinaryPath) return cachedBinaryPath;
5473
5556
  const pkgJsonPath = require_.resolve("@devcontainers/cli/package.json");
@@ -5476,7 +5559,7 @@ function devcontainerCliPath() {
5476
5559
  if (!binEntry) {
5477
5560
  throw new Error("Could not resolve @devcontainers/cli bin entry.");
5478
5561
  }
5479
- cachedBinaryPath = path13.resolve(path13.dirname(pkgJsonPath), binEntry);
5562
+ cachedBinaryPath = path14.resolve(path14.dirname(pkgJsonPath), binEntry);
5480
5563
  return cachedBinaryPath;
5481
5564
  }
5482
5565
  var require_, cachedBinaryPath, spawnDevcontainer;
@@ -5550,8 +5633,9 @@ var init_cli = __esm({
5550
5633
 
5551
5634
  // src/devcontainer/compose.ts
5552
5635
  import { spawn as spawn5 } from "child_process";
5553
- import { existsSync as existsSync6 } from "fs";
5554
- import path14 from "path";
5636
+ import { existsSync as existsSync7 } from "fs";
5637
+ import path15 from "path";
5638
+ import { Writable as Writable3 } from "stream";
5555
5639
  import { consola as consola9 } from "consola";
5556
5640
  async function findContainerIds(filters, exec = spawnDocker) {
5557
5641
  const ids = /* @__PURE__ */ new Set();
@@ -5591,16 +5675,16 @@ async function cleanupDockerObjects(opts) {
5591
5675
  return { exitCode: rmExit, removedIds: ids };
5592
5676
  }
5593
5677
  function composeProjectName(root) {
5594
- return `${path14.basename(root)}_devcontainer`;
5678
+ return `${path15.basename(root)}_devcontainer`;
5595
5679
  }
5596
5680
  function resolveCompose(root) {
5597
- if (!existsSync6(path14.join(root, ".devcontainer"))) {
5681
+ if (!existsSync7(path15.join(root, ".devcontainer"))) {
5598
5682
  throw new Error(
5599
5683
  `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
5600
5684
  );
5601
5685
  }
5602
- const composeFile = path14.join(root, ".devcontainer", "compose.yaml");
5603
- if (!existsSync6(composeFile)) {
5686
+ const composeFile = path15.join(root, ".devcontainer", "compose.yaml");
5687
+ if (!existsSync7(composeFile)) {
5604
5688
  throw new Error(
5605
5689
  `No compose.yaml at ${composeFile}. \`start\` / \`stop\` / \`status\` / \`logs\` require services configured via \`monoceros add-service <name> <svc>\`. Use \`monoceros shell <name>\` to enter the container directly.`
5606
5690
  );
@@ -5619,7 +5703,13 @@ async function runStart(opts) {
5619
5703
  const spawnFn = opts.spawn ?? spawnDevcontainer;
5620
5704
  logger.info(`Bringing devcontainer up at ${opts.root}\u2026`);
5621
5705
  return spawnFn(
5622
- ["up", "--workspace-folder", opts.root, "--mount-workspace-git-root=false"],
5706
+ [
5707
+ "up",
5708
+ "--workspace-folder",
5709
+ opts.root,
5710
+ "--mount-workspace-git-root=false",
5711
+ ...opts.noCache ? ["--build-no-cache"] : []
5712
+ ],
5623
5713
  opts.root,
5624
5714
  buildSpawnOptions(opts)
5625
5715
  );
@@ -5631,14 +5721,62 @@ function buildSpawnOptions(opts) {
5631
5721
  if (opts.silent) out.silent = true;
5632
5722
  return Object.keys(out).length > 0 ? out : void 0;
5633
5723
  }
5724
+ async function runUpWithBindRetry(attempt, baseSink, logger, opts = {}) {
5725
+ const delayMs = opts.delayMs ?? BIND_RETRY_DELAY_MS;
5726
+ let code = 0;
5727
+ for (let i = 1; i <= BIND_RETRY_ATTEMPTS; i += 1) {
5728
+ let captured = "";
5729
+ const sink = new Writable3({
5730
+ write(chunk, _enc, cb) {
5731
+ captured += chunk.toString();
5732
+ if (baseSink) baseSink.write(chunk);
5733
+ cb();
5734
+ }
5735
+ });
5736
+ code = await attempt(sink);
5737
+ if (code === 0) return 0;
5738
+ if (i < BIND_RETRY_ATTEMPTS && BIND_SOURCE_MISSING_RE.test(captured)) {
5739
+ logger.info(
5740
+ `Bind source not visible yet (Docker Desktop file sync); nudging + retrying\u2026 (${i}/${BIND_RETRY_ATTEMPTS - 1})`
5741
+ );
5742
+ if (opts.onBindRetry) {
5743
+ try {
5744
+ await opts.onBindRetry();
5745
+ } catch {
5746
+ }
5747
+ }
5748
+ if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
5749
+ continue;
5750
+ }
5751
+ return code;
5752
+ }
5753
+ return code;
5754
+ }
5634
5755
  async function runContainerCycle(root, opts) {
5635
5756
  const { hasCompose, logger } = opts;
5757
+ const exec = opts.dockerExec ?? spawnDocker;
5758
+ const onBindRetry = opts.prewarmImage ? async () => {
5759
+ await exec([
5760
+ "run",
5761
+ "--rm",
5762
+ "--entrypoint",
5763
+ "sh",
5764
+ "--mount",
5765
+ `source=${root},target=/w,type=bind`,
5766
+ opts.prewarmImage,
5767
+ "-lc",
5768
+ "ls -laR /w/home >/dev/null 2>&1 || true"
5769
+ ]);
5770
+ } : void 0;
5771
+ const bindRetry = {
5772
+ ...opts.bindRetryDelayMs !== void 0 ? { delayMs: opts.bindRetryDelayMs } : {},
5773
+ ...onBindRetry ? { onBindRetry } : {}
5774
+ };
5636
5775
  if (hasCompose) {
5637
5776
  const projectName = composeProjectName(root);
5638
5777
  logger.info(
5639
5778
  `Force-removing existing ${projectName} containers (volumes preserved)\u2026`
5640
5779
  );
5641
- const exec = opts.dockerExec ?? spawnDocker;
5642
5780
  const filters = [
5643
5781
  `label=com.docker.compose.project=${projectName}`,
5644
5782
  `name=^${projectName}-`
@@ -5663,27 +5801,43 @@ and retry \`monoceros apply\`.`
5663
5801
  );
5664
5802
  return 1;
5665
5803
  }
5666
- return runStart({
5667
- root,
5668
- ...opts.devcontainerSpawn ? { spawn: opts.devcontainerSpawn } : {},
5669
- ...opts.logSink ? { logSink: opts.logSink } : {},
5670
- ...opts.progressSink ? { progressSink: opts.progressSink } : {},
5671
- ...opts.silent ? { silent: true } : {},
5672
- logger
5673
- });
5804
+ return runUpWithBindRetry(
5805
+ (logSink) => runStart({
5806
+ root,
5807
+ ...opts.devcontainerSpawn ? { spawn: opts.devcontainerSpawn } : {},
5808
+ logSink,
5809
+ ...opts.progressSink ? { progressSink: opts.progressSink } : {},
5810
+ ...opts.silent ? { silent: true } : {},
5811
+ ...opts.noCache ? { noCache: true } : {},
5812
+ logger
5813
+ }),
5814
+ opts.logSink,
5815
+ logger,
5816
+ bindRetry
5817
+ );
5674
5818
  }
5675
5819
  logger.info(`Recreating image-mode devcontainer at ${root}\u2026`);
5676
5820
  const spawnFn = opts.devcontainerSpawn ?? spawnDevcontainer;
5677
- return spawnFn(
5678
- [
5679
- "up",
5680
- "--workspace-folder",
5821
+ return runUpWithBindRetry(
5822
+ (logSink) => spawnFn(
5823
+ [
5824
+ "up",
5825
+ "--workspace-folder",
5826
+ root,
5827
+ "--mount-workspace-git-root=false",
5828
+ "--remove-existing-container",
5829
+ ...opts.noCache ? ["--build-no-cache"] : []
5830
+ ],
5681
5831
  root,
5682
- "--mount-workspace-git-root=false",
5683
- "--remove-existing-container"
5684
- ],
5685
- root,
5686
- buildSpawnOptions(opts)
5832
+ {
5833
+ logSink,
5834
+ ...opts.progressSink ? { progressSink: opts.progressSink } : {},
5835
+ ...opts.silent ? { silent: true } : {}
5836
+ }
5837
+ ),
5838
+ opts.logSink,
5839
+ logger,
5840
+ bindRetry
5687
5841
  );
5688
5842
  }
5689
5843
  function runStop(opts) {
@@ -5709,7 +5863,7 @@ function runLogs(opts) {
5709
5863
  opts
5710
5864
  );
5711
5865
  }
5712
- var spawnDockerCompose, spawnDocker;
5866
+ var spawnDockerCompose, spawnDocker, BIND_SOURCE_MISSING_RE, BIND_RETRY_ATTEMPTS, BIND_RETRY_DELAY_MS;
5713
5867
  var init_compose = __esm({
5714
5868
  "src/devcontainer/compose.ts"() {
5715
5869
  "use strict";
@@ -5749,6 +5903,102 @@ var init_compose = __esm({
5749
5903
  );
5750
5904
  });
5751
5905
  };
5906
+ BIND_SOURCE_MISSING_RE = /bind source path does not exist/i;
5907
+ BIND_RETRY_ATTEMPTS = 3;
5908
+ BIND_RETRY_DELAY_MS = 500;
5909
+ }
5910
+ });
5911
+
5912
+ // src/devcontainer/images.ts
5913
+ async function resolveContainerImageId(root, exec = spawnDocker) {
5914
+ const ids = await findContainerIds(
5915
+ [`label=devcontainer.local_folder=${root}`],
5916
+ exec
5917
+ );
5918
+ const containerId = ids[0];
5919
+ if (!containerId) return null;
5920
+ const res = await exec(["inspect", "--format", "{{.Image}}", containerId]);
5921
+ if (res.exitCode !== 0) return null;
5922
+ const imageId = res.stdout.trim();
5923
+ return imageId || null;
5924
+ }
5925
+ async function removeImage(imageId, exec = spawnDocker) {
5926
+ try {
5927
+ const res = await exec(["rmi", imageId]);
5928
+ if (res.exitCode === 0) return "removed";
5929
+ const err = (res.stderr ?? "").toLowerCase();
5930
+ if (err.includes("no such image")) return "absent";
5931
+ if (err.includes("is being used") || err.includes("conflict") || err.includes("in use")) {
5932
+ return "in-use";
5933
+ }
5934
+ return "error";
5935
+ } catch {
5936
+ return "error";
5937
+ }
5938
+ }
5939
+ var init_images = __esm({
5940
+ "src/devcontainer/images.ts"() {
5941
+ "use strict";
5942
+ init_proxy();
5943
+ init_compose();
5944
+ }
5945
+ });
5946
+
5947
+ // src/config/machine-state.ts
5948
+ import { promises as fsp3 } from "fs";
5949
+ import path16 from "path";
5950
+ function machineStatePath(home = monocerosHome()) {
5951
+ return path16.join(home, ".machine-state.json");
5952
+ }
5953
+ async function readMachineState(home = monocerosHome()) {
5954
+ try {
5955
+ const raw = await fsp3.readFile(machineStatePath(home), "utf8");
5956
+ const parsed = JSON.parse(raw);
5957
+ if (typeof parsed === "object" && parsed !== null) {
5958
+ return parsed;
5959
+ }
5960
+ } catch {
5961
+ }
5962
+ return {};
5963
+ }
5964
+ async function writeMachineState(state, home = monocerosHome()) {
5965
+ await fsp3.writeFile(
5966
+ machineStatePath(home),
5967
+ `${JSON.stringify(state, null, 2)}
5968
+ `
5969
+ );
5970
+ }
5971
+ async function recordBuiltImage(record, home = monocerosHome()) {
5972
+ const state = await readMachineState(home);
5973
+ const rest = (state.builtImages ?? []).filter(
5974
+ (r) => r.imageId !== record.imageId
5975
+ );
5976
+ state.builtImages = [...rest, record];
5977
+ await writeMachineState(state, home);
5978
+ }
5979
+ async function markUpgraded(nowIso, home = monocerosHome()) {
5980
+ const state = await readMachineState(home);
5981
+ state.lastUpgradeAt = nowIso;
5982
+ await writeMachineState(state, home);
5983
+ }
5984
+ function daysBetween(fromIso, now) {
5985
+ const from = new Date(fromIso).getTime();
5986
+ if (!Number.isFinite(from)) return 0;
5987
+ const ms = now.getTime() - from;
5988
+ return Math.max(0, Math.floor(ms / 864e5));
5989
+ }
5990
+ function upgradeNudge(state, now, thresholdDays = DEFAULT_UPGRADE_STALE_DAYS) {
5991
+ if (!state.lastUpgradeAt) return null;
5992
+ const days = daysBetween(state.lastUpgradeAt, now);
5993
+ if (days < thresholdDays) return null;
5994
+ return `Tools last refreshed ${days} days ago. Run \`monoceros upgrade\` to update them.`;
5995
+ }
5996
+ var DEFAULT_UPGRADE_STALE_DAYS;
5997
+ var init_machine_state = __esm({
5998
+ "src/config/machine-state.ts"() {
5999
+ "use strict";
6000
+ init_paths();
6001
+ DEFAULT_UPGRADE_STALE_DAYS = 30;
5752
6002
  }
5753
6003
  });
5754
6004
 
@@ -5824,7 +6074,7 @@ var init_docker_mode = __esm({
5824
6074
  // src/devcontainer/identity.ts
5825
6075
  import { spawn as spawn7 } from "child_process";
5826
6076
  import { promises as fs11 } from "fs";
5827
- import path15 from "path";
6077
+ import path17 from "path";
5828
6078
  import { consola as consola10 } from "consola";
5829
6079
  async function resolveIdentityWithPrompt(options = {}) {
5830
6080
  const spawnFn = options.spawn ?? realGitConfigGet;
@@ -5880,8 +6130,8 @@ async function resolveIdentityWithPrompt(options = {}) {
5880
6130
  };
5881
6131
  }
5882
6132
  async function collectGitIdentity(devContainerRoot, options = {}) {
5883
- const gitconfigDir = path15.join(devContainerRoot, ".monoceros");
5884
- const gitconfigPath = path15.join(gitconfigDir, "gitconfig");
6133
+ const gitconfigDir = path17.join(devContainerRoot, ".monoceros");
6134
+ const gitconfigPath = path17.join(gitconfigDir, "gitconfig");
5885
6135
  const logger = options.logger ?? { info: () => {
5886
6136
  }, warn: () => {
5887
6137
  } };
@@ -6015,7 +6265,7 @@ var init_identity = __esm({
6015
6265
  });
6016
6266
 
6017
6267
  // src/apply/index.ts
6018
- import { existsSync as existsSync7, promises as fs12 } from "fs";
6268
+ import { existsSync as existsSync8, promises as fs12 } from "fs";
6019
6269
  import { consola as consola11 } from "consola";
6020
6270
  async function runApply(opts) {
6021
6271
  const home = opts.monocerosHome ?? monocerosHome();
@@ -6037,7 +6287,7 @@ ${sectionLine(label)}
6037
6287
  );
6038
6288
  }
6039
6289
  const ymlPath = containerConfigPath(opts.name, home);
6040
- if (!existsSync7(ymlPath)) {
6290
+ if (!existsSync8(ymlPath)) {
6041
6291
  throw new Error(
6042
6292
  `No such config: ${ymlPath}. Run \`monoceros init <template> ${opts.name}\` first.`
6043
6293
  );
@@ -6236,6 +6486,8 @@ Fix the value in the env file (or the yml).`
6236
6486
  }
6237
6487
  exitCode = await runContainerCycle(targetDir, {
6238
6488
  hasCompose: needsCompose(createOpts),
6489
+ ...opts.rebuild ? { noCache: true } : {},
6490
+ prewarmImage: resolveRuntimeImage(createOpts.runtimeVersion),
6239
6491
  ...opts.dockerExec !== void 0 ? { dockerExec: opts.dockerExec } : {},
6240
6492
  ...opts.devcontainerSpawn !== void 0 ? { devcontainerSpawn: opts.devcontainerSpawn } : {},
6241
6493
  logSink: applyLog.sink,
@@ -6267,6 +6519,30 @@ ${formatted}
6267
6519
  `);
6268
6520
  applyLog.stream.write(`
6269
6521
  ${stripAnsi(formatted)}
6522
+ `);
6523
+ }
6524
+ const now = opts.now ?? /* @__PURE__ */ new Date();
6525
+ try {
6526
+ const imageId = await resolveContainerImageId(
6527
+ targetDir,
6528
+ opts.dockerExec ?? defaultDockerExec
6529
+ );
6530
+ if (imageId) {
6531
+ await recordBuiltImage(
6532
+ { imageId, container: opts.name, builtAt: now.toISOString() },
6533
+ home
6534
+ );
6535
+ }
6536
+ } catch {
6537
+ }
6538
+ const nudge = upgradeNudge(
6539
+ await readMachineState(home),
6540
+ now,
6541
+ globalConfig?.upgrade?.staleDays ?? DEFAULT_UPGRADE_STALE_DAYS
6542
+ );
6543
+ if (nudge) {
6544
+ progressOut.write(`
6545
+ ${dim(nudge)}
6270
6546
  `);
6271
6547
  }
6272
6548
  }
@@ -6284,7 +6560,7 @@ ${stripAnsi(formatted)}
6284
6560
  return { targetDir, configPath: ymlPath, containerExitCode: exitCode };
6285
6561
  }
6286
6562
  async function assertSafeTargetDir(targetDir, expectedOrigin) {
6287
- if (!existsSync7(targetDir)) return;
6563
+ if (!existsSync8(targetDir)) return;
6288
6564
  const entries = await fs12.readdir(targetDir);
6289
6565
  if (entries.length === 0) return;
6290
6566
  const state = await readStateFile(targetDir);
@@ -6395,6 +6671,8 @@ var init_apply = __esm({
6395
6671
  init_briefing();
6396
6672
  init_components();
6397
6673
  init_compose();
6674
+ init_images();
6675
+ init_machine_state();
6398
6676
  init_credentials();
6399
6677
  init_docker_mode();
6400
6678
  init_cli();
@@ -6412,7 +6690,7 @@ var CLI_VERSION;
6412
6690
  var init_version = __esm({
6413
6691
  "src/version.ts"() {
6414
6692
  "use strict";
6415
- CLI_VERSION = true ? "1.20.2" : "dev";
6693
+ CLI_VERSION = true ? "1.21.1" : "dev";
6416
6694
  }
6417
6695
  });
6418
6696
 
@@ -6602,8 +6880,8 @@ var init_completion = __esm({
6602
6880
  });
6603
6881
 
6604
6882
  // src/completion/resolve.ts
6605
- import { existsSync as existsSync8, promises as fs13 } from "fs";
6606
- import path16 from "path";
6883
+ import { existsSync as existsSync9, promises as fs13 } from "fs";
6884
+ import path18 from "path";
6607
6885
  async function resolveCompletions(line, point, opts = {}) {
6608
6886
  const { prev, current } = parseCompletionLine(line, point);
6609
6887
  const ctx = { prev, current, opts };
@@ -6752,8 +7030,8 @@ function filterPrefix(values, fragment) {
6752
7030
  }
6753
7031
  async function listContainerNames(ctx) {
6754
7032
  const home = ctx.opts.monocerosHome ?? monocerosHome();
6755
- const dir = path16.join(home, "container-configs");
6756
- if (!existsSync8(dir)) return [];
7033
+ const dir = path18.join(home, "container-configs");
7034
+ if (!existsSync9(dir)) return [];
6757
7035
  const entries = await fs13.readdir(dir);
6758
7036
  return entries.filter((e) => e.endsWith(".yml")).map((e) => e.slice(0, -".yml".length)).sort();
6759
7037
  }
@@ -7043,9 +7321,12 @@ function generateComposedYml(name, composed, lookupManifest, repoUrls = [], port
7043
7321
  lines.push("schemaVersion: 1");
7044
7322
  lines.push(`name: ${name}`);
7045
7323
  lines.push(
7046
- "# Pinned runtime image version. Reused on every apply; change it with"
7324
+ "# Pinned runtime base image, reused on every apply (never auto-bumped)."
7325
+ );
7326
+ lines.push(
7327
+ "# `monoceros upgrade <name>` refreshes the tooling and moves this to the"
7047
7328
  );
7048
- lines.push("# `monoceros upgrade <name> [version]`.");
7329
+ lines.push("# latest runtime when a newer one exists.");
7049
7330
  lines.push(`runtimeVersion: ${DEFAULT_RUNTIME_VERSION}`);
7050
7331
  lines.push("");
7051
7332
  if (composed.languages.length > 0) {
@@ -7146,9 +7427,12 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
7146
7427
  lines.push("schemaVersion: 1");
7147
7428
  lines.push(`name: ${name}`);
7148
7429
  lines.push(
7149
- "# Pinned runtime image version. Reused on every apply; change it with"
7430
+ "# Pinned runtime base image, reused on every apply (never auto-bumped)."
7150
7431
  );
7151
- lines.push("# `monoceros upgrade <name> [version]`.");
7432
+ lines.push(
7433
+ "# `monoceros upgrade <name>` refreshes the tooling and moves this to the"
7434
+ );
7435
+ lines.push("# latest runtime when a newer one exists.");
7152
7436
  lines.push(`runtimeVersion: ${DEFAULT_RUNTIME_VERSION}`);
7153
7437
  lines.push("");
7154
7438
  if (byCategory.language.length > 0) {
@@ -7400,8 +7684,8 @@ var init_generator = __esm({
7400
7684
  });
7401
7685
 
7402
7686
  // src/init/index.ts
7403
- import { existsSync as existsSync9, promises as fs14 } from "fs";
7404
- import path17 from "path";
7687
+ import { existsSync as existsSync10, promises as fs14 } from "fs";
7688
+ import path19 from "path";
7405
7689
  import { consola as consola13 } from "consola";
7406
7690
  async function runInit(opts) {
7407
7691
  const workbench = opts.workbenchRoot ?? workbenchRoot();
@@ -7416,7 +7700,7 @@ async function runInit(opts) {
7416
7700
  );
7417
7701
  }
7418
7702
  const dest = containerConfigPath(opts.name, home);
7419
- if (existsSync9(dest)) {
7703
+ if (existsSync10(dest)) {
7420
7704
  throw new Error(
7421
7705
  `Config already exists: ${dest}. Delete it manually before re-running \`monoceros init\` \u2014 this protects any hand-edits.`
7422
7706
  );
@@ -7512,8 +7796,8 @@ async function runInit(opts) {
7512
7796
  }
7513
7797
  await ensureEnvVars(envPath, opts.name, seedVars);
7514
7798
  const documented = !anyComposed;
7515
- const ymlRel = path17.relative(home, dest);
7516
- const envRel = path17.relative(home, envPath);
7799
+ const ymlRel = path19.relative(home, dest);
7800
+ const envRel = path19.relative(home, envPath);
7517
7801
  if (documented) {
7518
7802
  logger.success(`Wrote documented default to ${ymlRel} and ${envRel}.`);
7519
7803
  logger.info(
@@ -7875,8 +8159,8 @@ var init_list_components = __esm({
7875
8159
 
7876
8160
  // src/commands/logs.ts
7877
8161
  import { spawn as spawn8 } from "child_process";
7878
- import { existsSync as existsSync10 } from "fs";
7879
- import path18 from "path";
8162
+ import { existsSync as existsSync11 } from "fs";
8163
+ import path20 from "path";
7880
8164
  import { defineCommand as defineCommand13 } from "citty";
7881
8165
  function tailLogFile(file, follow) {
7882
8166
  const [cmd, args] = follow ? ["tail", ["-F", file]] : ["cat", [file]];
@@ -7919,8 +8203,8 @@ var init_logs = __esm({
7919
8203
  run({ args }) {
7920
8204
  const service = typeof args.service === "string" ? args.service : void 0;
7921
8205
  if (service) {
7922
- const logFile = path18.join(containerLogsDir(args.name), `${service}.log`);
7923
- if (existsSync10(logFile)) {
8206
+ const logFile = path20.join(containerLogsDir(args.name), `${service}.log`);
8207
+ if (existsSync11(logFile)) {
7924
8208
  return dispatch(() => tailLogFile(logFile, args.follow));
7925
8209
  }
7926
8210
  }
@@ -8127,8 +8411,8 @@ var init_remove_feature = __esm({
8127
8411
  });
8128
8412
 
8129
8413
  // src/remove/index.ts
8130
- import { existsSync as existsSync11, promises as fs15 } from "fs";
8131
- import path19 from "path";
8414
+ import { existsSync as existsSync12, promises as fs15 } from "fs";
8415
+ import path21 from "path";
8132
8416
  import { consola as consola19 } from "consola";
8133
8417
  async function runRemove(opts) {
8134
8418
  const home = opts.monocerosHome ?? monocerosHome();
@@ -8145,9 +8429,9 @@ async function runRemove(opts) {
8145
8429
  const ymlPath = containerConfigPath(opts.name, home);
8146
8430
  const envPath = containerEnvPath(opts.name, home);
8147
8431
  const containerPath = containerDir(opts.name, home);
8148
- const hasYml = existsSync11(ymlPath);
8149
- const hasEnv = existsSync11(envPath);
8150
- const hasContainer = existsSync11(containerPath);
8432
+ const hasYml = existsSync12(ymlPath);
8433
+ const hasEnv = existsSync12(envPath);
8434
+ const hasContainer = existsSync12(containerPath);
8151
8435
  if (!hasYml && !hasContainer) {
8152
8436
  throw new Error(
8153
8437
  `Nothing to remove for '${opts.name}': neither ${ymlPath} nor ${containerPath} exists.`
@@ -8173,16 +8457,16 @@ async function runRemove(opts) {
8173
8457
  let backupPath = null;
8174
8458
  if (!opts.noBackup && (hasYml || hasContainer)) {
8175
8459
  const ts = (opts.now ?? /* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
8176
- backupPath = path19.join(home, "container-backups", `${opts.name}-${ts}`);
8460
+ backupPath = path21.join(home, "container-backups", `${opts.name}-${ts}`);
8177
8461
  await fs15.mkdir(backupPath, { recursive: true });
8178
8462
  if (hasYml) {
8179
- await fs15.copyFile(ymlPath, path19.join(backupPath, `${opts.name}.yml`));
8463
+ await fs15.copyFile(ymlPath, path21.join(backupPath, `${opts.name}.yml`));
8180
8464
  }
8181
8465
  if (hasEnv) {
8182
- await fs15.copyFile(envPath, path19.join(backupPath, `${opts.name}.env`));
8466
+ await fs15.copyFile(envPath, path21.join(backupPath, `${opts.name}.env`));
8183
8467
  }
8184
8468
  if (hasContainer) {
8185
- await fs15.cp(containerPath, path19.join(backupPath, "container"), {
8469
+ await fs15.cp(containerPath, path21.join(backupPath, "container"), {
8186
8470
  recursive: true
8187
8471
  });
8188
8472
  }
@@ -8342,8 +8626,8 @@ var init_remove2 = __esm({
8342
8626
  });
8343
8627
 
8344
8628
  // src/restore/index.ts
8345
- import { existsSync as existsSync12, promises as fs16 } from "fs";
8346
- import path20 from "path";
8629
+ import { existsSync as existsSync13, promises as fs16 } from "fs";
8630
+ import path22 from "path";
8347
8631
  import { consola as consola21 } from "consola";
8348
8632
  async function runRestore(opts) {
8349
8633
  const home = opts.monocerosHome ?? monocerosHome();
@@ -8351,8 +8635,8 @@ async function runRestore(opts) {
8351
8635
  info: (msg) => consola21.info(msg),
8352
8636
  success: (msg) => consola21.success(msg)
8353
8637
  };
8354
- const backup = path20.resolve(opts.backupPath);
8355
- if (!existsSync12(backup)) {
8638
+ const backup = path22.resolve(opts.backupPath);
8639
+ if (!existsSync13(backup)) {
8356
8640
  throw new Error(`Backup not found: ${backup}.`);
8357
8641
  }
8358
8642
  const stat = await fs16.stat(backup);
@@ -8373,24 +8657,24 @@ async function runRestore(opts) {
8373
8657
  }
8374
8658
  const ymlFile = ymlFiles[0];
8375
8659
  const name = ymlFile.replace(/\.yml$/, "");
8376
- const containerInBackup = path20.join(backup, "container");
8377
- const hasContainer = existsSync12(containerInBackup);
8378
- const envInBackup = path20.join(backup, `${name}.env`);
8379
- const hasEnv = existsSync12(envInBackup);
8660
+ const containerInBackup = path22.join(backup, "container");
8661
+ const hasContainer = existsSync13(containerInBackup);
8662
+ const envInBackup = path22.join(backup, `${name}.env`);
8663
+ const hasEnv = existsSync13(envInBackup);
8380
8664
  const destYml = containerConfigPath(name, home);
8381
8665
  const destContainer = containerDir(name, home);
8382
- if (existsSync12(destYml)) {
8666
+ if (existsSync13(destYml)) {
8383
8667
  throw new Error(
8384
8668
  `Refusing to restore: ${destYml} already exists. Remove the current container first (\`monoceros remove ${name}\`) or rename the existing config.`
8385
8669
  );
8386
8670
  }
8387
- if (hasContainer && existsSync12(destContainer)) {
8671
+ if (hasContainer && existsSync13(destContainer)) {
8388
8672
  throw new Error(
8389
8673
  `Refusing to restore: ${destContainer} already exists. Remove the current container first (\`monoceros remove ${name}\`).`
8390
8674
  );
8391
8675
  }
8392
8676
  await fs16.mkdir(containerConfigsDir(home), { recursive: true });
8393
- await fs16.copyFile(path20.join(backup, ymlFile), destYml);
8677
+ await fs16.copyFile(path22.join(backup, ymlFile), destYml);
8394
8678
  if (hasEnv) {
8395
8679
  await fs16.copyFile(envInBackup, containerEnvPath(name, home));
8396
8680
  }
@@ -8701,9 +8985,9 @@ var init_remove_service = __esm({
8701
8985
 
8702
8986
  // src/devcontainer/browser-bridge.ts
8703
8987
  import { spawn as spawn9 } from "child_process";
8704
- import { existsSync as existsSync13, promises as fsp2, readFileSync as readFileSync6 } from "fs";
8988
+ import { existsSync as existsSync14, promises as fsp4, readFileSync as readFileSync6 } from "fs";
8705
8989
  import http from "http";
8706
- import path21 from "path";
8990
+ import path23 from "path";
8707
8991
  function parseCallbackTarget(authUrl) {
8708
8992
  try {
8709
8993
  const u = new URL(authUrl);
@@ -8733,12 +9017,12 @@ function openInBrowser(url) {
8733
9017
  }
8734
9018
  }
8735
9019
  async function startBrowserBridge(opts) {
8736
- const relayDir = path21.join(opts.root, RELAY_DIRNAME);
8737
- const relayScript = path21.join(relayDir, "xdg-open");
8738
- const urlFile = path21.join(relayDir, "url");
8739
- await fsp2.mkdir(relayDir, { recursive: true });
8740
- await fsp2.rm(urlFile, { force: true });
8741
- await fsp2.writeFile(
9020
+ const relayDir = path23.join(opts.root, RELAY_DIRNAME);
9021
+ const relayScript = path23.join(relayDir, "xdg-open");
9022
+ const urlFile = path23.join(relayDir, "url");
9023
+ await fsp4.mkdir(relayDir, { recursive: true });
9024
+ await fsp4.rm(urlFile, { force: true });
9025
+ await fsp4.writeFile(
8742
9026
  relayScript,
8743
9027
  `#!/bin/sh
8744
9028
  printf '%s\\n' "$1" > "$(dirname "$0")/url"
@@ -8746,7 +9030,7 @@ exit 0
8746
9030
  `,
8747
9031
  { mode: 493 }
8748
9032
  );
8749
- await fsp2.chmod(relayScript, 493);
9033
+ await fsp4.chmod(relayScript, 493);
8750
9034
  const servers = [];
8751
9035
  let handled = false;
8752
9036
  const onUrl = (url) => {
@@ -8779,7 +9063,7 @@ exit 0
8779
9063
  servers.push(server);
8780
9064
  };
8781
9065
  const poll = setInterval(() => {
8782
- if (handled || !existsSync13(urlFile)) return;
9066
+ if (handled || !existsSync14(urlFile)) return;
8783
9067
  let content = "";
8784
9068
  try {
8785
9069
  content = readFileSync6(urlFile, "utf8");
@@ -8795,7 +9079,7 @@ exit 0
8795
9079
  async dispose() {
8796
9080
  clearInterval(poll);
8797
9081
  for (const s of servers) s.close();
8798
- await fsp2.rm(relayDir, { recursive: true, force: true });
9082
+ await fsp4.rm(relayDir, { recursive: true, force: true });
8799
9083
  }
8800
9084
  };
8801
9085
  }
@@ -8808,18 +9092,18 @@ var init_browser_bridge = __esm({
8808
9092
  });
8809
9093
 
8810
9094
  // src/devcontainer/claude-trust.ts
8811
- import { existsSync as existsSync14, promises as fsp3 } from "fs";
8812
- import path22 from "path";
9095
+ import { existsSync as existsSync15, promises as fsp5 } from "fs";
9096
+ import path24 from "path";
8813
9097
  function resolveContainerCwd(name, cwd) {
8814
9098
  const workspace = `/workspaces/${name}`;
8815
9099
  if (!cwd) return workspace;
8816
- return path22.posix.isAbsolute(cwd) ? cwd : path22.posix.join(workspace, cwd);
9100
+ return path24.posix.isAbsolute(cwd) ? cwd : path24.posix.join(workspace, cwd);
8817
9101
  }
8818
9102
  async function preApproveClaudeProject(opts) {
8819
- const file = path22.join(opts.root, "home", ".claude.json");
8820
- if (!existsSync14(file)) return;
9103
+ const file = path24.join(opts.root, "home", ".claude.json");
9104
+ if (!existsSync15(file)) return;
8821
9105
  try {
8822
- const raw = await fsp3.readFile(file, "utf8");
9106
+ const raw = await fsp5.readFile(file, "utf8");
8823
9107
  const config = raw.trim() ? JSON.parse(raw) : {};
8824
9108
  if (typeof config !== "object" || config === null) return;
8825
9109
  const dir = resolveContainerCwd(opts.name, opts.cwd);
@@ -8839,7 +9123,7 @@ async function preApproveClaudeProject(opts) {
8839
9123
  if (typeof entry2.projectOnboardingSeenCount !== "number") {
8840
9124
  entry2.projectOnboardingSeenCount = 1;
8841
9125
  }
8842
- await fsp3.writeFile(file, `${JSON.stringify(config, null, 2)}
9126
+ await fsp5.writeFile(file, `${JSON.stringify(config, null, 2)}
8843
9127
  `);
8844
9128
  } catch {
8845
9129
  }
@@ -8851,8 +9135,8 @@ var init_claude_trust = __esm({
8851
9135
  });
8852
9136
 
8853
9137
  // src/devcontainer/shell.ts
8854
- import { existsSync as existsSync15 } from "fs";
8855
- import path23 from "path";
9138
+ import { existsSync as existsSync16 } from "fs";
9139
+ import path25 from "path";
8856
9140
  async function runShell(opts) {
8857
9141
  assertContainerExists(opts.root);
8858
9142
  const spawnFn = opts.spawn ?? spawnDevcontainer;
@@ -8875,7 +9159,7 @@ async function runShell(opts) {
8875
9159
  );
8876
9160
  }
8877
9161
  function assertContainerExists(root) {
8878
- if (!existsSync15(path23.join(root, ".devcontainer"))) {
9162
+ if (!existsSync16(path25.join(root, ".devcontainer"))) {
8879
9163
  throw new Error(
8880
9164
  `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
8881
9165
  );
@@ -9193,11 +9477,11 @@ var init_stop = __esm({
9193
9477
  });
9194
9478
 
9195
9479
  // src/tunnel/resolve.ts
9196
- import { existsSync as existsSync16 } from "fs";
9197
- import path24 from "path";
9480
+ import { existsSync as existsSync17 } from "fs";
9481
+ import path26 from "path";
9198
9482
  async function resolveTunnelTarget(opts) {
9199
9483
  const ymlPath = containerConfigPath(opts.name, opts.monocerosHome);
9200
- if (!existsSync16(ymlPath)) {
9484
+ if (!existsSync17(ymlPath)) {
9201
9485
  throw new Error(
9202
9486
  `No yml profile for '${opts.name}' at ${ymlPath}. Run \`monoceros init ${opts.name}\` first.`
9203
9487
  );
@@ -9205,13 +9489,13 @@ async function resolveTunnelTarget(opts) {
9205
9489
  const parsed = await readConfig(ymlPath);
9206
9490
  const config = parsed.config;
9207
9491
  const containerRoot = containerDir(opts.name, opts.monocerosHome);
9208
- if (!existsSync16(containerRoot)) {
9492
+ if (!existsSync17(containerRoot)) {
9209
9493
  throw new Error(
9210
9494
  `Container '${opts.name}' is not materialised at ${containerRoot}. Run \`monoceros apply ${opts.name}\` first.`
9211
9495
  );
9212
9496
  }
9213
- const composePath = path24.join(containerRoot, ".devcontainer", "compose.yaml");
9214
- const isCompose = existsSync16(composePath);
9497
+ const composePath = path26.join(containerRoot, ".devcontainer", "compose.yaml");
9498
+ const isCompose = existsSync17(composePath);
9215
9499
  const parsedTarget = parseTargetArg(opts.target, config);
9216
9500
  const docker = opts.docker ?? defaultDockerExec;
9217
9501
  if (isCompose) {
@@ -9629,8 +9913,69 @@ var init_tunnel = __esm({
9629
9913
  }
9630
9914
  });
9631
9915
 
9916
+ // src/upgrade/prune.ts
9917
+ function selectStaleImages(registry, currentContainerNames) {
9918
+ const byContainer = /* @__PURE__ */ new Map();
9919
+ for (const rec of registry) {
9920
+ const list = byContainer.get(rec.container) ?? [];
9921
+ list.push(rec);
9922
+ byContainer.set(rec.container, list);
9923
+ }
9924
+ const stale = [];
9925
+ const keep = [];
9926
+ for (const [container, records] of byContainer) {
9927
+ if (!currentContainerNames.has(container)) {
9928
+ stale.push(...records);
9929
+ continue;
9930
+ }
9931
+ const sorted = [...records].sort(
9932
+ (a, b) => a.builtAt < b.builtAt ? 1 : a.builtAt > b.builtAt ? -1 : 0
9933
+ );
9934
+ keep.push(sorted[0]);
9935
+ stale.push(...sorted.slice(1));
9936
+ }
9937
+ return { stale, keep };
9938
+ }
9939
+ async function pruneStaleImages(opts) {
9940
+ const exec = opts.exec ?? spawnDocker;
9941
+ const state = await readMachineState(opts.home);
9942
+ const registry = state.builtImages ?? [];
9943
+ const { stale, keep } = selectStaleImages(
9944
+ registry,
9945
+ opts.currentContainerNames
9946
+ );
9947
+ const survivors = [...keep];
9948
+ let removed = 0;
9949
+ for (const rec of stale) {
9950
+ const outcome = await removeImage(rec.imageId, exec);
9951
+ if (outcome === "removed") {
9952
+ removed += 1;
9953
+ } else if (outcome === "absent") {
9954
+ } else {
9955
+ survivors.push(rec);
9956
+ }
9957
+ }
9958
+ state.builtImages = survivors;
9959
+ await writeMachineState(state, opts.home);
9960
+ if (removed > 0) {
9961
+ opts.logger?.info(
9962
+ `Pruned ${removed} stale Monoceros image${removed === 1 ? "" : "s"}.`
9963
+ );
9964
+ }
9965
+ return { removed, attempted: stale.length };
9966
+ }
9967
+ var init_prune = __esm({
9968
+ "src/upgrade/prune.ts"() {
9969
+ "use strict";
9970
+ init_machine_state();
9971
+ init_proxy();
9972
+ init_compose();
9973
+ init_images();
9974
+ }
9975
+ });
9976
+
9632
9977
  // src/upgrade/index.ts
9633
- import { existsSync as existsSync17, promises as fs17 } from "fs";
9978
+ import { existsSync as existsSync18, promises as fs17 } from "fs";
9634
9979
  import { consola as consola34 } from "consola";
9635
9980
  async function fetchRuntimeVersions() {
9636
9981
  const tokenUrl = `https://ghcr.io/token?service=ghcr.io&scope=repository:${RUNTIME_REPO}:pull`;
@@ -9683,53 +10028,113 @@ async function runUpgrade(opts) {
9683
10028
  );
9684
10029
  return 0;
9685
10030
  }
9686
- if (!opts.name) {
9687
- throw new Error(
9688
- "Usage: `monoceros upgrade <name> [version]` (or `monoceros upgrade --list`)."
9689
- );
10031
+ if (opts.version !== void 0) {
10032
+ if (!opts.name) {
10033
+ throw new Error(
10034
+ "A specific version can only be pinned for one container: `monoceros upgrade <name> <version>`."
10035
+ );
10036
+ }
10037
+ if (!VERSION_RE.test(opts.version)) {
10038
+ throw new Error(
10039
+ `Invalid version ${JSON.stringify(opts.version)}. Expected an exact version like '1.1.0'.`
10040
+ );
10041
+ }
9690
10042
  }
9691
- const ymlPath = containerConfigPath(opts.name, home);
9692
- if (!existsSync17(ymlPath)) {
10043
+ if (opts.name && !existsSync18(containerConfigPath(opts.name, home))) {
9693
10044
  throw new Error(
9694
- `No such config: ${ymlPath}. Run \`monoceros init <template> ${opts.name}\` first.`
10045
+ `No such config: ${containerConfigPath(opts.name, home)}. Run \`monoceros init <template> ${opts.name}\` first.`
9695
10046
  );
9696
10047
  }
9697
- let target = opts.version;
9698
- if (target !== void 0 && !VERSION_RE.test(target)) {
9699
- throw new Error(
9700
- `Invalid version ${JSON.stringify(target)}. Expected an exact version like '1.1.0'.`
9701
- );
10048
+ const targets = opts.name ? [opts.name] : await listContainerNames2(home);
10049
+ const apply = opts.applyRunner ?? runApply;
10050
+ const now = opts.now ?? /* @__PURE__ */ new Date();
10051
+ const pruneAndStamp = async () => {
10052
+ const result = await pruneStaleImages({
10053
+ home,
10054
+ currentContainerNames: new Set(await listContainerNames2(home)),
10055
+ ...opts.dockerExec ? { exec: opts.dockerExec } : {}
10056
+ });
10057
+ await markUpgraded(now.toISOString(), home);
10058
+ return result;
10059
+ };
10060
+ if (targets.length === 0) {
10061
+ logger.info("No containers to upgrade.");
10062
+ await pruneAndStamp();
10063
+ return 0;
9702
10064
  }
9703
- if (target === void 0) {
10065
+ let pinVersion = opts.version;
10066
+ if (pinVersion === void 0) {
9704
10067
  const versions = await fetchVersions();
9705
- target = versions[versions.length - 1];
9706
- if (!target) {
10068
+ pinVersion = versions[versions.length - 1];
10069
+ if (!pinVersion) {
9707
10070
  throw new Error("Could not determine the latest runtime version.");
9708
10071
  }
9709
- logger.info(`Latest published runtime version: ${target}`);
10072
+ logger.info(`Latest published runtime version: ${pinVersion}`);
10073
+ }
10074
+ let worstExit = 0;
10075
+ let bumped = 0;
10076
+ for (const name of targets) {
10077
+ const ymlPath = containerConfigPath(name, home);
10078
+ if (!existsSync18(ymlPath)) continue;
10079
+ const raw = await fs17.readFile(ymlPath, "utf8");
10080
+ const updated = setRuntimeVersion(raw, pinVersion);
10081
+ if (updated !== raw) {
10082
+ await fs17.writeFile(ymlPath, updated);
10083
+ bumped += 1;
10084
+ logger.info(`Pinned '${name}' to runtime ${pinVersion}.`);
10085
+ }
10086
+ logger.info(`Refreshing '${name}' (rebuild \u2014 latest tools)\u2026`);
10087
+ const result = await apply({
10088
+ name,
10089
+ cliVersion: opts.cliVersion,
10090
+ monocerosHome: home,
10091
+ rebuild: true
10092
+ });
10093
+ if (result.containerExitCode !== 0) {
10094
+ worstExit = result.containerExitCode;
10095
+ logger.warn?.(
10096
+ `Upgrade of '${name}' failed (exit ${result.containerExitCode}).`
10097
+ );
10098
+ }
9710
10099
  }
9711
- const raw = await fs17.readFile(ymlPath, "utf8");
9712
- const updated = setRuntimeVersion(raw, target);
9713
- if (updated === raw) {
9714
- logger.info(`'${opts.name}' is already pinned to runtime ${target}.`);
9715
- } else {
9716
- await fs17.writeFile(ymlPath, updated);
9717
- logger.success(`Pinned '${opts.name}' to runtime ${target}. Re-applying\u2026`);
10100
+ if (worstExit === 0) {
10101
+ const prune = await pruneAndStamp();
10102
+ logger.success(
10103
+ `Upgraded ${opts.name ? `'${opts.name}'` : `${targets.length} container${targets.length === 1 ? "" : "s"}`}
10104
+ tools rebuilt \u2014 latest pulled
10105
+ base ${pinVersion} ${bumped > 0 ? `(${bumped} bumped)` : "(already latest)"}
10106
+ pruned ${formatPruneLine(prune)}
10107
+ recorded ${now.toISOString().slice(0, 16).replace("T", " ")} UTC`
10108
+ );
10109
+ }
10110
+ return worstExit;
10111
+ }
10112
+ function formatPruneLine(prune) {
10113
+ const gone = prune.attempted - prune.removed;
10114
+ if (prune.attempted === 0) return "nothing stale";
10115
+ if (prune.removed === 0) {
10116
+ return `0 removed (${gone} stale ${gone === 1 ? "entry" : "entries"} already gone)`;
10117
+ }
10118
+ const base = `${prune.removed} image${prune.removed === 1 ? "" : "s"} removed`;
10119
+ return gone > 0 ? `${base}, ${gone} stale ${gone === 1 ? "entry" : "entries"} cleared` : base;
10120
+ }
10121
+ async function listContainerNames2(home) {
10122
+ try {
10123
+ const entries = await fs17.readdir(containerConfigsDir(home));
10124
+ return entries.filter((e) => e.endsWith(".yml")).map((e) => e.slice(0, -".yml".length));
10125
+ } catch {
10126
+ return [];
9718
10127
  }
9719
- const apply = opts.applyRunner ?? runApply;
9720
- const result = await apply({
9721
- name: opts.name,
9722
- cliVersion: opts.cliVersion,
9723
- monocerosHome: home
9724
- });
9725
- return result.containerExitCode;
9726
10128
  }
9727
10129
  var RUNTIME_REPO, VERSION_RE;
9728
10130
  var init_upgrade = __esm({
9729
10131
  "src/upgrade/index.ts"() {
9730
10132
  "use strict";
9731
10133
  init_paths();
10134
+ init_machine_state();
9732
10135
  init_catalog();
10136
+ init_proxy();
10137
+ init_prune();
9733
10138
  init_apply();
9734
10139
  RUNTIME_REPO = "getmonoceros/monoceros-runtime";
9735
10140
  VERSION_RE = /^\d+\.\d+\.\d+$/;
@@ -9749,7 +10154,7 @@ var init_upgrade2 = __esm({
9749
10154
  meta: {
9750
10155
  name: "upgrade",
9751
10156
  group: "lifecycle",
9752
- description: "Pin a container to a newer runtime image version and re-apply. `monoceros upgrade <name>` pins to the latest published version; `monoceros upgrade <name> <version>` pins to an exact one; `monoceros upgrade --list` lists available versions."
10157
+ description: "Refresh tooling to the latest (rebuilds feature layers, bumps the runtime base when newer) and prune stale Monoceros images. `monoceros upgrade` refreshes all containers; `monoceros upgrade <name>` one; `monoceros upgrade <name> <version>` pins that base version; `monoceros upgrade --list` lists runtime versions."
9753
10158
  },
9754
10159
  args: {
9755
10160
  name: {
@@ -10119,25 +10524,25 @@ function detectHelpRequest(argv, main2) {
10119
10524
  const separatorIdx = argv.indexOf("--");
10120
10525
  if (helpIdx === -1) return null;
10121
10526
  if (separatorIdx !== -1 && separatorIdx < helpIdx) return null;
10122
- const path25 = [];
10527
+ const path27 = [];
10123
10528
  const tokens = argv.slice(
10124
10529
  0,
10125
10530
  separatorIdx === -1 ? argv.length : separatorIdx
10126
10531
  );
10127
10532
  let cursor = main2;
10128
10533
  const mainName = (main2.meta ?? {}).name ?? "monoceros";
10129
- path25.push(mainName);
10534
+ path27.push(mainName);
10130
10535
  for (const tok of tokens) {
10131
10536
  if (tok.startsWith("-")) continue;
10132
10537
  const subs = cursor.subCommands ?? {};
10133
10538
  if (tok in subs) {
10134
10539
  cursor = subs[tok];
10135
- path25.push(tok);
10540
+ path27.push(tok);
10136
10541
  continue;
10137
10542
  }
10138
10543
  break;
10139
10544
  }
10140
- return { path: path25, cmd: cursor };
10545
+ return { path: path27, cmd: cursor };
10141
10546
  }
10142
10547
  async function maybeRenderHelp(argv, main2) {
10143
10548
  const hit = detectHelpRequest(argv, main2);