@getmonoceros/workbench 1.15.0 → 1.16.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
@@ -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,27 @@ var init_port_check = __esm({
1821
1831
  });
1822
1832
 
1823
1833
  // src/create/catalog.ts
1834
+ import "fs";
1835
+ import "url";
1836
+ function resolveRuntimeImage(version) {
1837
+ const ov = process.env.MONOCEROS_BASE_IMAGE_OVERRIDE?.trim();
1838
+ if (ov && ov.length > 0) return ov;
1839
+ if (!version) return `${RUNTIME_IMAGE_REPO}:1`;
1840
+ return `${RUNTIME_IMAGE_REPO}:${version}`;
1841
+ }
1842
+ function compareRuntimeVersions(a, b) {
1843
+ const pa = a.split(".").map(Number);
1844
+ const pb = b.split(".").map(Number);
1845
+ for (let i = 0; i < 3; i++) {
1846
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
1847
+ if (d !== 0) return d < 0 ? -1 : 1;
1848
+ }
1849
+ return 0;
1850
+ }
1851
+ function runtimeSupportsIdeVolumes(version) {
1852
+ if (!version) return false;
1853
+ return compareRuntimeVersions(version, MIN_RUNTIME_FOR_IDE_VOLUMES) >= 0;
1854
+ }
1824
1855
  function parseLanguageSpec(spec) {
1825
1856
  const m = LANGUAGE_SPEC_RE.exec(spec);
1826
1857
  if (!m) return null;
@@ -1877,13 +1908,21 @@ function deriveServiceName(image) {
1877
1908
  const noTag = lastSegment.split("@")[0].split(":")[0];
1878
1909
  return noTag.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
1879
1910
  }
1880
- var DEFAULT_BASE_IMAGE, override, BASE_IMAGE, BUILTIN_LANGUAGES, LANGUAGE_CATALOG, LANGUAGE_SPEC_RE, SERVICE_CATALOG;
1911
+ 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
1912
  var init_catalog = __esm({
1882
1913
  "src/create/catalog.ts"() {
1883
1914
  "use strict";
1884
1915
  DEFAULT_BASE_IMAGE = "ghcr.io/getmonoceros/monoceros-runtime:1";
1885
1916
  override = process.env.MONOCEROS_BASE_IMAGE_OVERRIDE?.trim();
1886
1917
  BASE_IMAGE = override && override.length > 0 ? override : DEFAULT_BASE_IMAGE;
1918
+ RUNTIME_IMAGE_REPO = "ghcr.io/getmonoceros/monoceros-runtime";
1919
+ DEFAULT_RUNTIME_VERSION = true ? "1.1.0" : readFileSync3(
1920
+ fileURLToPath2(
1921
+ new URL("../../../../images/runtime/VERSION", import.meta.url)
1922
+ ),
1923
+ "utf8"
1924
+ ).trim();
1925
+ MIN_RUNTIME_FOR_IDE_VOLUMES = "1.1.0";
1887
1926
  BUILTIN_LANGUAGES = /* @__PURE__ */ new Set(["node"]);
1888
1927
  LANGUAGE_CATALOG = {
1889
1928
  node: { id: "node", feature: "ghcr.io/devcontainers/features/node:1" },
@@ -1921,7 +1960,8 @@ var init_catalog = __esm({
1921
1960
  // /var/lib/postgresql/data directly. See
1922
1961
  // https://github.com/docker-library/postgres/pull/1259.
1923
1962
  dataMount: "/var/lib/postgresql",
1924
- defaultPort: 5432
1963
+ defaultPort: 5432,
1964
+ vscodeExtensions: ["cweijan.vscode-database-client2"]
1925
1965
  },
1926
1966
  mysql: {
1927
1967
  id: "mysql",
@@ -1946,7 +1986,8 @@ var init_catalog = __esm({
1946
1986
  retries: 5
1947
1987
  },
1948
1988
  dataMount: "/var/lib/mysql",
1949
- defaultPort: 3306
1989
+ defaultPort: 3306,
1990
+ vscodeExtensions: ["cweijan.vscode-database-client2"]
1950
1991
  },
1951
1992
  redis: {
1952
1993
  id: "redis",
@@ -1958,7 +1999,8 @@ var init_catalog = __esm({
1958
1999
  retries: 5
1959
2000
  },
1960
2001
  dataMount: "/data",
1961
- defaultPort: 6379
2002
+ defaultPort: 6379,
2003
+ vscodeExtensions: ["cweijan.vscode-database-client2"]
1962
2004
  }
1963
2005
  };
1964
2006
  }
@@ -2022,7 +2064,7 @@ var init_service_doc = __esm({
2022
2064
  });
2023
2065
 
2024
2066
  // src/create/scaffold.ts
2025
- import { existsSync as existsSync5, readFileSync as readFileSync3, promises as fs7 } from "fs";
2067
+ import { existsSync as existsSync5, readFileSync as readFileSync4, promises as fs7 } from "fs";
2026
2068
  import path8 from "path";
2027
2069
  function deriveRepoName(url) {
2028
2070
  const lastSep = Math.max(url.lastIndexOf("/"), url.lastIndexOf(":"));
@@ -2134,6 +2176,7 @@ function normalizeOptions(opts) {
2134
2176
  const ports = opts.ports ? [...new Set(opts.ports)] : void 0;
2135
2177
  return {
2136
2178
  name: opts.name,
2179
+ ...opts.runtimeVersion !== void 0 ? { runtimeVersion: opts.runtimeVersion } : {},
2137
2180
  languages,
2138
2181
  services,
2139
2182
  postgresUrl: opts.postgresUrl,
@@ -2208,7 +2251,7 @@ function resolveFeatures(opts) {
2208
2251
  function readPersistentHomeEntries(localSourceDir) {
2209
2252
  const manifestPath = path8.join(localSourceDir, "devcontainer-feature.json");
2210
2253
  try {
2211
- const text = readFileSync3(manifestPath, "utf8");
2254
+ const text = readFileSync4(manifestPath, "utf8");
2212
2255
  const parsed = JSON.parse(text);
2213
2256
  return {
2214
2257
  paths: filterSubpaths(parsed["x-monoceros"]?.persistentHomePaths),
@@ -2246,6 +2289,18 @@ function filterFileEntries(raw) {
2246
2289
  function isValidHomeSubpath(p) {
2247
2290
  return p.length > 0 && !p.startsWith("/") && !p.includes("..") && HOME_SUBPATH_RE.test(p);
2248
2291
  }
2292
+ function ideStateVolumes(name) {
2293
+ return [
2294
+ {
2295
+ volume: `monoceros-${name}-vscode-extensions`,
2296
+ target: "/home/node/.vscode-server/extensions"
2297
+ },
2298
+ {
2299
+ volume: `monoceros-${name}-vscode-userdata`,
2300
+ target: "/home/node/.vscode-server/data/User"
2301
+ }
2302
+ ];
2303
+ }
2249
2304
  function buildDevcontainerJson(opts, dockerMode = "rootful") {
2250
2305
  const resolvedFeatures = resolveFeatures(opts);
2251
2306
  const features = {};
@@ -2291,7 +2346,10 @@ function buildDevcontainerJson(opts, dockerMode = "rootful") {
2291
2346
  ...customizationsField ?? {}
2292
2347
  };
2293
2348
  }
2294
- const mounts = [...homeMounts];
2349
+ const ideMounts = runtimeSupportsIdeVolumes(opts.runtimeVersion) ? ideStateVolumes(opts.name).map(
2350
+ (v) => `source=${v.volume},target=${v.target},type=volume`
2351
+ ) : [];
2352
+ const mounts = [...homeMounts, ...ideMounts];
2295
2353
  const mountsField = mounts.length > 0 ? { mounts } : {};
2296
2354
  const workspaceMountField = {
2297
2355
  workspaceMount: `source=\${localWorkspaceFolder},target=/workspaces/${opts.name},type=bind,consistency=cached`,
@@ -2304,7 +2362,7 @@ function buildDevcontainerJson(opts, dockerMode = "rootful") {
2304
2362
  }
2305
2363
  return {
2306
2364
  name: opts.name,
2307
- image: BASE_IMAGE,
2365
+ image: resolveRuntimeImage(opts.runtimeVersion),
2308
2366
  remoteUser: "node",
2309
2367
  ...workspaceMountField,
2310
2368
  ...mountsField,
@@ -2331,8 +2389,9 @@ function buildComposeYaml(opts, dockerMode = "rootful") {
2331
2389
  void dockerMode;
2332
2390
  const hasPorts = (opts.ports?.length ?? 0) > 0;
2333
2391
  const lines = ["services:"];
2392
+ const ideVolumes = runtimeSupportsIdeVolumes(opts.runtimeVersion) ? ideStateVolumes(opts.name) : [];
2334
2393
  lines.push(" workspace:");
2335
- lines.push(` image: ${BASE_IMAGE}`);
2394
+ lines.push(` image: ${resolveRuntimeImage(opts.runtimeVersion)}`);
2336
2395
  lines.push(" command: 'sleep infinity'");
2337
2396
  lines.push(" cap_add:");
2338
2397
  lines.push(" - NET_ADMIN");
@@ -2355,6 +2414,9 @@ function buildComposeYaml(opts, dockerMode = "rootful") {
2355
2414
  lines.push(` - ../home/${sub}:/home/node/${sub}`);
2356
2415
  }
2357
2416
  }
2417
+ for (const v of ideVolumes) {
2418
+ lines.push(` - ${v.volume}:${v.target}`);
2419
+ }
2358
2420
  for (const svc of opts.services) {
2359
2421
  lines.push(` ${svc.name}:`);
2360
2422
  lines.push(` image: ${svc.image}`);
@@ -2393,6 +2455,13 @@ function buildComposeYaml(opts, dockerMode = "rootful") {
2393
2455
  }
2394
2456
  }
2395
2457
  }
2458
+ if (ideVolumes.length > 0) {
2459
+ lines.push("volumes:");
2460
+ for (const v of ideVolumes) {
2461
+ lines.push(` ${v.volume}:`);
2462
+ lines.push(` name: ${v.volume}`);
2463
+ }
2464
+ }
2396
2465
  if (hasPorts) {
2397
2466
  lines.push("networks:");
2398
2467
  lines.push(" monoceros-proxy:");
@@ -2400,8 +2469,34 @@ function buildComposeYaml(opts, dockerMode = "rootful") {
2400
2469
  }
2401
2470
  return lines.join("\n") + "\n";
2402
2471
  }
2472
+ function extractRepoHost(url) {
2473
+ const schemeMatch = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)/i.exec(url);
2474
+ if (schemeMatch) return schemeMatch[1].toLowerCase();
2475
+ const scpMatch = /^(?:[^@/]+@)?([^/:]+):/.exec(url);
2476
+ if (scpMatch) return scpMatch[1].toLowerCase();
2477
+ return null;
2478
+ }
2479
+ function computeExtensionRecommendations(opts) {
2480
+ const recs = /* @__PURE__ */ new Set();
2481
+ for (const svc of opts.services) {
2482
+ const catalogEntry = SERVICE_CATALOG[svc.name];
2483
+ for (const ext of catalogEntry?.vscodeExtensions ?? []) {
2484
+ recs.add(ext);
2485
+ }
2486
+ }
2487
+ for (const repo of opts.repos ?? []) {
2488
+ const host = extractRepoHost(repo.url);
2489
+ if (!host) continue;
2490
+ for (const ext of REPO_HOST_EXTENSIONS[host] ?? []) {
2491
+ recs.add(ext);
2492
+ }
2493
+ }
2494
+ return [...recs].sort((a, b) => a.localeCompare(b));
2495
+ }
2403
2496
  function buildCodeWorkspaceJson(opts) {
2404
- const folders = [{ path: "." }];
2497
+ const folders = [
2498
+ { path: ".", name: WORKSPACE_ROOT_LABEL }
2499
+ ];
2405
2500
  const sortedRepos = [...opts.repos ?? []].sort(
2406
2501
  (a, b) => a.path.localeCompare(b.path)
2407
2502
  );
@@ -2409,7 +2504,11 @@ function buildCodeWorkspaceJson(opts) {
2409
2504
  const label = repo.path.split("/").pop() ?? repo.path;
2410
2505
  folders.push({ path: `projects/${repo.path}`, name: label });
2411
2506
  }
2412
- return { folders };
2507
+ const recommendations = computeExtensionRecommendations(opts);
2508
+ return {
2509
+ folders,
2510
+ ...recommendations.length > 0 ? { extensions: { recommendations } } : {}
2511
+ };
2413
2512
  }
2414
2513
  function mergeCodeWorkspace(existing, generated) {
2415
2514
  if (!existing || typeof existing !== "object" || Array.isArray(existing) || !Array.isArray(existing.folders)) {
@@ -2424,10 +2523,41 @@ function mergeCodeWorkspace(existing, generated) {
2424
2523
  for (const g of generated.folders) {
2425
2524
  if (!existingPaths.has(g.path)) merged.push(g);
2426
2525
  }
2526
+ const generatedRootName = generated.folders.find((f) => f.path === ".")?.name;
2527
+ if (generatedRootName) {
2528
+ const idx = merged.findIndex(
2529
+ (f) => f && typeof f === "object" && f.path === "."
2530
+ );
2531
+ if (idx >= 0 && !merged[idx].name) {
2532
+ merged[idx] = { ...merged[idx], name: generatedRootName };
2533
+ }
2534
+ }
2427
2535
  const out = { ...existingObj };
2428
2536
  out.folders = merged;
2537
+ const generatedRecs = generated.extensions?.recommendations ?? [];
2538
+ if (generatedRecs.length > 0) {
2539
+ const existingExtRaw = existingObj.extensions;
2540
+ const existingExt = existingExtRaw && typeof existingExtRaw === "object" && !Array.isArray(existingExtRaw) ? existingExtRaw : {};
2541
+ const existingRecs = Array.isArray(existingExt.recommendations) ? existingExt.recommendations.filter(
2542
+ (r) => typeof r === "string"
2543
+ ) : [];
2544
+ const existingRecSet = new Set(existingRecs);
2545
+ const mergedRecs = [...existingRecs];
2546
+ for (const r of generatedRecs) {
2547
+ if (!existingRecSet.has(r)) mergedRecs.push(r);
2548
+ }
2549
+ out.extensions = { ...existingExt, recommendations: mergedRecs };
2550
+ }
2429
2551
  return out;
2430
2552
  }
2553
+ function mergeVscodeSettings(existing) {
2554
+ const base = existing && typeof existing === "object" && !Array.isArray(existing) ? existing : {};
2555
+ const existingExclude = base["files.exclude"] && typeof base["files.exclude"] === "object" && !Array.isArray(base["files.exclude"]) ? base["files.exclude"] : {};
2556
+ return {
2557
+ ...base,
2558
+ "files.exclude": { ...existingExclude, ...ROOT_DENOISE_EXCLUDES }
2559
+ };
2560
+ }
2431
2561
  function buildPostCreateScript(opts) {
2432
2562
  const lines = [
2433
2563
  "#!/usr/bin/env bash",
@@ -2528,6 +2658,14 @@ async function writePostCreateScript(devcontainerDir, opts) {
2528
2658
  await fs7.writeFile(dest, buildPostCreateScript(opts));
2529
2659
  await fs7.chmod(dest, 493);
2530
2660
  }
2661
+ async function writeIfChanged(filePath, content) {
2662
+ try {
2663
+ if (await fs7.readFile(filePath, "utf8") === content) return false;
2664
+ } catch {
2665
+ }
2666
+ await fs7.writeFile(filePath, content);
2667
+ return true;
2668
+ }
2531
2669
  async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2532
2670
  const dockerMode = scaffoldOpts.dockerMode ?? "rootful";
2533
2671
  const devcontainerDir = path8.join(targetDir, ".devcontainer");
@@ -2562,7 +2700,7 @@ async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2562
2700
  "git-credentials*\ngitconfig\n"
2563
2701
  );
2564
2702
  const devcontainerJson = buildDevcontainerJson(opts, dockerMode);
2565
- await fs7.writeFile(
2703
+ await writeIfChanged(
2566
2704
  path8.join(devcontainerDir, "devcontainer.json"),
2567
2705
  JSON.stringify(devcontainerJson, null, 2) + "\n"
2568
2706
  );
@@ -2592,7 +2730,7 @@ async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2592
2730
  await writePostCreateScript(devcontainerDir, opts);
2593
2731
  const composePath = path8.join(devcontainerDir, "compose.yaml");
2594
2732
  if (needsCompose(opts)) {
2595
- await fs7.writeFile(composePath, buildComposeYaml(opts, dockerMode));
2733
+ await writeIfChanged(composePath, buildComposeYaml(opts, dockerMode));
2596
2734
  } else if (existsSync5(composePath)) {
2597
2735
  await fs7.rm(composePath);
2598
2736
  }
@@ -2607,8 +2745,21 @@ async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2607
2745
  const generated = buildCodeWorkspaceJson(opts);
2608
2746
  const merged = mergeCodeWorkspace(existingWorkspace, generated);
2609
2747
  await fs7.writeFile(workspacePath, JSON.stringify(merged, null, 2) + "\n");
2748
+ const vscodeDir = path8.join(targetDir, ".vscode");
2749
+ const settingsPath = path8.join(vscodeDir, "settings.json");
2750
+ let existingSettings;
2751
+ try {
2752
+ existingSettings = JSON.parse(await fs7.readFile(settingsPath, "utf8"));
2753
+ } catch {
2754
+ existingSettings = void 0;
2755
+ }
2756
+ await fs7.mkdir(vscodeDir, { recursive: true });
2757
+ await fs7.writeFile(
2758
+ settingsPath,
2759
+ JSON.stringify(mergeVscodeSettings(existingSettings), null, 2) + "\n"
2760
+ );
2610
2761
  }
2611
- var APT_PACKAGE_NAME_RE2, FEATURE_REF_RE2, INSTALL_URL_RE2, REPO_URL_RE2, REPO_PATH_RE2, HOME_SUBPATH_RE;
2762
+ 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
2763
  var init_scaffold = __esm({
2613
2764
  "src/create/scaffold.ts"() {
2614
2765
  "use strict";
@@ -2621,6 +2772,23 @@ var init_scaffold = __esm({
2621
2772
  REPO_URL_RE2 = /^[A-Za-z0-9@:/+_~.#=&?-]+$/;
2622
2773
  REPO_PATH_RE2 = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
2623
2774
  HOME_SUBPATH_RE = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
2775
+ WORKSPACE_ROOT_LABEL = "\u{1F984} Monoceros";
2776
+ REPO_HOST_EXTENSIONS = {
2777
+ "github.com": [
2778
+ "github.vscode-pull-request-github",
2779
+ "GitHub.vscode-github-actions"
2780
+ ],
2781
+ "gitlab.com": ["GitLab.gitlab-workflow"]
2782
+ };
2783
+ ROOT_DENOISE_EXCLUDES = {
2784
+ ".devcontainer": true,
2785
+ ".monoceros": true,
2786
+ ".vscode": true,
2787
+ ".gitignore": true,
2788
+ data: true,
2789
+ projects: true,
2790
+ "*.code-workspace": true
2791
+ };
2624
2792
  }
2625
2793
  });
2626
2794
 
@@ -4097,7 +4265,8 @@ function buildStateFile(opts) {
4097
4265
  schemaVersion: CONFIG_SCHEMA_VERSION,
4098
4266
  origin: opts.origin,
4099
4267
  monocerosCliVersion: opts.cliVersion,
4100
- materializedAt: (opts.now ?? /* @__PURE__ */ new Date()).toISOString()
4268
+ materializedAt: (opts.now ?? /* @__PURE__ */ new Date()).toISOString(),
4269
+ ...opts.runtimeImage ? { runtimeImage: opts.runtimeImage } : {}
4101
4270
  };
4102
4271
  }
4103
4272
  function stateFilePath(targetDir) {
@@ -4138,6 +4307,7 @@ function solutionConfigToCreateOptions(config, featureDefaults = {}) {
4138
4307
  }
4139
4308
  const result = {
4140
4309
  name: config.name,
4310
+ ...config.runtimeVersion !== void 0 ? { runtimeVersion: config.runtimeVersion } : {},
4141
4311
  languages: [...config.languages],
4142
4312
  // Normalize every services[] entry (curated string or explicit
4143
4313
  // object) to the canonical ResolvedService shape. `${VAR}` values
@@ -4435,12 +4605,22 @@ var init_apply_progress = __esm({
4435
4605
  FRAME_INTERVAL_MS = 80;
4436
4606
  TAIL_LINES = 15;
4437
4607
  PHASE_TRIGGERS = [
4438
- // Compose mode triggers a feature/layer build before the container
4439
- // is created distinct phase, often the longest single step.
4440
- { pattern: /Start: Run: docker build/i, label: "building feature layers\u2026" },
4441
- // Image mode jumps straight from "preparing…" into the docker run
4442
- // that pulls (if needed) + creates + starts the container.
4443
- { pattern: /Start: Run: docker run/i, label: "starting container\u2026" },
4608
+ // Feature/layer build distinct phase, often the longest single
4609
+ // step. Image mode runs `docker build`; compose mode runs
4610
+ // `docker compose build <services>`. We match the build *subcommand*
4611
+ // (a space-delimited ` build`), NOT the substring the compose `up`
4612
+ // line below carries `-f …devcontainer.build-<n>.yml` and would
4613
+ // otherwise false-match here and swallow the "starting" phase.
4614
+ {
4615
+ pattern: /Start: Run: docker (?:build\b|compose\b.* build\b)/i,
4616
+ label: "building feature layers\u2026"
4617
+ },
4618
+ // Container create/start. Image mode: `docker run` (pulls if needed,
4619
+ // creates, starts). Compose mode: `docker compose … up -d <services>`.
4620
+ {
4621
+ pattern: /Start: Run: docker (?:run\b|compose\b.* up\b)/i,
4622
+ label: "starting container\u2026"
4623
+ },
4444
4624
  { pattern: /Running the postCreateCommand/i, label: "running postCreate\u2026" }
4445
4625
  ];
4446
4626
  }
@@ -5148,13 +5328,13 @@ var init_runtime_pull_hint = __esm({
5148
5328
 
5149
5329
  // src/devcontainer/cli.ts
5150
5330
  import { spawn as spawn4 } from "child_process";
5151
- import { readFileSync as readFileSync4 } from "fs";
5331
+ import { readFileSync as readFileSync5 } from "fs";
5152
5332
  import { createRequire } from "module";
5153
5333
  import path13 from "path";
5154
5334
  function devcontainerCliPath() {
5155
5335
  if (cachedBinaryPath) return cachedBinaryPath;
5156
5336
  const pkgJsonPath = require_.resolve("@devcontainers/cli/package.json");
5157
- const pkg = JSON.parse(readFileSync4(pkgJsonPath, "utf8"));
5337
+ const pkg = JSON.parse(readFileSync5(pkgJsonPath, "utf8"));
5158
5338
  const binEntry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.devcontainer ?? "";
5159
5339
  if (!binEntry) {
5160
5340
  throw new Error("Could not resolve @devcontainers/cli bin entry.");
@@ -5726,6 +5906,11 @@ ${sectionLine(label)}
5726
5906
  await assertSafeTargetDir(targetDir, opts.name);
5727
5907
  section("Configuration");
5728
5908
  const parsed = await readConfig(ymlPath);
5909
+ if (!parsed.config.runtimeVersion) {
5910
+ throw new Error(
5911
+ `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.`
5912
+ );
5913
+ }
5729
5914
  const globalConfig = await readMonocerosConfig({ monocerosHome: home });
5730
5915
  warnOnDeprecatedFeatureRefs(parsed.config.features, globalConfig, logger);
5731
5916
  const envPath = containerEnvPath(opts.name, home);
@@ -5837,6 +6022,7 @@ Fix the value in the env file (or the yml).`
5837
6022
  buildStateFile({
5838
6023
  origin: opts.name,
5839
6024
  cliVersion: opts.cliVersion,
6025
+ runtimeImage: resolveRuntimeImage(createOpts.runtimeVersion),
5840
6026
  ...opts.now ? { now: opts.now } : {}
5841
6027
  })
5842
6028
  );
@@ -6059,6 +6245,7 @@ var init_apply = __esm({
6059
6245
  init_schema();
6060
6246
  init_state();
6061
6247
  init_transform();
6248
+ init_catalog();
6062
6249
  init_scaffold();
6063
6250
  init_format();
6064
6251
  init_ref();
@@ -6085,7 +6272,7 @@ var CLI_VERSION;
6085
6272
  var init_version = __esm({
6086
6273
  "src/version.ts"() {
6087
6274
  "use strict";
6088
- CLI_VERSION = true ? "1.15.0" : "dev";
6275
+ CLI_VERSION = true ? "1.16.1" : "dev";
6089
6276
  }
6090
6277
  });
6091
6278
 
@@ -6510,6 +6697,7 @@ var init_resolve = __esm({
6510
6697
  "stop",
6511
6698
  "status",
6512
6699
  "apply",
6700
+ "upgrade",
6513
6701
  "remove",
6514
6702
  "restore",
6515
6703
  "add-service",
@@ -6554,6 +6742,12 @@ var init_resolve = __esm({
6554
6742
  positionals: [containerName],
6555
6743
  flags: { "--yes": { type: "boolean", aliases: ["-y"] } }
6556
6744
  },
6745
+ upgrade: {
6746
+ // First positional is a container name; the second is a version
6747
+ // string (no suggestions — versions live in the registry).
6748
+ positionals: [containerName, () => []],
6749
+ flags: { "--list": { type: "boolean" } }
6750
+ },
6557
6751
  remove: {
6558
6752
  positionals: [containerName],
6559
6753
  flags: {
@@ -6704,6 +6898,11 @@ function generateComposedYml(name, composed, lookupManifest, repoUrls = [], port
6704
6898
  lines.push("");
6705
6899
  lines.push("schemaVersion: 1");
6706
6900
  lines.push(`name: ${name}`);
6901
+ lines.push(
6902
+ "# Pinned runtime image version. Reused on every apply; change it with"
6903
+ );
6904
+ lines.push("# `monoceros upgrade <name> [version]`.");
6905
+ lines.push(`runtimeVersion: ${DEFAULT_RUNTIME_VERSION}`);
6707
6906
  lines.push("");
6708
6907
  if (composed.languages.length > 0) {
6709
6908
  pushSectionHeader(
@@ -6802,6 +7001,11 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
6802
7001
  lines.push("");
6803
7002
  lines.push("schemaVersion: 1");
6804
7003
  lines.push(`name: ${name}`);
7004
+ lines.push(
7005
+ "# Pinned runtime image version. Reused on every apply; change it with"
7006
+ );
7007
+ lines.push("# `monoceros upgrade <name> [version]`.");
7008
+ lines.push(`runtimeVersion: ${DEFAULT_RUNTIME_VERSION}`);
6805
7009
  lines.push("");
6806
7010
  if (byCategory.language.length > 0) {
6807
7011
  pushSectionHeader(
@@ -7802,6 +8006,8 @@ async function runRemove(opts) {
7802
8006
  logger,
7803
8007
  exec: dockerExec
7804
8008
  });
8009
+ const ideVolumes = ideStateVolumes(opts.name).map((v) => v.volume);
8010
+ await dockerExec(["volume", "rm", "-f", ...ideVolumes]);
7805
8011
  let backupPath = null;
7806
8012
  if (!opts.noBackup && (hasYml || hasContainer)) {
7807
8013
  const ts = (opts.now ?? /* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -7896,6 +8102,7 @@ var init_remove = __esm({
7896
8102
  init_paths();
7897
8103
  init_schema();
7898
8104
  init_compose();
8105
+ init_scaffold();
7899
8106
  init_proxy();
7900
8107
  init_dynamic();
7901
8108
  }
@@ -9063,12 +9270,165 @@ var init_tunnel = __esm({
9063
9270
  }
9064
9271
  });
9065
9272
 
9273
+ // src/upgrade/index.ts
9274
+ import { existsSync as existsSync14, promises as fs17 } from "fs";
9275
+ import { consola as consola34 } from "consola";
9276
+ async function fetchRuntimeVersions() {
9277
+ const tokenUrl = `https://ghcr.io/token?service=ghcr.io&scope=repository:${RUNTIME_REPO}:pull`;
9278
+ const tokenRes = await fetch(tokenUrl);
9279
+ if (!tokenRes.ok) {
9280
+ throw new Error(
9281
+ `Could not get a GHCR token (HTTP ${tokenRes.status}). Pass an explicit version to skip the lookup: \`monoceros upgrade <name> <version>\`.`
9282
+ );
9283
+ }
9284
+ const { token } = await tokenRes.json();
9285
+ if (!token) throw new Error("GHCR token response contained no token.");
9286
+ const tagsRes = await fetch(`https://ghcr.io/v2/${RUNTIME_REPO}/tags/list`, {
9287
+ headers: { Authorization: `Bearer ${token}` }
9288
+ });
9289
+ if (!tagsRes.ok) {
9290
+ throw new Error(
9291
+ `Could not list runtime versions (HTTP ${tagsRes.status}). Pass an explicit version: \`monoceros upgrade <name> <version>\`.`
9292
+ );
9293
+ }
9294
+ const { tags } = await tagsRes.json();
9295
+ return (tags ?? []).filter((t) => VERSION_RE.test(t)).sort(compareRuntimeVersions);
9296
+ }
9297
+ function setRuntimeVersion(yml, version) {
9298
+ if (/^runtimeVersion:.*$/m.test(yml)) {
9299
+ return yml.replace(/^runtimeVersion:.*$/m, `runtimeVersion: ${version}`);
9300
+ }
9301
+ if (/^schemaVersion:.*$/m.test(yml)) {
9302
+ return yml.replace(
9303
+ /^(schemaVersion:.*)$/m,
9304
+ `$1
9305
+ runtimeVersion: ${version}`
9306
+ );
9307
+ }
9308
+ return `runtimeVersion: ${version}
9309
+ ${yml}`;
9310
+ }
9311
+ async function runUpgrade(opts) {
9312
+ const home = opts.monocerosHome ?? monocerosHome();
9313
+ const logger = opts.logger ?? consola34;
9314
+ const fetchVersions = opts.fetchVersions ?? fetchRuntimeVersions;
9315
+ if (opts.list) {
9316
+ const versions = await fetchVersions();
9317
+ if (versions.length === 0) {
9318
+ logger.info("No published runtime versions found.");
9319
+ return 0;
9320
+ }
9321
+ logger.info(
9322
+ `Available runtime versions (latest last):
9323
+ ${versions.join("\n ")}`
9324
+ );
9325
+ return 0;
9326
+ }
9327
+ if (!opts.name) {
9328
+ throw new Error(
9329
+ "Usage: `monoceros upgrade <name> [version]` (or `monoceros upgrade --list`)."
9330
+ );
9331
+ }
9332
+ const ymlPath = containerConfigPath(opts.name, home);
9333
+ if (!existsSync14(ymlPath)) {
9334
+ throw new Error(
9335
+ `No such config: ${ymlPath}. Run \`monoceros init <template> ${opts.name}\` first.`
9336
+ );
9337
+ }
9338
+ let target = opts.version;
9339
+ if (target !== void 0 && !VERSION_RE.test(target)) {
9340
+ throw new Error(
9341
+ `Invalid version ${JSON.stringify(target)}. Expected an exact version like '1.1.0'.`
9342
+ );
9343
+ }
9344
+ if (target === void 0) {
9345
+ const versions = await fetchVersions();
9346
+ target = versions[versions.length - 1];
9347
+ if (!target) {
9348
+ throw new Error("Could not determine the latest runtime version.");
9349
+ }
9350
+ logger.info(`Latest published runtime version: ${target}`);
9351
+ }
9352
+ const raw = await fs17.readFile(ymlPath, "utf8");
9353
+ const updated = setRuntimeVersion(raw, target);
9354
+ if (updated === raw) {
9355
+ logger.info(`'${opts.name}' is already pinned to runtime ${target}.`);
9356
+ } else {
9357
+ await fs17.writeFile(ymlPath, updated);
9358
+ logger.success(`Pinned '${opts.name}' to runtime ${target}. Re-applying\u2026`);
9359
+ }
9360
+ const apply = opts.applyRunner ?? runApply;
9361
+ const result = await apply({
9362
+ name: opts.name,
9363
+ cliVersion: opts.cliVersion,
9364
+ monocerosHome: home
9365
+ });
9366
+ return result.containerExitCode;
9367
+ }
9368
+ var RUNTIME_REPO, VERSION_RE;
9369
+ var init_upgrade = __esm({
9370
+ "src/upgrade/index.ts"() {
9371
+ "use strict";
9372
+ init_paths();
9373
+ init_catalog();
9374
+ init_apply();
9375
+ RUNTIME_REPO = "getmonoceros/monoceros-runtime";
9376
+ VERSION_RE = /^\d+\.\d+\.\d+$/;
9377
+ }
9378
+ });
9379
+
9380
+ // src/commands/upgrade.ts
9381
+ import { defineCommand as defineCommand30 } from "citty";
9382
+ var upgradeCommand;
9383
+ var init_upgrade2 = __esm({
9384
+ "src/commands/upgrade.ts"() {
9385
+ "use strict";
9386
+ init_upgrade();
9387
+ init_version();
9388
+ init_dispatch();
9389
+ upgradeCommand = defineCommand30({
9390
+ meta: {
9391
+ name: "upgrade",
9392
+ group: "lifecycle",
9393
+ 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."
9394
+ },
9395
+ args: {
9396
+ name: {
9397
+ type: "positional",
9398
+ description: "Config name. Resolves to $MONOCEROS_HOME/container-configs/<name>.yml.",
9399
+ required: false
9400
+ },
9401
+ version: {
9402
+ type: "positional",
9403
+ description: "Exact runtime version to pin (e.g. 1.1.0). Omit to use the latest published version.",
9404
+ required: false
9405
+ },
9406
+ list: {
9407
+ type: "boolean",
9408
+ description: "List available runtime versions and exit, changing nothing.",
9409
+ default: false
9410
+ }
9411
+ },
9412
+ run({ args }) {
9413
+ return dispatch(
9414
+ () => runUpgrade({
9415
+ ...args.name ? { name: args.name } : {},
9416
+ ...args.version ? { version: args.version } : {},
9417
+ list: args.list,
9418
+ cliVersion: CLI_VERSION
9419
+ })
9420
+ );
9421
+ }
9422
+ });
9423
+ }
9424
+ });
9425
+
9066
9426
  // src/main.ts
9067
9427
  var main_exports = {};
9068
9428
  __export(main_exports, {
9069
9429
  main: () => main
9070
9430
  });
9071
- import { defineCommand as defineCommand30 } from "citty";
9431
+ import { defineCommand as defineCommand31 } from "citty";
9072
9432
  var main;
9073
9433
  var init_main = __esm({
9074
9434
  "src/main.ts"() {
@@ -9102,8 +9462,9 @@ var init_main = __esm({
9102
9462
  init_status();
9103
9463
  init_stop();
9104
9464
  init_tunnel();
9465
+ init_upgrade2();
9105
9466
  init_version();
9106
- main = defineCommand30({
9467
+ main = defineCommand31({
9107
9468
  meta: {
9108
9469
  name: "monoceros",
9109
9470
  version: CLI_VERSION,
@@ -9119,6 +9480,7 @@ var init_main = __esm({
9119
9480
  stop: stopCommand,
9120
9481
  status: statusCommand,
9121
9482
  apply: applyCommand,
9483
+ upgrade: upgradeCommand,
9122
9484
  remove: removeCommand,
9123
9485
  restore: restoreCommand,
9124
9486
  "add-service": addServiceCommand,