@getmonoceros/workbench 1.15.0 → 1.16.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.
package/dist/bin.js CHANGED
@@ -74,12 +74,13 @@ ${issues}`);
74
74
  }
75
75
  return result.data;
76
76
  }
77
- var SOLUTION_NAME_RE, APT_PACKAGE_NAME_RE, FEATURE_REF_RE, INSTALL_URL_RE, REPO_URL_RE, REPO_PATH_RE, POSTGRES_URL_RE, REGEX, PROVIDER_VALUES, KNOWN_PROVIDER_HOSTS, CONFIG_SCHEMA_VERSION, FeatureOptionValueSchema, FeatureEntrySchema, EMAIL_RE, GitUserSchema, RepoEntrySchema, PortEntrySchema, RoutingSchema, SERVICE_NAME_RE, ServiceEnvValueSchema, ServiceHealthcheckSchema, SERVICE_RESTART_VALUES, ServiceObjectSchema, ExternalServicesSchema, SolutionConfigSchema;
77
+ var SOLUTION_NAME_RE, APT_PACKAGE_NAME_RE, RUNTIME_VERSION_RE, FEATURE_REF_RE, INSTALL_URL_RE, REPO_URL_RE, REPO_PATH_RE, POSTGRES_URL_RE, REGEX, PROVIDER_VALUES, KNOWN_PROVIDER_HOSTS, CONFIG_SCHEMA_VERSION, FeatureOptionValueSchema, FeatureEntrySchema, EMAIL_RE, GitUserSchema, RepoEntrySchema, PortEntrySchema, RoutingSchema, SERVICE_NAME_RE, ServiceEnvValueSchema, ServiceHealthcheckSchema, SERVICE_RESTART_VALUES, ServiceObjectSchema, ExternalServicesSchema, SolutionConfigSchema;
78
78
  var init_schema = __esm({
79
79
  "src/config/schema.ts"() {
80
80
  "use strict";
81
81
  SOLUTION_NAME_RE = /^[A-Za-z0-9._-]+$/;
82
82
  APT_PACKAGE_NAME_RE = /^[a-z0-9][a-z0-9.+-]*$/;
83
+ RUNTIME_VERSION_RE = /^\d+\.\d+\.\d+$/;
83
84
  FEATURE_REF_RE = /^[a-z0-9.-]+(\/[a-z0-9._-]+)+:[a-z0-9._-]+$/;
84
85
  INSTALL_URL_RE = /^https:\/\/[A-Za-z0-9.\-_~/:?#[\]@!&'()*+,;=%]+$/;
85
86
  REPO_URL_RE = /^https:\/\/[A-Za-z0-9@:/+_~.#=&?-]+$/;
@@ -216,6 +217,15 @@ var init_schema = __esm({
216
217
  SOLUTION_NAME_RE,
217
218
  "Invalid solution name. Use letters, digits, '.', '_' or '-'."
218
219
  ),
220
+ // Pinned runtime-image version (ADR 0017). Written by `init`, reused
221
+ // verbatim by every subsequent `apply` (never auto-bumped), changed
222
+ // only by `monoceros upgrade`. Optional in the schema so a
223
+ // pre-pinning yml still parses — `apply` is what enforces its
224
+ // presence with an actionable hint.
225
+ runtimeVersion: z.string().regex(
226
+ RUNTIME_VERSION_RE,
227
+ "Invalid runtimeVersion. Expected an exact version like '1.1.0'."
228
+ ).optional(),
219
229
  languages: z.array(z.string().min(1)).default([]),
220
230
  aptPackages: z.array(
221
231
  z.string().regex(
@@ -1821,6 +1831,25 @@ var init_port_check = __esm({
1821
1831
  });
1822
1832
 
1823
1833
  // src/create/catalog.ts
1834
+ function resolveRuntimeImage(version) {
1835
+ const ov = process.env.MONOCEROS_BASE_IMAGE_OVERRIDE?.trim();
1836
+ if (ov && ov.length > 0) return ov;
1837
+ if (!version) return `${RUNTIME_IMAGE_REPO}:1`;
1838
+ return `${RUNTIME_IMAGE_REPO}:${version}`;
1839
+ }
1840
+ function compareRuntimeVersions(a, b) {
1841
+ const pa = a.split(".").map(Number);
1842
+ const pb = b.split(".").map(Number);
1843
+ for (let i = 0; i < 3; i++) {
1844
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
1845
+ if (d !== 0) return d < 0 ? -1 : 1;
1846
+ }
1847
+ return 0;
1848
+ }
1849
+ function runtimeSupportsIdeVolumes(version) {
1850
+ if (!version) return false;
1851
+ return compareRuntimeVersions(version, MIN_RUNTIME_FOR_IDE_VOLUMES) >= 0;
1852
+ }
1824
1853
  function parseLanguageSpec(spec) {
1825
1854
  const m = LANGUAGE_SPEC_RE.exec(spec);
1826
1855
  if (!m) return null;
@@ -1877,13 +1906,16 @@ function deriveServiceName(image) {
1877
1906
  const noTag = lastSegment.split("@")[0].split(":")[0];
1878
1907
  return noTag.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
1879
1908
  }
1880
- var DEFAULT_BASE_IMAGE, override, BASE_IMAGE, BUILTIN_LANGUAGES, LANGUAGE_CATALOG, LANGUAGE_SPEC_RE, SERVICE_CATALOG;
1909
+ var DEFAULT_BASE_IMAGE, override, BASE_IMAGE, RUNTIME_IMAGE_REPO, DEFAULT_RUNTIME_VERSION, MIN_RUNTIME_FOR_IDE_VOLUMES, BUILTIN_LANGUAGES, LANGUAGE_CATALOG, LANGUAGE_SPEC_RE, SERVICE_CATALOG;
1881
1910
  var init_catalog = __esm({
1882
1911
  "src/create/catalog.ts"() {
1883
1912
  "use strict";
1884
1913
  DEFAULT_BASE_IMAGE = "ghcr.io/getmonoceros/monoceros-runtime:1";
1885
1914
  override = process.env.MONOCEROS_BASE_IMAGE_OVERRIDE?.trim();
1886
1915
  BASE_IMAGE = override && override.length > 0 ? override : DEFAULT_BASE_IMAGE;
1916
+ RUNTIME_IMAGE_REPO = "ghcr.io/getmonoceros/monoceros-runtime";
1917
+ DEFAULT_RUNTIME_VERSION = "1.1.0";
1918
+ MIN_RUNTIME_FOR_IDE_VOLUMES = "1.1.0";
1887
1919
  BUILTIN_LANGUAGES = /* @__PURE__ */ new Set(["node"]);
1888
1920
  LANGUAGE_CATALOG = {
1889
1921
  node: { id: "node", feature: "ghcr.io/devcontainers/features/node:1" },
@@ -1921,7 +1953,8 @@ var init_catalog = __esm({
1921
1953
  // /var/lib/postgresql/data directly. See
1922
1954
  // https://github.com/docker-library/postgres/pull/1259.
1923
1955
  dataMount: "/var/lib/postgresql",
1924
- defaultPort: 5432
1956
+ defaultPort: 5432,
1957
+ vscodeExtensions: ["cweijan.vscode-database-client2"]
1925
1958
  },
1926
1959
  mysql: {
1927
1960
  id: "mysql",
@@ -1946,7 +1979,8 @@ var init_catalog = __esm({
1946
1979
  retries: 5
1947
1980
  },
1948
1981
  dataMount: "/var/lib/mysql",
1949
- defaultPort: 3306
1982
+ defaultPort: 3306,
1983
+ vscodeExtensions: ["cweijan.vscode-database-client2"]
1950
1984
  },
1951
1985
  redis: {
1952
1986
  id: "redis",
@@ -1958,7 +1992,8 @@ var init_catalog = __esm({
1958
1992
  retries: 5
1959
1993
  },
1960
1994
  dataMount: "/data",
1961
- defaultPort: 6379
1995
+ defaultPort: 6379,
1996
+ vscodeExtensions: ["cweijan.vscode-database-client2"]
1962
1997
  }
1963
1998
  };
1964
1999
  }
@@ -2134,6 +2169,7 @@ function normalizeOptions(opts) {
2134
2169
  const ports = opts.ports ? [...new Set(opts.ports)] : void 0;
2135
2170
  return {
2136
2171
  name: opts.name,
2172
+ ...opts.runtimeVersion !== void 0 ? { runtimeVersion: opts.runtimeVersion } : {},
2137
2173
  languages,
2138
2174
  services,
2139
2175
  postgresUrl: opts.postgresUrl,
@@ -2246,6 +2282,18 @@ function filterFileEntries(raw) {
2246
2282
  function isValidHomeSubpath(p) {
2247
2283
  return p.length > 0 && !p.startsWith("/") && !p.includes("..") && HOME_SUBPATH_RE.test(p);
2248
2284
  }
2285
+ function ideStateVolumes(name) {
2286
+ return [
2287
+ {
2288
+ volume: `monoceros-${name}-vscode-extensions`,
2289
+ target: "/home/node/.vscode-server/extensions"
2290
+ },
2291
+ {
2292
+ volume: `monoceros-${name}-vscode-userdata`,
2293
+ target: "/home/node/.vscode-server/data/User"
2294
+ }
2295
+ ];
2296
+ }
2249
2297
  function buildDevcontainerJson(opts, dockerMode = "rootful") {
2250
2298
  const resolvedFeatures = resolveFeatures(opts);
2251
2299
  const features = {};
@@ -2291,7 +2339,10 @@ function buildDevcontainerJson(opts, dockerMode = "rootful") {
2291
2339
  ...customizationsField ?? {}
2292
2340
  };
2293
2341
  }
2294
- const mounts = [...homeMounts];
2342
+ const ideMounts = runtimeSupportsIdeVolumes(opts.runtimeVersion) ? ideStateVolumes(opts.name).map(
2343
+ (v) => `source=${v.volume},target=${v.target},type=volume`
2344
+ ) : [];
2345
+ const mounts = [...homeMounts, ...ideMounts];
2295
2346
  const mountsField = mounts.length > 0 ? { mounts } : {};
2296
2347
  const workspaceMountField = {
2297
2348
  workspaceMount: `source=\${localWorkspaceFolder},target=/workspaces/${opts.name},type=bind,consistency=cached`,
@@ -2304,7 +2355,7 @@ function buildDevcontainerJson(opts, dockerMode = "rootful") {
2304
2355
  }
2305
2356
  return {
2306
2357
  name: opts.name,
2307
- image: BASE_IMAGE,
2358
+ image: resolveRuntimeImage(opts.runtimeVersion),
2308
2359
  remoteUser: "node",
2309
2360
  ...workspaceMountField,
2310
2361
  ...mountsField,
@@ -2331,8 +2382,9 @@ function buildComposeYaml(opts, dockerMode = "rootful") {
2331
2382
  void dockerMode;
2332
2383
  const hasPorts = (opts.ports?.length ?? 0) > 0;
2333
2384
  const lines = ["services:"];
2385
+ const ideVolumes = runtimeSupportsIdeVolumes(opts.runtimeVersion) ? ideStateVolumes(opts.name) : [];
2334
2386
  lines.push(" workspace:");
2335
- lines.push(` image: ${BASE_IMAGE}`);
2387
+ lines.push(` image: ${resolveRuntimeImage(opts.runtimeVersion)}`);
2336
2388
  lines.push(" command: 'sleep infinity'");
2337
2389
  lines.push(" cap_add:");
2338
2390
  lines.push(" - NET_ADMIN");
@@ -2355,6 +2407,9 @@ function buildComposeYaml(opts, dockerMode = "rootful") {
2355
2407
  lines.push(` - ../home/${sub}:/home/node/${sub}`);
2356
2408
  }
2357
2409
  }
2410
+ for (const v of ideVolumes) {
2411
+ lines.push(` - ${v.volume}:${v.target}`);
2412
+ }
2358
2413
  for (const svc of opts.services) {
2359
2414
  lines.push(` ${svc.name}:`);
2360
2415
  lines.push(` image: ${svc.image}`);
@@ -2393,6 +2448,13 @@ function buildComposeYaml(opts, dockerMode = "rootful") {
2393
2448
  }
2394
2449
  }
2395
2450
  }
2451
+ if (ideVolumes.length > 0) {
2452
+ lines.push("volumes:");
2453
+ for (const v of ideVolumes) {
2454
+ lines.push(` ${v.volume}:`);
2455
+ lines.push(` name: ${v.volume}`);
2456
+ }
2457
+ }
2396
2458
  if (hasPorts) {
2397
2459
  lines.push("networks:");
2398
2460
  lines.push(" monoceros-proxy:");
@@ -2400,8 +2462,34 @@ function buildComposeYaml(opts, dockerMode = "rootful") {
2400
2462
  }
2401
2463
  return lines.join("\n") + "\n";
2402
2464
  }
2465
+ function extractRepoHost(url) {
2466
+ const schemeMatch = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)/i.exec(url);
2467
+ if (schemeMatch) return schemeMatch[1].toLowerCase();
2468
+ const scpMatch = /^(?:[^@/]+@)?([^/:]+):/.exec(url);
2469
+ if (scpMatch) return scpMatch[1].toLowerCase();
2470
+ return null;
2471
+ }
2472
+ function computeExtensionRecommendations(opts) {
2473
+ const recs = /* @__PURE__ */ new Set();
2474
+ for (const svc of opts.services) {
2475
+ const catalogEntry = SERVICE_CATALOG[svc.name];
2476
+ for (const ext of catalogEntry?.vscodeExtensions ?? []) {
2477
+ recs.add(ext);
2478
+ }
2479
+ }
2480
+ for (const repo of opts.repos ?? []) {
2481
+ const host = extractRepoHost(repo.url);
2482
+ if (!host) continue;
2483
+ for (const ext of REPO_HOST_EXTENSIONS[host] ?? []) {
2484
+ recs.add(ext);
2485
+ }
2486
+ }
2487
+ return [...recs].sort((a, b) => a.localeCompare(b));
2488
+ }
2403
2489
  function buildCodeWorkspaceJson(opts) {
2404
- const folders = [{ path: "." }];
2490
+ const folders = [
2491
+ { path: ".", name: WORKSPACE_ROOT_LABEL }
2492
+ ];
2405
2493
  const sortedRepos = [...opts.repos ?? []].sort(
2406
2494
  (a, b) => a.path.localeCompare(b.path)
2407
2495
  );
@@ -2409,7 +2497,11 @@ function buildCodeWorkspaceJson(opts) {
2409
2497
  const label = repo.path.split("/").pop() ?? repo.path;
2410
2498
  folders.push({ path: `projects/${repo.path}`, name: label });
2411
2499
  }
2412
- return { folders };
2500
+ const recommendations = computeExtensionRecommendations(opts);
2501
+ return {
2502
+ folders,
2503
+ ...recommendations.length > 0 ? { extensions: { recommendations } } : {}
2504
+ };
2413
2505
  }
2414
2506
  function mergeCodeWorkspace(existing, generated) {
2415
2507
  if (!existing || typeof existing !== "object" || Array.isArray(existing) || !Array.isArray(existing.folders)) {
@@ -2424,10 +2516,41 @@ function mergeCodeWorkspace(existing, generated) {
2424
2516
  for (const g of generated.folders) {
2425
2517
  if (!existingPaths.has(g.path)) merged.push(g);
2426
2518
  }
2519
+ const generatedRootName = generated.folders.find((f) => f.path === ".")?.name;
2520
+ if (generatedRootName) {
2521
+ const idx = merged.findIndex(
2522
+ (f) => f && typeof f === "object" && f.path === "."
2523
+ );
2524
+ if (idx >= 0 && !merged[idx].name) {
2525
+ merged[idx] = { ...merged[idx], name: generatedRootName };
2526
+ }
2527
+ }
2427
2528
  const out = { ...existingObj };
2428
2529
  out.folders = merged;
2530
+ const generatedRecs = generated.extensions?.recommendations ?? [];
2531
+ if (generatedRecs.length > 0) {
2532
+ const existingExtRaw = existingObj.extensions;
2533
+ const existingExt = existingExtRaw && typeof existingExtRaw === "object" && !Array.isArray(existingExtRaw) ? existingExtRaw : {};
2534
+ const existingRecs = Array.isArray(existingExt.recommendations) ? existingExt.recommendations.filter(
2535
+ (r) => typeof r === "string"
2536
+ ) : [];
2537
+ const existingRecSet = new Set(existingRecs);
2538
+ const mergedRecs = [...existingRecs];
2539
+ for (const r of generatedRecs) {
2540
+ if (!existingRecSet.has(r)) mergedRecs.push(r);
2541
+ }
2542
+ out.extensions = { ...existingExt, recommendations: mergedRecs };
2543
+ }
2429
2544
  return out;
2430
2545
  }
2546
+ function mergeVscodeSettings(existing) {
2547
+ const base = existing && typeof existing === "object" && !Array.isArray(existing) ? existing : {};
2548
+ const existingExclude = base["files.exclude"] && typeof base["files.exclude"] === "object" && !Array.isArray(base["files.exclude"]) ? base["files.exclude"] : {};
2549
+ return {
2550
+ ...base,
2551
+ "files.exclude": { ...existingExclude, ...ROOT_DENOISE_EXCLUDES }
2552
+ };
2553
+ }
2431
2554
  function buildPostCreateScript(opts) {
2432
2555
  const lines = [
2433
2556
  "#!/usr/bin/env bash",
@@ -2528,6 +2651,14 @@ async function writePostCreateScript(devcontainerDir, opts) {
2528
2651
  await fs7.writeFile(dest, buildPostCreateScript(opts));
2529
2652
  await fs7.chmod(dest, 493);
2530
2653
  }
2654
+ async function writeIfChanged(filePath, content) {
2655
+ try {
2656
+ if (await fs7.readFile(filePath, "utf8") === content) return false;
2657
+ } catch {
2658
+ }
2659
+ await fs7.writeFile(filePath, content);
2660
+ return true;
2661
+ }
2531
2662
  async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2532
2663
  const dockerMode = scaffoldOpts.dockerMode ?? "rootful";
2533
2664
  const devcontainerDir = path8.join(targetDir, ".devcontainer");
@@ -2562,7 +2693,7 @@ async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2562
2693
  "git-credentials*\ngitconfig\n"
2563
2694
  );
2564
2695
  const devcontainerJson = buildDevcontainerJson(opts, dockerMode);
2565
- await fs7.writeFile(
2696
+ await writeIfChanged(
2566
2697
  path8.join(devcontainerDir, "devcontainer.json"),
2567
2698
  JSON.stringify(devcontainerJson, null, 2) + "\n"
2568
2699
  );
@@ -2592,7 +2723,7 @@ async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2592
2723
  await writePostCreateScript(devcontainerDir, opts);
2593
2724
  const composePath = path8.join(devcontainerDir, "compose.yaml");
2594
2725
  if (needsCompose(opts)) {
2595
- await fs7.writeFile(composePath, buildComposeYaml(opts, dockerMode));
2726
+ await writeIfChanged(composePath, buildComposeYaml(opts, dockerMode));
2596
2727
  } else if (existsSync5(composePath)) {
2597
2728
  await fs7.rm(composePath);
2598
2729
  }
@@ -2607,8 +2738,21 @@ async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2607
2738
  const generated = buildCodeWorkspaceJson(opts);
2608
2739
  const merged = mergeCodeWorkspace(existingWorkspace, generated);
2609
2740
  await fs7.writeFile(workspacePath, JSON.stringify(merged, null, 2) + "\n");
2741
+ const vscodeDir = path8.join(targetDir, ".vscode");
2742
+ const settingsPath = path8.join(vscodeDir, "settings.json");
2743
+ let existingSettings;
2744
+ try {
2745
+ existingSettings = JSON.parse(await fs7.readFile(settingsPath, "utf8"));
2746
+ } catch {
2747
+ existingSettings = void 0;
2748
+ }
2749
+ await fs7.mkdir(vscodeDir, { recursive: true });
2750
+ await fs7.writeFile(
2751
+ settingsPath,
2752
+ JSON.stringify(mergeVscodeSettings(existingSettings), null, 2) + "\n"
2753
+ );
2610
2754
  }
2611
- var APT_PACKAGE_NAME_RE2, FEATURE_REF_RE2, INSTALL_URL_RE2, REPO_URL_RE2, REPO_PATH_RE2, HOME_SUBPATH_RE;
2755
+ var APT_PACKAGE_NAME_RE2, FEATURE_REF_RE2, INSTALL_URL_RE2, REPO_URL_RE2, REPO_PATH_RE2, HOME_SUBPATH_RE, WORKSPACE_ROOT_LABEL, REPO_HOST_EXTENSIONS, ROOT_DENOISE_EXCLUDES;
2612
2756
  var init_scaffold = __esm({
2613
2757
  "src/create/scaffold.ts"() {
2614
2758
  "use strict";
@@ -2621,6 +2765,23 @@ var init_scaffold = __esm({
2621
2765
  REPO_URL_RE2 = /^[A-Za-z0-9@:/+_~.#=&?-]+$/;
2622
2766
  REPO_PATH_RE2 = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
2623
2767
  HOME_SUBPATH_RE = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
2768
+ WORKSPACE_ROOT_LABEL = "\u{1F984} Monoceros";
2769
+ REPO_HOST_EXTENSIONS = {
2770
+ "github.com": [
2771
+ "github.vscode-pull-request-github",
2772
+ "GitHub.vscode-github-actions"
2773
+ ],
2774
+ "gitlab.com": ["GitLab.gitlab-workflow"]
2775
+ };
2776
+ ROOT_DENOISE_EXCLUDES = {
2777
+ ".devcontainer": true,
2778
+ ".monoceros": true,
2779
+ ".vscode": true,
2780
+ ".gitignore": true,
2781
+ data: true,
2782
+ projects: true,
2783
+ "*.code-workspace": true
2784
+ };
2624
2785
  }
2625
2786
  });
2626
2787
 
@@ -4097,7 +4258,8 @@ function buildStateFile(opts) {
4097
4258
  schemaVersion: CONFIG_SCHEMA_VERSION,
4098
4259
  origin: opts.origin,
4099
4260
  monocerosCliVersion: opts.cliVersion,
4100
- materializedAt: (opts.now ?? /* @__PURE__ */ new Date()).toISOString()
4261
+ materializedAt: (opts.now ?? /* @__PURE__ */ new Date()).toISOString(),
4262
+ ...opts.runtimeImage ? { runtimeImage: opts.runtimeImage } : {}
4101
4263
  };
4102
4264
  }
4103
4265
  function stateFilePath(targetDir) {
@@ -4138,6 +4300,7 @@ function solutionConfigToCreateOptions(config, featureDefaults = {}) {
4138
4300
  }
4139
4301
  const result = {
4140
4302
  name: config.name,
4303
+ ...config.runtimeVersion !== void 0 ? { runtimeVersion: config.runtimeVersion } : {},
4141
4304
  languages: [...config.languages],
4142
4305
  // Normalize every services[] entry (curated string or explicit
4143
4306
  // object) to the canonical ResolvedService shape. `${VAR}` values
@@ -5726,6 +5889,11 @@ ${sectionLine(label)}
5726
5889
  await assertSafeTargetDir(targetDir, opts.name);
5727
5890
  section("Configuration");
5728
5891
  const parsed = await readConfig(ymlPath);
5892
+ if (!parsed.config.runtimeVersion) {
5893
+ throw new Error(
5894
+ `No runtime pinned for '${opts.name}': the yml has no 'runtimeVersion'. Pin it with \`monoceros upgrade ${opts.name} <version>\` (or add \`runtimeVersion: <version>\` to the yml), then re-apply.`
5895
+ );
5896
+ }
5729
5897
  const globalConfig = await readMonocerosConfig({ monocerosHome: home });
5730
5898
  warnOnDeprecatedFeatureRefs(parsed.config.features, globalConfig, logger);
5731
5899
  const envPath = containerEnvPath(opts.name, home);
@@ -5837,6 +6005,7 @@ Fix the value in the env file (or the yml).`
5837
6005
  buildStateFile({
5838
6006
  origin: opts.name,
5839
6007
  cliVersion: opts.cliVersion,
6008
+ runtimeImage: resolveRuntimeImage(createOpts.runtimeVersion),
5840
6009
  ...opts.now ? { now: opts.now } : {}
5841
6010
  })
5842
6011
  );
@@ -6059,6 +6228,7 @@ var init_apply = __esm({
6059
6228
  init_schema();
6060
6229
  init_state();
6061
6230
  init_transform();
6231
+ init_catalog();
6062
6232
  init_scaffold();
6063
6233
  init_format();
6064
6234
  init_ref();
@@ -6085,7 +6255,7 @@ var CLI_VERSION;
6085
6255
  var init_version = __esm({
6086
6256
  "src/version.ts"() {
6087
6257
  "use strict";
6088
- CLI_VERSION = true ? "1.15.0" : "dev";
6258
+ CLI_VERSION = true ? "1.16.0" : "dev";
6089
6259
  }
6090
6260
  });
6091
6261
 
@@ -6510,6 +6680,7 @@ var init_resolve = __esm({
6510
6680
  "stop",
6511
6681
  "status",
6512
6682
  "apply",
6683
+ "upgrade",
6513
6684
  "remove",
6514
6685
  "restore",
6515
6686
  "add-service",
@@ -6554,6 +6725,12 @@ var init_resolve = __esm({
6554
6725
  positionals: [containerName],
6555
6726
  flags: { "--yes": { type: "boolean", aliases: ["-y"] } }
6556
6727
  },
6728
+ upgrade: {
6729
+ // First positional is a container name; the second is a version
6730
+ // string (no suggestions — versions live in the registry).
6731
+ positionals: [containerName, () => []],
6732
+ flags: { "--list": { type: "boolean" } }
6733
+ },
6557
6734
  remove: {
6558
6735
  positionals: [containerName],
6559
6736
  flags: {
@@ -6704,6 +6881,11 @@ function generateComposedYml(name, composed, lookupManifest, repoUrls = [], port
6704
6881
  lines.push("");
6705
6882
  lines.push("schemaVersion: 1");
6706
6883
  lines.push(`name: ${name}`);
6884
+ lines.push(
6885
+ "# Pinned runtime image version. Reused on every apply; change it with"
6886
+ );
6887
+ lines.push("# `monoceros upgrade <name> [version]`.");
6888
+ lines.push(`runtimeVersion: ${DEFAULT_RUNTIME_VERSION}`);
6707
6889
  lines.push("");
6708
6890
  if (composed.languages.length > 0) {
6709
6891
  pushSectionHeader(
@@ -6802,6 +6984,11 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
6802
6984
  lines.push("");
6803
6985
  lines.push("schemaVersion: 1");
6804
6986
  lines.push(`name: ${name}`);
6987
+ lines.push(
6988
+ "# Pinned runtime image version. Reused on every apply; change it with"
6989
+ );
6990
+ lines.push("# `monoceros upgrade <name> [version]`.");
6991
+ lines.push(`runtimeVersion: ${DEFAULT_RUNTIME_VERSION}`);
6805
6992
  lines.push("");
6806
6993
  if (byCategory.language.length > 0) {
6807
6994
  pushSectionHeader(
@@ -7802,6 +7989,8 @@ async function runRemove(opts) {
7802
7989
  logger,
7803
7990
  exec: dockerExec
7804
7991
  });
7992
+ const ideVolumes = ideStateVolumes(opts.name).map((v) => v.volume);
7993
+ await dockerExec(["volume", "rm", "-f", ...ideVolumes]);
7805
7994
  let backupPath = null;
7806
7995
  if (!opts.noBackup && (hasYml || hasContainer)) {
7807
7996
  const ts = (opts.now ?? /* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -7896,6 +8085,7 @@ var init_remove = __esm({
7896
8085
  init_paths();
7897
8086
  init_schema();
7898
8087
  init_compose();
8088
+ init_scaffold();
7899
8089
  init_proxy();
7900
8090
  init_dynamic();
7901
8091
  }
@@ -9063,12 +9253,165 @@ var init_tunnel = __esm({
9063
9253
  }
9064
9254
  });
9065
9255
 
9256
+ // src/upgrade/index.ts
9257
+ import { existsSync as existsSync14, promises as fs17 } from "fs";
9258
+ import { consola as consola34 } from "consola";
9259
+ async function fetchRuntimeVersions() {
9260
+ const tokenUrl = `https://ghcr.io/token?service=ghcr.io&scope=repository:${RUNTIME_REPO}:pull`;
9261
+ const tokenRes = await fetch(tokenUrl);
9262
+ if (!tokenRes.ok) {
9263
+ throw new Error(
9264
+ `Could not get a GHCR token (HTTP ${tokenRes.status}). Pass an explicit version to skip the lookup: \`monoceros upgrade <name> <version>\`.`
9265
+ );
9266
+ }
9267
+ const { token } = await tokenRes.json();
9268
+ if (!token) throw new Error("GHCR token response contained no token.");
9269
+ const tagsRes = await fetch(`https://ghcr.io/v2/${RUNTIME_REPO}/tags/list`, {
9270
+ headers: { Authorization: `Bearer ${token}` }
9271
+ });
9272
+ if (!tagsRes.ok) {
9273
+ throw new Error(
9274
+ `Could not list runtime versions (HTTP ${tagsRes.status}). Pass an explicit version: \`monoceros upgrade <name> <version>\`.`
9275
+ );
9276
+ }
9277
+ const { tags } = await tagsRes.json();
9278
+ return (tags ?? []).filter((t) => VERSION_RE.test(t)).sort(compareRuntimeVersions);
9279
+ }
9280
+ function setRuntimeVersion(yml, version) {
9281
+ if (/^runtimeVersion:.*$/m.test(yml)) {
9282
+ return yml.replace(/^runtimeVersion:.*$/m, `runtimeVersion: ${version}`);
9283
+ }
9284
+ if (/^schemaVersion:.*$/m.test(yml)) {
9285
+ return yml.replace(
9286
+ /^(schemaVersion:.*)$/m,
9287
+ `$1
9288
+ runtimeVersion: ${version}`
9289
+ );
9290
+ }
9291
+ return `runtimeVersion: ${version}
9292
+ ${yml}`;
9293
+ }
9294
+ async function runUpgrade(opts) {
9295
+ const home = opts.monocerosHome ?? monocerosHome();
9296
+ const logger = opts.logger ?? consola34;
9297
+ const fetchVersions = opts.fetchVersions ?? fetchRuntimeVersions;
9298
+ if (opts.list) {
9299
+ const versions = await fetchVersions();
9300
+ if (versions.length === 0) {
9301
+ logger.info("No published runtime versions found.");
9302
+ return 0;
9303
+ }
9304
+ logger.info(
9305
+ `Available runtime versions (latest last):
9306
+ ${versions.join("\n ")}`
9307
+ );
9308
+ return 0;
9309
+ }
9310
+ if (!opts.name) {
9311
+ throw new Error(
9312
+ "Usage: `monoceros upgrade <name> [version]` (or `monoceros upgrade --list`)."
9313
+ );
9314
+ }
9315
+ const ymlPath = containerConfigPath(opts.name, home);
9316
+ if (!existsSync14(ymlPath)) {
9317
+ throw new Error(
9318
+ `No such config: ${ymlPath}. Run \`monoceros init <template> ${opts.name}\` first.`
9319
+ );
9320
+ }
9321
+ let target = opts.version;
9322
+ if (target !== void 0 && !VERSION_RE.test(target)) {
9323
+ throw new Error(
9324
+ `Invalid version ${JSON.stringify(target)}. Expected an exact version like '1.1.0'.`
9325
+ );
9326
+ }
9327
+ if (target === void 0) {
9328
+ const versions = await fetchVersions();
9329
+ target = versions[versions.length - 1];
9330
+ if (!target) {
9331
+ throw new Error("Could not determine the latest runtime version.");
9332
+ }
9333
+ logger.info(`Latest published runtime version: ${target}`);
9334
+ }
9335
+ const raw = await fs17.readFile(ymlPath, "utf8");
9336
+ const updated = setRuntimeVersion(raw, target);
9337
+ if (updated === raw) {
9338
+ logger.info(`'${opts.name}' is already pinned to runtime ${target}.`);
9339
+ } else {
9340
+ await fs17.writeFile(ymlPath, updated);
9341
+ logger.success(`Pinned '${opts.name}' to runtime ${target}. Re-applying\u2026`);
9342
+ }
9343
+ const apply = opts.applyRunner ?? runApply;
9344
+ const result = await apply({
9345
+ name: opts.name,
9346
+ cliVersion: opts.cliVersion,
9347
+ monocerosHome: home
9348
+ });
9349
+ return result.containerExitCode;
9350
+ }
9351
+ var RUNTIME_REPO, VERSION_RE;
9352
+ var init_upgrade = __esm({
9353
+ "src/upgrade/index.ts"() {
9354
+ "use strict";
9355
+ init_paths();
9356
+ init_catalog();
9357
+ init_apply();
9358
+ RUNTIME_REPO = "getmonoceros/monoceros-runtime";
9359
+ VERSION_RE = /^\d+\.\d+\.\d+$/;
9360
+ }
9361
+ });
9362
+
9363
+ // src/commands/upgrade.ts
9364
+ import { defineCommand as defineCommand30 } from "citty";
9365
+ var upgradeCommand;
9366
+ var init_upgrade2 = __esm({
9367
+ "src/commands/upgrade.ts"() {
9368
+ "use strict";
9369
+ init_upgrade();
9370
+ init_version();
9371
+ init_dispatch();
9372
+ upgradeCommand = defineCommand30({
9373
+ meta: {
9374
+ name: "upgrade",
9375
+ group: "lifecycle",
9376
+ 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."
9377
+ },
9378
+ args: {
9379
+ name: {
9380
+ type: "positional",
9381
+ description: "Config name. Resolves to $MONOCEROS_HOME/container-configs/<name>.yml.",
9382
+ required: false
9383
+ },
9384
+ version: {
9385
+ type: "positional",
9386
+ description: "Exact runtime version to pin (e.g. 1.1.0). Omit to use the latest published version.",
9387
+ required: false
9388
+ },
9389
+ list: {
9390
+ type: "boolean",
9391
+ description: "List available runtime versions and exit, changing nothing.",
9392
+ default: false
9393
+ }
9394
+ },
9395
+ run({ args }) {
9396
+ return dispatch(
9397
+ () => runUpgrade({
9398
+ ...args.name ? { name: args.name } : {},
9399
+ ...args.version ? { version: args.version } : {},
9400
+ list: args.list,
9401
+ cliVersion: CLI_VERSION
9402
+ })
9403
+ );
9404
+ }
9405
+ });
9406
+ }
9407
+ });
9408
+
9066
9409
  // src/main.ts
9067
9410
  var main_exports = {};
9068
9411
  __export(main_exports, {
9069
9412
  main: () => main
9070
9413
  });
9071
- import { defineCommand as defineCommand30 } from "citty";
9414
+ import { defineCommand as defineCommand31 } from "citty";
9072
9415
  var main;
9073
9416
  var init_main = __esm({
9074
9417
  "src/main.ts"() {
@@ -9102,8 +9445,9 @@ var init_main = __esm({
9102
9445
  init_status();
9103
9446
  init_stop();
9104
9447
  init_tunnel();
9448
+ init_upgrade2();
9105
9449
  init_version();
9106
- main = defineCommand30({
9450
+ main = defineCommand31({
9107
9451
  meta: {
9108
9452
  name: "monoceros",
9109
9453
  version: CLI_VERSION,
@@ -9119,6 +9463,7 @@ var init_main = __esm({
9119
9463
  stop: stopCommand,
9120
9464
  status: statusCommand,
9121
9465
  apply: applyCommand,
9466
+ upgrade: upgradeCommand,
9122
9467
  remove: removeCommand,
9123
9468
  restore: restoreCommand,
9124
9469
  "add-service": addServiceCommand,