@getmonoceros/workbench 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/bin.js +1185 -519
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -251,25 +251,25 @@ function detectHelpRequest(argv, main2) {
251
251
  const separatorIdx = argv.indexOf("--");
252
252
  if (helpIdx === -1) return null;
253
253
  if (separatorIdx !== -1 && separatorIdx < helpIdx) return null;
254
- const path18 = [];
254
+ const path21 = [];
255
255
  const tokens = argv.slice(
256
256
  0,
257
257
  separatorIdx === -1 ? argv.length : separatorIdx
258
258
  );
259
259
  let cursor = main2;
260
260
  const mainName = (main2.meta ?? {}).name ?? "monoceros";
261
- path18.push(mainName);
261
+ path21.push(mainName);
262
262
  for (const tok of tokens) {
263
263
  if (tok.startsWith("-")) continue;
264
264
  const subs = cursor.subCommands ?? {};
265
265
  if (tok in subs) {
266
266
  cursor = subs[tok];
267
- path18.push(tok);
267
+ path21.push(tok);
268
268
  continue;
269
269
  }
270
270
  break;
271
271
  }
272
- return { path: path18, cmd: cursor };
272
+ return { path: path21, cmd: cursor };
273
273
  }
274
274
  async function maybeRenderHelp(argv, main2) {
275
275
  const hit = detectHelpRequest(argv, main2);
@@ -311,7 +311,7 @@ import { consola as consola2 } from "consola";
311
311
  import { promises as fs8 } from "fs";
312
312
  import { consola } from "consola";
313
313
  import { createPatch } from "diff";
314
- import path8 from "path";
314
+ import path9 from "path";
315
315
 
316
316
  // src/config/io.ts
317
317
  import { promises as fs } from "fs";
@@ -404,6 +404,68 @@ var RoutingSchema = z.object({
404
404
  ports: z.array(PortEntrySchema).default([]),
405
405
  vscodeAutoForward: z.boolean().optional()
406
406
  });
407
+ var SERVICE_NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
408
+ var ServiceEnvValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]).transform((v) => v === null ? "" : String(v));
409
+ var ServiceHealthcheckSchema = z.object({
410
+ // Compose accepts both forms and they differ semantically:
411
+ // - string → run via the shell (CMD-SHELL)
412
+ // - ["CMD", …] → exec the args directly, no shell
413
+ // - ["CMD-SHELL", …]
414
+ // We accept either and render it back faithfully.
415
+ test: z.union([
416
+ z.string().min(1, "Healthcheck test must not be empty."),
417
+ z.array(z.string().min(1)).min(1, "Healthcheck test array must not be empty.")
418
+ ]),
419
+ interval: z.string().optional(),
420
+ timeout: z.string().optional(),
421
+ retries: z.number().int().min(1).optional(),
422
+ startPeriod: z.string().optional()
423
+ });
424
+ var SERVICE_RESTART_VALUES = [
425
+ "no",
426
+ "always",
427
+ "on-failure",
428
+ "unless-stopped"
429
+ ];
430
+ function isValidServiceVolume(spec) {
431
+ const parts = spec.split(":");
432
+ if (parts.length < 2 || parts.length > 3) return false;
433
+ const [src, dest, mode] = parts;
434
+ if (!src || !dest) return false;
435
+ if (!dest.startsWith("/")) return false;
436
+ if (mode !== void 0 && !/^(ro|rw|cached|delegated|z|Z)$/.test(mode)) {
437
+ return false;
438
+ }
439
+ if (src === "data") return true;
440
+ if (src.startsWith("/")) return false;
441
+ const looksLikePath = src.startsWith("./") || src.includes("/");
442
+ if (!looksLikePath) return false;
443
+ const normalized = src.startsWith("./") ? src.slice(2) : src;
444
+ if (normalized.split("/").some((s) => s === ".." || s === ".")) return false;
445
+ return true;
446
+ }
447
+ var ServiceObjectSchema = z.object({
448
+ name: z.string().regex(
449
+ SERVICE_NAME_RE,
450
+ "Invalid service name. Use lowercase letters, digits, '_' or '-' (must start with a letter or digit)."
451
+ ),
452
+ image: z.string().min(1, "Service image must not be empty."),
453
+ // In-container port the service listens on. Used by
454
+ // `monoceros tunnel <name> <service>` to forward without an explicit
455
+ // port argument. NOT a host port mapping — host exposure goes through
456
+ // routing.ports (Traefik) or `monoceros tunnel`.
457
+ port: z.number().int().min(1, "Port must be \u2265 1.").max(65535).optional(),
458
+ env: z.record(z.string(), ServiceEnvValueSchema).optional(),
459
+ volumes: z.array(
460
+ z.string().refine(
461
+ isValidServiceVolume,
462
+ "Invalid volume. Use 'data:/container/path' for the per-service persistent dir, or a relative host path ('projects/app/init.sql:/...:ro', './config:/...'). Docker named volumes (a bare name like 'rustfs_data') are not supported; absolute host paths and '..' are rejected."
463
+ )
464
+ ).optional(),
465
+ healthcheck: ServiceHealthcheckSchema.optional(),
466
+ restart: z.enum(SERVICE_RESTART_VALUES).optional(),
467
+ command: z.string().optional()
468
+ });
407
469
  var ExternalServicesSchema = z.object({
408
470
  postgres: z.string().regex(
409
471
  POSTGRES_URL_RE,
@@ -430,7 +492,7 @@ var SolutionConfigSchema = z.object({
430
492
  "Invalid install URL. Must start with 'https://' and contain only URL-safe characters (no shell metacharacters)."
431
493
  )
432
494
  ).default([]),
433
- services: z.array(z.string().min(1)).default([]),
495
+ services: z.array(ServiceObjectSchema).default([]),
434
496
  repos: z.array(RepoEntrySchema).default([]),
435
497
  routing: RoutingSchema.optional(),
436
498
  externalServices: ExternalServicesSchema.default({}),
@@ -549,6 +611,9 @@ function containerConfigsDir(home = monocerosHome()) {
549
611
  function containerConfigPath(name, home = monocerosHome()) {
550
612
  return path.join(containerConfigsDir(home), `${name}.yml`);
551
613
  }
614
+ function containerEnvPath(name, home = monocerosHome()) {
615
+ return path.join(containerConfigsDir(home), `${name}.env`);
616
+ }
552
617
  function containersDir(home = monocerosHome()) {
553
618
  return path.join(home, "container");
554
619
  }
@@ -569,10 +634,323 @@ function prettyPath(p) {
569
634
  return p;
570
635
  }
571
636
 
637
+ // src/config/env-file.ts
638
+ import { existsSync as existsSync2, readFileSync, promises as fsp } from "fs";
639
+ import path2 from "path";
640
+ var ENV_LINE_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
641
+ function parseEnvFile(content) {
642
+ const out = {};
643
+ for (const raw of content.split(/\r?\n/)) {
644
+ const trimmed = raw.trim();
645
+ if (!trimmed || trimmed.startsWith("#")) continue;
646
+ const m = ENV_LINE_RE.exec(raw);
647
+ if (!m) continue;
648
+ const key = m[1];
649
+ let val = m[2].trim();
650
+ if (val.length >= 2 && (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'"))) {
651
+ val = val.slice(1, -1);
652
+ }
653
+ out[key] = val;
654
+ }
655
+ return out;
656
+ }
657
+ function readEnvFile(envPath) {
658
+ if (!existsSync2(envPath)) return {};
659
+ return parseEnvFile(readFileSync(envPath, "utf8"));
660
+ }
661
+ async function ensureEnvGitignored(configsDir) {
662
+ const gitignorePath = path2.join(configsDir, ".gitignore");
663
+ const pattern = "*.env";
664
+ let existing = "";
665
+ if (existsSync2(gitignorePath)) {
666
+ existing = readFileSync(gitignorePath, "utf8");
667
+ const lines = existing.split(/\r?\n/).map((l) => l.trim());
668
+ if (lines.includes(pattern)) return;
669
+ }
670
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
671
+ const header = existing.length === 0 ? "# Per-container env files hold the secrets behind the yml ${VAR}\n# references. Never commit them.\n" : "";
672
+ await fsp.appendFile(gitignorePath, `${prefix}${header}${pattern}
673
+ `);
674
+ }
675
+ var VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
676
+ function interpolate(value, vars) {
677
+ const missing = [];
678
+ const out = value.replace(VAR_RE, (_match, name) => {
679
+ if (Object.prototype.hasOwnProperty.call(vars, name)) return vars[name];
680
+ missing.push(name);
681
+ return _match;
682
+ });
683
+ return { value: out, missing };
684
+ }
685
+ function interpolateServices(services, vars) {
686
+ const missing = [];
687
+ const resolved = services.map((svc) => {
688
+ const interp = (raw, field) => {
689
+ const r = interpolate(raw, vars);
690
+ for (const name of r.missing) {
691
+ missing.push({ location: `services.${svc.name}.${field}`, name });
692
+ }
693
+ return r.value;
694
+ };
695
+ const next = {
696
+ ...svc,
697
+ image: interp(svc.image, "image"),
698
+ env: Object.fromEntries(
699
+ Object.entries(svc.env).map(([k, v]) => [k, interp(v, `env.${k}`)])
700
+ ),
701
+ volumes: svc.volumes.map((v, i) => interp(v, `volumes[${i}]`))
702
+ };
703
+ if (svc.command !== void 0) {
704
+ next.command = interp(svc.command, "command");
705
+ }
706
+ if (svc.healthcheck) {
707
+ const hc = svc.healthcheck;
708
+ next.healthcheck = {
709
+ ...hc,
710
+ test: Array.isArray(hc.test) ? hc.test.map((t, i) => interp(t, `healthcheck.test[${i}]`)) : interp(hc.test, "healthcheck.test"),
711
+ ...hc.interval !== void 0 ? { interval: interp(hc.interval, "healthcheck.interval") } : {},
712
+ ...hc.timeout !== void 0 ? { timeout: interp(hc.timeout, "healthcheck.timeout") } : {},
713
+ ...hc.startPeriod !== void 0 ? { startPeriod: interp(hc.startPeriod, "healthcheck.startPeriod") } : {}
714
+ };
715
+ }
716
+ return next;
717
+ });
718
+ return { services: resolved, missing };
719
+ }
720
+ function interpolateFeatures(features, vars) {
721
+ const missing = [];
722
+ const out = {};
723
+ for (const [ref, options] of Object.entries(features)) {
724
+ const next = {};
725
+ for (const [key, value] of Object.entries(options)) {
726
+ if (typeof value !== "string") {
727
+ next[key] = value;
728
+ continue;
729
+ }
730
+ const r = interpolate(value, vars);
731
+ for (const name of r.missing) {
732
+ missing.push({ location: `features.${ref}.${key}`, name });
733
+ }
734
+ next[key] = r.value;
735
+ }
736
+ out[ref] = next;
737
+ }
738
+ return { features: out, missing };
739
+ }
740
+ function buildEnvStub(name) {
741
+ return `# Secrets and values for \${VAR} references in ${name}.yml.
742
+ `;
743
+ }
744
+ async function ensureEnvVars(envPath, name, vars) {
745
+ const exists = existsSync2(envPath);
746
+ let content = exists ? readFileSync(envPath, "utf8") : buildEnvStub(name);
747
+ const present = new Set(Object.keys(parseEnvFile(content)));
748
+ const added = [...new Set(vars)].filter((v) => !present.has(v));
749
+ if (!exists || added.length > 0) {
750
+ if (content.length > 0 && !content.endsWith("\n")) content += "\n";
751
+ for (const v of added) content += `${v}=
752
+ `;
753
+ await fsp.mkdir(path2.dirname(envPath), { recursive: true });
754
+ await fsp.writeFile(envPath, content);
755
+ }
756
+ return { created: !exists, added };
757
+ }
758
+ function formatMissingVarsError(missing, envPathPretty) {
759
+ const lines = missing.map((m) => ` - \${${m.name}} (${m.location})`);
760
+ const uniqueNames = [...new Set(missing.map((m) => m.name))];
761
+ return `Unresolved \${VAR} references in the container yml:
762
+ ${lines.join("\n")}
763
+
764
+ Define them in ${envPathPretty}, e.g.
765
+ ` + uniqueNames.map((n) => ` ${n}=<value>`).join("\n");
766
+ }
767
+
768
+ // src/init/feature-doc.ts
769
+ function buildFeatureHeaderLines(summary, width) {
770
+ const paragraphs = buildHeaderParagraphs(summary);
771
+ const wrapped = [];
772
+ for (const para of paragraphs) {
773
+ for (const line of wrapToComment(para, width)) {
774
+ wrapped.push(line);
775
+ }
776
+ }
777
+ return wrapped;
778
+ }
779
+ function buildFeatureHeaderCommentBefore(summary, width) {
780
+ const lines = buildFeatureHeaderLines(summary, width);
781
+ return lines.map((l) => ` ${l}`).join("\n");
782
+ }
783
+ function buildHeaderParagraphs(summary) {
784
+ if (!summary) return [];
785
+ const out = [];
786
+ const tagline = summary.name?.trim();
787
+ const description = summary.description?.trim();
788
+ if (tagline && description) {
789
+ out.push(`${tagline} \u2014 ${description}`);
790
+ } else if (tagline) {
791
+ out.push(tagline);
792
+ } else if (description) {
793
+ out.push(description);
794
+ }
795
+ for (const note of summary.usageNotes) {
796
+ const trimmed = note.trim();
797
+ if (trimmed.length > 0) out.push(trimmed);
798
+ }
799
+ if (summary.optionHints.length > 0) {
800
+ const parts = summary.optionHints.map((key) => {
801
+ const desc = summary.optionDescriptions[key];
802
+ const short = desc ? shortenOptionDescription(desc) : void 0;
803
+ return short ? `${key} (${short})` : key;
804
+ });
805
+ out.push(`Options: ${parts.join(", ")}.`);
806
+ }
807
+ if (summary.documentationURL) {
808
+ out.push(`See ${summary.documentationURL} for further information.`);
809
+ }
810
+ return out;
811
+ }
812
+ function shortenOptionDescription(desc) {
813
+ const firstSentence = desc.split(/(?<=[.!?])\s+/)[0]?.trim() ?? desc.trim();
814
+ return firstSentence.replace(/[.!?]+$/, "").trim();
815
+ }
816
+ function wrapToComment(text, width) {
817
+ const words = text.split(/\s+/).filter((w) => w.length > 0);
818
+ if (words.length === 0) return [""];
819
+ const usable = Math.max(width, 20);
820
+ const lines = [];
821
+ let current = "";
822
+ for (const w of words) {
823
+ if (current.length === 0) {
824
+ current = w;
825
+ continue;
826
+ }
827
+ if (current.length + 1 + w.length <= usable) {
828
+ current += " " + w;
829
+ } else {
830
+ lines.push(current);
831
+ current = w;
832
+ }
833
+ }
834
+ if (current.length > 0) lines.push(current);
835
+ return lines;
836
+ }
837
+ var FEATURE_HEADER_WIDTH = 76 - 2;
838
+ function featureOptionVarName(ref, optionKey) {
839
+ const leaf = ref.split("/").pop() ?? ref;
840
+ const id = leaf.split("@")[0].split(":")[0];
841
+ const idSnake = id.replace(/[^A-Za-z0-9]+/g, "_").toUpperCase();
842
+ const optSnake = optionKey.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").toUpperCase();
843
+ return `${idSnake}_${optSnake}`;
844
+ }
845
+ function featureOptionHints(summary, ref, activeKeys = []) {
846
+ return (summary?.optionHints ?? []).filter((key) => !activeKeys.includes(key)).map((key) => {
847
+ const envVar = featureOptionVarName(ref, key);
848
+ return { key, envVar, placeholder: `\${${envVar}}` };
849
+ });
850
+ }
851
+
852
+ // src/init/manifest.ts
853
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
854
+ import path3 from "path";
855
+
856
+ // src/util/ref.ts
857
+ var FEATURE_NAME_CHARSET = "[a-z0-9._-]+";
858
+ var FEATURE_TAG_CHARSET = "[a-z0-9._-]+";
859
+ var MONOCEROS_FEATURE_RE = new RegExp(
860
+ `^ghcr\\.io/getmonoceros/monoceros-features/(${FEATURE_NAME_CHARSET}):${FEATURE_TAG_CHARSET}$`
861
+ );
862
+ var DEPRECATED_MONOCEROS_FEATURE_RE = new RegExp(
863
+ `^ghcr\\.io/monoceros/features/(${FEATURE_NAME_CHARSET}):(${FEATURE_TAG_CHARSET})$`
864
+ );
865
+ function matchMonocerosFeature(ref) {
866
+ const match = MONOCEROS_FEATURE_RE.exec(ref);
867
+ if (!match) return null;
868
+ return { name: match[1] };
869
+ }
870
+ function migrateDeprecatedFeatureRef(ref) {
871
+ const match = DEPRECATED_MONOCEROS_FEATURE_RE.exec(ref);
872
+ if (!match) return null;
873
+ const name = match[1];
874
+ const tag = match[2];
875
+ return `ghcr.io/getmonoceros/monoceros-features/${name}:${tag}`;
876
+ }
877
+
878
+ // src/init/manifest.ts
879
+ function resolveManifestPath(name, checkoutRoot) {
880
+ if (checkoutRoot) {
881
+ const checkoutPath = path3.join(
882
+ checkoutRoot,
883
+ "images",
884
+ "features",
885
+ name,
886
+ "devcontainer-feature.json"
887
+ );
888
+ if (existsSync3(checkoutPath)) return checkoutPath;
889
+ }
890
+ const bundlePath = path3.join(
891
+ bundledFeaturesDir(),
892
+ name,
893
+ "devcontainer-feature.json"
894
+ );
895
+ if (existsSync3(bundlePath)) return bundlePath;
896
+ return null;
897
+ }
898
+ function loadFeatureManifestSummary(ref, checkoutRoot = workbenchCheckoutRoot()) {
899
+ const match = matchMonocerosFeature(ref);
900
+ if (!match) return void 0;
901
+ const manifestPath = resolveManifestPath(match.name, checkoutRoot);
902
+ if (!manifestPath) return void 0;
903
+ try {
904
+ const text = readFileSync2(manifestPath, "utf8");
905
+ const parsed = JSON.parse(text);
906
+ const rawHints = parsed["x-monoceros"]?.optionHints;
907
+ const optionHints = Array.isArray(rawHints) ? rawHints.filter(
908
+ (x) => typeof x === "string" && x.length > 0
909
+ ) : [];
910
+ const rawNotes = parsed["x-monoceros"]?.usageNotes;
911
+ const usageNotes = Array.isArray(rawNotes) ? rawNotes.filter(
912
+ (x) => typeof x === "string" && x.length > 0
913
+ ) : [];
914
+ const optionDescriptions = {};
915
+ const optionTypes = {};
916
+ const optionNames = [];
917
+ if (parsed.options) {
918
+ for (const [key, opt] of Object.entries(parsed.options)) {
919
+ if (!opt || typeof opt !== "object") continue;
920
+ optionNames.push(key);
921
+ if (typeof opt.description === "string" && opt.description.length > 0) {
922
+ optionDescriptions[key] = opt.description;
923
+ }
924
+ if (opt.type === "boolean") {
925
+ optionTypes[key] = "boolean";
926
+ } else if (opt.type === "string") {
927
+ optionTypes[key] = "string";
928
+ }
929
+ }
930
+ }
931
+ const name = typeof parsed.name === "string" ? parsed.name : "";
932
+ const description = typeof parsed.description === "string" ? parsed.description : "";
933
+ const rawUrl = typeof parsed.documentationURL === "string" ? parsed.documentationURL.trim() : "";
934
+ const documentationURL = rawUrl.length > 0 && rawUrl.toLowerCase() !== "tbd" ? rawUrl : void 0;
935
+ return {
936
+ name,
937
+ description,
938
+ documentationURL,
939
+ optionHints,
940
+ optionDescriptions,
941
+ optionNames,
942
+ optionTypes,
943
+ usageNotes
944
+ };
945
+ } catch {
946
+ return void 0;
947
+ }
948
+ }
949
+
572
950
  // src/devcontainer/credentials.ts
573
951
  import { spawn } from "child_process";
574
952
  import { promises as fs2 } from "fs";
575
- import path2 from "path";
953
+ import path4 from "path";
576
954
 
577
955
  // src/util/format.ts
578
956
  var ESC = "\x1B[";
@@ -788,8 +1166,8 @@ function formatCredentialLine(host, username, password) {
788
1166
  return `https://${encUser}:${encPass}@${host}`;
789
1167
  }
790
1168
  async function collectGitCredentials(devContainerRoot, hosts, options = {}) {
791
- const credsDir = path2.join(devContainerRoot, ".monoceros");
792
- const credentialsPath = path2.join(credsDir, "git-credentials");
1169
+ const credsDir = path4.join(devContainerRoot, ".monoceros");
1170
+ const credentialsPath = path4.join(credsDir, "git-credentials");
793
1171
  const spawnFn = options.spawn ?? realGitCredentialFill;
794
1172
  const approveFn = options.approve ?? realGitCredentialApprove;
795
1173
  const logger = options.logger ?? { info: () => {
@@ -1186,8 +1564,8 @@ ${existing}` : leakedComment;
1186
1564
  }
1187
1565
 
1188
1566
  // src/init/components.ts
1189
- import { existsSync as existsSync2, promises as fs4 } from "fs";
1190
- import path3 from "path";
1567
+ import { existsSync as existsSync4, promises as fs4 } from "fs";
1568
+ import path5 from "path";
1191
1569
  import { z as z3 } from "zod";
1192
1570
  import { parse as parseYaml } from "yaml";
1193
1571
  var CategorySchema = z3.enum(["language", "service", "feature"]);
@@ -1234,7 +1612,7 @@ var ComponentFileSchema = z3.object({
1234
1612
  }
1235
1613
  });
1236
1614
  async function loadComponentCatalog(rootDir = componentsDir()) {
1237
- if (!existsSync2(rootDir)) {
1615
+ if (!existsSync4(rootDir)) {
1238
1616
  return /* @__PURE__ */ new Map();
1239
1617
  }
1240
1618
  const out = /* @__PURE__ */ new Map();
@@ -1244,14 +1622,14 @@ async function loadComponentCatalog(rootDir = componentsDir()) {
1244
1622
  async function walk(baseDir, currentDir, out) {
1245
1623
  const entries = await fs4.readdir(currentDir, { withFileTypes: true });
1246
1624
  for (const entry2 of entries) {
1247
- const full = path3.join(currentDir, entry2.name);
1625
+ const full = path5.join(currentDir, entry2.name);
1248
1626
  if (entry2.isDirectory()) {
1249
1627
  await walk(baseDir, full, out);
1250
1628
  continue;
1251
1629
  }
1252
1630
  if (!entry2.isFile() || !entry2.name.endsWith(".yml")) continue;
1253
- const relative = path3.relative(baseDir, full);
1254
- const name = relative.replace(/\.yml$/, "").split(path3.sep).join("/");
1631
+ const relative = path5.relative(baseDir, full);
1632
+ const name = relative.replace(/\.yml$/, "").split(path5.sep).join("/");
1255
1633
  const text = await fs4.readFile(full, "utf8");
1256
1634
  let raw;
1257
1635
  try {
@@ -1273,42 +1651,6 @@ ${issues}`);
1273
1651
  out.set(name, { name, sourcePath: full, file: parsed.data });
1274
1652
  }
1275
1653
  }
1276
- function mergeComponents(resolved) {
1277
- const languages = [];
1278
- const services = [];
1279
- const featureByRef = /* @__PURE__ */ new Map();
1280
- for (const entry2 of resolved) {
1281
- const c = isResolvedComponent(entry2) ? entry2.component : entry2;
1282
- const version = isResolvedComponent(entry2) ? entry2.version : void 0;
1283
- const ct = c.file.contributes;
1284
- for (const lang of ct.languages ?? []) {
1285
- const value = version !== void 0 ? `${lang}:${version}` : lang;
1286
- if (!languages.includes(value)) languages.push(value);
1287
- }
1288
- for (const svc of ct.services ?? []) {
1289
- if (!services.includes(svc)) services.push(svc);
1290
- }
1291
- for (const f of ct.features ?? []) {
1292
- const existing = featureByRef.get(f.ref);
1293
- if (!existing) {
1294
- featureByRef.set(f.ref, {
1295
- ref: f.ref,
1296
- options: { ...f.options ?? {} }
1297
- });
1298
- continue;
1299
- }
1300
- existing.options = mergeFeatureOptions(existing.options, f.options ?? {});
1301
- }
1302
- }
1303
- return {
1304
- languages,
1305
- services,
1306
- features: [...featureByRef.values()]
1307
- };
1308
- }
1309
- function isResolvedComponent(x) {
1310
- return "component" in x;
1311
- }
1312
1654
  function mergeFeatureOptions(a, b) {
1313
1655
  const result = { ...a };
1314
1656
  for (const [key, valueB] of Object.entries(b)) {
@@ -1321,39 +1663,11 @@ function mergeFeatureOptions(a, b) {
1321
1663
  }
1322
1664
  return result;
1323
1665
  }
1324
- function resolveComponents(catalog, names) {
1325
- const unknown = [];
1326
- const out = [];
1327
- for (const raw of names) {
1328
- const colon = raw.indexOf(":");
1329
- const name = colon === -1 ? raw : raw.slice(0, colon);
1330
- const version = colon === -1 ? void 0 : raw.slice(colon + 1);
1331
- const c = catalog.get(name);
1332
- if (!c) {
1333
- unknown.push(raw);
1334
- continue;
1335
- }
1336
- if (version !== void 0 && c.file.category !== "language") {
1337
- throw new Error(
1338
- `Component '${name}' is a ${c.file.category}, not a language \u2014 a ':${version}' suffix has no meaning here.`
1339
- );
1340
- }
1341
- out.push({ component: c, ...version !== void 0 ? { version } : {} });
1342
- }
1343
- if (unknown.length > 0) {
1344
- const available = [...catalog.keys()].sort();
1345
- throw new Error(
1346
- `Unknown component${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.
1347
- Available: ${available.join(", ")}.`
1348
- );
1349
- }
1350
- return out;
1351
- }
1352
1666
 
1353
1667
  // src/proxy/index.ts
1354
1668
  import { spawn as spawn3 } from "child_process";
1355
1669
  import { promises as fs5 } from "fs";
1356
- import path4 from "path";
1670
+ import path6 from "path";
1357
1671
  var PROXY_CONTAINER_NAME = "monoceros-proxy";
1358
1672
  var PROXY_NETWORK_NAME = "monoceros-proxy";
1359
1673
  var TRAEFIK_IMAGE = "traefik:v3.3";
@@ -1379,7 +1693,7 @@ var defaultDockerExec = (args) => {
1379
1693
  };
1380
1694
  var realDocker = defaultDockerExec;
1381
1695
  function proxyDynamicDir(home) {
1382
- return path4.join(home ?? monocerosHome(), "traefik", "dynamic");
1696
+ return path6.join(home ?? monocerosHome(), "traefik", "dynamic");
1383
1697
  }
1384
1698
  async function ensureProxy(opts = {}) {
1385
1699
  const docker = opts.docker ?? realDocker;
@@ -1471,7 +1785,7 @@ async function maybeStopProxy(opts = {}) {
1471
1785
 
1472
1786
  // src/proxy/dynamic.ts
1473
1787
  import { promises as fs6 } from "fs";
1474
- import path5 from "path";
1788
+ import path7 from "path";
1475
1789
  async function writeDynamicConfig(name, ports, opts = {}) {
1476
1790
  if (ports.length === 0) {
1477
1791
  throw new Error(
@@ -1480,12 +1794,12 @@ async function writeDynamicConfig(name, ports, opts = {}) {
1480
1794
  }
1481
1795
  const dir = proxyDynamicDir(opts.monocerosHome);
1482
1796
  await fs6.mkdir(dir, { recursive: true });
1483
- const file = path5.join(dir, `${name}.yml`);
1797
+ const file = path7.join(dir, `${name}.yml`);
1484
1798
  await fs6.writeFile(file, renderDynamicConfig(name, ports), "utf8");
1485
1799
  return file;
1486
1800
  }
1487
1801
  async function removeDynamicConfig(name, opts = {}) {
1488
- const file = path5.join(proxyDynamicDir(opts.monocerosHome), `${name}.yml`);
1802
+ const file = path7.join(proxyDynamicDir(opts.monocerosHome), `${name}.yml`);
1489
1803
  await fs6.rm(file, { force: true });
1490
1804
  }
1491
1805
  function renderDynamicConfig(name, ports) {
@@ -1688,34 +2002,97 @@ function knownLanguages() {
1688
2002
  function knownServices() {
1689
2003
  return Object.keys(SERVICE_CATALOG).sort();
1690
2004
  }
2005
+ function resolveService(entry2) {
2006
+ return {
2007
+ name: entry2.name,
2008
+ image: entry2.image,
2009
+ ...entry2.port !== void 0 ? { port: entry2.port } : {},
2010
+ env: entry2.env ? { ...entry2.env } : {},
2011
+ volumes: entry2.volumes ? [...entry2.volumes] : [],
2012
+ ...entry2.healthcheck ? { healthcheck: entry2.healthcheck } : {},
2013
+ ...entry2.restart ? { restart: entry2.restart } : {},
2014
+ ...entry2.command ? { command: entry2.command } : {}
2015
+ };
2016
+ }
2017
+ function isCuratedService(name) {
2018
+ return Object.prototype.hasOwnProperty.call(SERVICE_CATALOG, name);
2019
+ }
2020
+ function expandCuratedService(name) {
2021
+ const def = SERVICE_CATALOG[name];
2022
+ if (!def) {
2023
+ throw new Error(
2024
+ `Unknown service '${name}'. Known catalog services: ${knownServices().join(", ")}.`
2025
+ );
2026
+ }
2027
+ return {
2028
+ name: def.id,
2029
+ image: def.image,
2030
+ port: def.defaultPort,
2031
+ ...def.env ? { env: { ...def.env } } : {},
2032
+ ...def.dataMount ? { volumes: [`data:${def.dataMount}`] } : {}
2033
+ };
2034
+ }
2035
+ function deriveServiceName(image) {
2036
+ const lastSegment = image.split("/").pop() ?? image;
2037
+ const noTag = lastSegment.split("@")[0].split(":")[0];
2038
+ return noTag.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
2039
+ }
1691
2040
 
1692
- // src/create/scaffold.ts
1693
- import { existsSync as existsSync3, readFileSync, promises as fs7 } from "fs";
1694
- import path6 from "path";
1695
-
1696
- // src/util/ref.ts
1697
- var FEATURE_NAME_CHARSET = "[a-z0-9._-]+";
1698
- var FEATURE_TAG_CHARSET = "[a-z0-9._-]+";
1699
- var MONOCEROS_FEATURE_RE = new RegExp(
1700
- `^ghcr\\.io/getmonoceros/monoceros-features/(${FEATURE_NAME_CHARSET}):${FEATURE_TAG_CHARSET}$`
1701
- );
1702
- var DEPRECATED_MONOCEROS_FEATURE_RE = new RegExp(
1703
- `^ghcr\\.io/monoceros/features/(${FEATURE_NAME_CHARSET}):(${FEATURE_TAG_CHARSET})$`
1704
- );
1705
- function matchMonocerosFeature(ref) {
1706
- const match = MONOCEROS_FEATURE_RE.exec(ref);
1707
- if (!match) return null;
1708
- return { name: match[1] };
2041
+ // src/init/service-doc.ts
2042
+ function renderServiceObjectBody(svc) {
2043
+ const lines = [`name: ${svc.name}`, `image: ${svc.image}`];
2044
+ if (svc.port !== void 0) lines.push(`port: ${svc.port}`);
2045
+ if (svc.env && Object.keys(svc.env).length > 0) {
2046
+ lines.push("env:");
2047
+ for (const [k, v] of Object.entries(svc.env)) {
2048
+ lines.push(` ${k}: ${v}`);
2049
+ }
2050
+ }
2051
+ if (svc.volumes && svc.volumes.length > 0) {
2052
+ lines.push("volumes:");
2053
+ for (const vol of svc.volumes) lines.push(` - ${vol}`);
2054
+ }
2055
+ if (svc.restart) lines.push(`restart: ${svc.restart}`);
2056
+ if (svc.command !== void 0) lines.push(`command: ${svc.command}`);
2057
+ if (svc.healthcheck) {
2058
+ lines.push("healthcheck:");
2059
+ const test = svc.healthcheck.test;
2060
+ lines.push(
2061
+ Array.isArray(test) ? ` test: [${test.map((t) => JSON.stringify(t)).join(", ")}]` : ` test: ${test}`
2062
+ );
2063
+ if (svc.healthcheck.interval)
2064
+ lines.push(` interval: ${svc.healthcheck.interval}`);
2065
+ if (svc.healthcheck.timeout)
2066
+ lines.push(` timeout: ${svc.healthcheck.timeout}`);
2067
+ if (svc.healthcheck.retries !== void 0)
2068
+ lines.push(` retries: ${svc.healthcheck.retries}`);
2069
+ if (svc.healthcheck.startPeriod)
2070
+ lines.push(` startPeriod: ${svc.healthcheck.startPeriod}`);
2071
+ }
2072
+ return lines;
1709
2073
  }
1710
- function migrateDeprecatedFeatureRef(ref) {
1711
- const match = DEPRECATED_MONOCEROS_FEATURE_RE.exec(ref);
1712
- if (!match) return null;
1713
- const name = match[1];
1714
- const tag = match[2];
1715
- return `ghcr.io/getmonoceros/monoceros-features/${name}:${tag}`;
2074
+ function renderCustomService(name, image) {
2075
+ const bodyLines = [`name: ${name}`, `image: ${image}`];
2076
+ const comment = [
2077
+ " port: 8080 # in-container port \u2192 `monoceros tunnel`",
2078
+ " env: # values resolved from <name>.env",
2079
+ " KEY: ${SOME_VAR}",
2080
+ " volumes:",
2081
+ ` - data:/data # persistent host bind-mount under data/${name}`,
2082
+ " - rel/host/path:/in/container:ro",
2083
+ " healthcheck:",
2084
+ " test: curl -f http://localhost:8080/health",
2085
+ " restart: unless-stopped"
2086
+ ].join("\n");
2087
+ return { bodyLines, comment };
2088
+ }
2089
+ function customServiceHint(name) {
2090
+ return `'${name}' is a custom image \u2014 Monoceros doesn't know its env, ports or volumes. Review the commented block under services[].${name} in the yml and fill in what the image needs.`;
1716
2091
  }
1717
2092
 
1718
2093
  // src/create/scaffold.ts
2094
+ import { existsSync as existsSync5, readFileSync as readFileSync3, promises as fs7 } from "fs";
2095
+ import path8 from "path";
1719
2096
  var APT_PACKAGE_NAME_RE2 = /^[a-z0-9][a-z0-9.+-]*$/;
1720
2097
  var FEATURE_REF_RE2 = /^[a-z0-9.-]+(\/[a-z0-9._-]+)+:[a-z0-9._-]+$/;
1721
2098
  var INSTALL_URL_RE2 = /^https:\/\/[A-Za-z0-9.\-_~/:?#[\]@!&'()*+,;=%]+$/;
@@ -1745,12 +2122,24 @@ function validateOptions(opts) {
1745
2122
  );
1746
2123
  }
1747
2124
  }
2125
+ const seenServiceNames = /* @__PURE__ */ new Set();
1748
2126
  for (const svc of opts.services) {
1749
- if (!SERVICE_CATALOG[svc]) {
2127
+ if (!svc.image) {
2128
+ throw new Error(
2129
+ `Service ${JSON.stringify(svc.name)} has no image. Every service needs an 'image:'.`
2130
+ );
2131
+ }
2132
+ if (svc.name === "workspace") {
1750
2133
  throw new Error(
1751
- `Unknown service: ${svc}. Known: ${knownServices().join(", ")}.`
2134
+ `Invalid service name 'workspace': it collides with the reserved devcontainer workspace service. Pick another name.`
1752
2135
  );
1753
2136
  }
2137
+ if (seenServiceNames.has(svc.name)) {
2138
+ throw new Error(
2139
+ `Duplicate service name: ${JSON.stringify(svc.name)}. Each services[] entry must have a unique name.`
2140
+ );
2141
+ }
2142
+ seenServiceNames.add(svc.name);
1754
2143
  }
1755
2144
  for (const pkg of opts.aptPackages ?? []) {
1756
2145
  if (!APT_PACKAGE_NAME_RE2.test(pkg)) {
@@ -1800,10 +2189,14 @@ function validateOptions(opts) {
1800
2189
  }
1801
2190
  function normalizeOptions(opts) {
1802
2191
  const languages = [...new Set(opts.languages)].sort();
1803
- let services = [...new Set(opts.services)].sort();
1804
- if (opts.postgresUrl) {
1805
- services = services.filter((s) => s !== "postgres");
2192
+ const serviceByName = /* @__PURE__ */ new Map();
2193
+ for (const svc of opts.services) {
2194
+ if (opts.postgresUrl && svc.name === "postgres") continue;
2195
+ serviceByName.set(svc.name, svc);
1806
2196
  }
2197
+ const services = [...serviceByName.values()].sort(
2198
+ (a, b) => a.name.localeCompare(b.name)
2199
+ );
1807
2200
  const aptPackages = [...new Set(opts.aptPackages ?? [])].sort();
1808
2201
  const features = opts.features ? Object.fromEntries(
1809
2202
  Object.entries(opts.features).sort(([a], [b]) => a.localeCompare(b))
@@ -1862,8 +2255,8 @@ function resolveFeatures(opts) {
1862
2255
  if (match) {
1863
2256
  const name = match.name;
1864
2257
  const checkout = workbenchCheckoutRoot();
1865
- const localSourceDir = checkout ? path6.join(checkout, "images", "features", name) : null;
1866
- if (localSourceDir && existsSync3(localSourceDir)) {
2258
+ const localSourceDir = checkout ? path8.join(checkout, "images", "features", name) : null;
2259
+ if (localSourceDir && existsSync5(localSourceDir)) {
1867
2260
  const { paths, files } = readPersistentHomeEntries(localSourceDir);
1868
2261
  resolved.push({
1869
2262
  devcontainerKey: `./features/${name}`,
@@ -1887,9 +2280,9 @@ function resolveFeatures(opts) {
1887
2280
  return resolved;
1888
2281
  }
1889
2282
  function readPersistentHomeEntries(localSourceDir) {
1890
- const manifestPath = path6.join(localSourceDir, "devcontainer-feature.json");
2283
+ const manifestPath = path8.join(localSourceDir, "devcontainer-feature.json");
1891
2284
  try {
1892
- const text = readFileSync(manifestPath, "utf8");
2285
+ const text = readFileSync3(manifestPath, "utf8");
1893
2286
  const parsed = JSON.parse(text);
1894
2287
  return {
1895
2288
  paths: filterSubpaths(parsed["x-monoceros"]?.persistentHomePaths),
@@ -1964,7 +2357,7 @@ function buildDevcontainerJson(opts, dockerMode = "rootful") {
1964
2357
  name: opts.name,
1965
2358
  dockerComposeFile: "compose.yaml",
1966
2359
  service: "workspace",
1967
- ...opts.services.length > 0 ? { runServices: opts.services } : {},
2360
+ ...opts.services.length > 0 ? { runServices: opts.services.map((s) => s.name) } : {},
1968
2361
  workspaceFolder: `/workspaces/${opts.name}`,
1969
2362
  remoteUser: "node",
1970
2363
  forwardPorts: ports,
@@ -1997,6 +2390,18 @@ function buildDevcontainerJson(opts, dockerMode = "rootful") {
1997
2390
  ...customizationsField ?? {}
1998
2391
  };
1999
2392
  }
2393
+ function composeScalar(value) {
2394
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t");
2395
+ return `"${escaped}"`;
2396
+ }
2397
+ function composeVolumeSource(spec, serviceName) {
2398
+ const parts = spec.split(":");
2399
+ const src = parts[0];
2400
+ const rest = parts.slice(1).join(":");
2401
+ if (src === "data") return `../data/${serviceName}:${rest}`;
2402
+ const relative = src.startsWith("./") ? src.slice(2) : src;
2403
+ return `../${relative}:${rest}`;
2404
+ }
2000
2405
  function buildComposeYaml(opts, dockerMode = "rootful") {
2001
2406
  void dockerMode;
2002
2407
  const hasPorts = (opts.ports?.length ?? 0) > 0;
@@ -2025,20 +2430,42 @@ function buildComposeYaml(opts, dockerMode = "rootful") {
2025
2430
  lines.push(` - ../home/${sub}:/home/node/${sub}`);
2026
2431
  }
2027
2432
  }
2028
- for (const svcId of opts.services) {
2029
- const def = SERVICE_CATALOG[svcId];
2030
- if (!def) continue;
2031
- lines.push(` ${def.id}:`);
2032
- lines.push(` image: ${def.image}`);
2033
- if (def.env) {
2433
+ for (const svc of opts.services) {
2434
+ lines.push(` ${svc.name}:`);
2435
+ lines.push(` image: ${svc.image}`);
2436
+ if (svc.restart) {
2437
+ lines.push(` restart: ${svc.restart}`);
2438
+ }
2439
+ if (svc.command !== void 0) {
2440
+ lines.push(` command: ${composeScalar(svc.command)}`);
2441
+ }
2442
+ const envKeys = Object.keys(svc.env);
2443
+ if (envKeys.length > 0) {
2034
2444
  lines.push(" environment:");
2035
- for (const [k, v] of Object.entries(def.env)) {
2036
- lines.push(` ${k}: ${v}`);
2445
+ for (const k of envKeys) {
2446
+ lines.push(` ${k}: ${composeScalar(svc.env[k])}`);
2037
2447
  }
2038
2448
  }
2039
- if (def.dataMount) {
2449
+ if (svc.volumes.length > 0) {
2040
2450
  lines.push(" volumes:");
2041
- lines.push(` - ../data/${def.id}:${def.dataMount}`);
2451
+ for (const vol of svc.volumes) {
2452
+ lines.push(` - ${composeVolumeSource(vol, svc.name)}`);
2453
+ }
2454
+ }
2455
+ if (svc.healthcheck) {
2456
+ const hc = svc.healthcheck;
2457
+ lines.push(" healthcheck:");
2458
+ if (Array.isArray(hc.test)) {
2459
+ lines.push(` test: [${hc.test.map(composeScalar).join(", ")}]`);
2460
+ } else {
2461
+ lines.push(` test: ${composeScalar(hc.test)}`);
2462
+ }
2463
+ if (hc.interval) lines.push(` interval: ${hc.interval}`);
2464
+ if (hc.timeout) lines.push(` timeout: ${hc.timeout}`);
2465
+ if (hc.retries !== void 0) lines.push(` retries: ${hc.retries}`);
2466
+ if (hc.startPeriod) {
2467
+ lines.push(` start_period: ${hc.startPeriod}`);
2468
+ }
2042
2469
  }
2043
2470
  }
2044
2471
  if (hasPorts) {
@@ -2172,245 +2599,99 @@ function buildPostCreateScript(opts) {
2172
2599
  return lines.join("\n") + "\n";
2173
2600
  }
2174
2601
  async function writePostCreateScript(devcontainerDir, opts) {
2175
- const dest = path6.join(devcontainerDir, "post-create.sh");
2602
+ const dest = path8.join(devcontainerDir, "post-create.sh");
2176
2603
  await fs7.writeFile(dest, buildPostCreateScript(opts));
2177
2604
  await fs7.chmod(dest, 493);
2178
2605
  }
2179
2606
  async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2180
2607
  const dockerMode = scaffoldOpts.dockerMode ?? "rootful";
2181
- const devcontainerDir = path6.join(targetDir, ".devcontainer");
2182
- const monocerosDir = path6.join(targetDir, ".monoceros");
2183
- const projectsDir = path6.join(targetDir, "projects");
2184
- const homeDir = path6.join(targetDir, "home");
2185
- const dataDir = path6.join(targetDir, "data");
2608
+ const devcontainerDir = path8.join(targetDir, ".devcontainer");
2609
+ const monocerosDir = path8.join(targetDir, ".monoceros");
2610
+ const projectsDir = path8.join(targetDir, "projects");
2611
+ const homeDir = path8.join(targetDir, "home");
2612
+ const dataDir = path8.join(targetDir, "data");
2186
2613
  await fs7.mkdir(devcontainerDir, { recursive: true });
2187
2614
  await fs7.mkdir(monocerosDir, { recursive: true });
2188
2615
  await fs7.mkdir(projectsDir, { recursive: true });
2189
2616
  await fs7.mkdir(homeDir, { recursive: true });
2190
2617
  if (needsCompose(opts)) {
2191
2618
  await fs7.mkdir(dataDir, { recursive: true });
2192
- for (const svcId of opts.services) {
2193
- const def = SERVICE_CATALOG[svcId];
2194
- if (def?.dataMount) {
2195
- await fs7.mkdir(path6.join(dataDir, def.id), { recursive: true });
2619
+ for (const svc of opts.services) {
2620
+ const hasDataVolume = svc.volumes.some((v) => v.split(":")[0] === "data");
2621
+ if (hasDataVolume) {
2622
+ await fs7.mkdir(path8.join(dataDir, svc.name), { recursive: true });
2196
2623
  }
2197
2624
  }
2198
2625
  }
2199
- const containerGitignore = path6.join(targetDir, ".gitignore");
2626
+ const containerGitignore = path8.join(targetDir, ".gitignore");
2200
2627
  await fs7.writeFile(containerGitignore, "/home/\n/.monoceros/\n/data/\n");
2201
- const gitkeep = path6.join(projectsDir, ".gitkeep");
2202
- if (!existsSync3(gitkeep)) {
2628
+ const gitkeep = path8.join(projectsDir, ".gitkeep");
2629
+ if (!existsSync5(gitkeep)) {
2203
2630
  await fs7.writeFile(gitkeep, "");
2204
2631
  }
2205
2632
  await fs7.writeFile(
2206
- path6.join(monocerosDir, ".gitignore"),
2633
+ path8.join(monocerosDir, ".gitignore"),
2207
2634
  "git-credentials*\ngitconfig\n"
2208
2635
  );
2209
2636
  const devcontainerJson = buildDevcontainerJson(opts, dockerMode);
2210
2637
  await fs7.writeFile(
2211
- path6.join(devcontainerDir, "devcontainer.json"),
2638
+ path8.join(devcontainerDir, "devcontainer.json"),
2212
2639
  JSON.stringify(devcontainerJson, null, 2) + "\n"
2213
2640
  );
2214
- const featuresDir = path6.join(devcontainerDir, "features");
2215
- if (existsSync3(featuresDir)) {
2641
+ const featuresDir = path8.join(devcontainerDir, "features");
2642
+ if (existsSync5(featuresDir)) {
2216
2643
  await fs7.rm(featuresDir, { recursive: true, force: true });
2217
2644
  }
2218
2645
  const resolvedFeatures = resolveFeatures(opts);
2219
2646
  for (const f of resolvedFeatures) {
2220
2647
  if (!f.localSourceDir || !f.localName) continue;
2221
- const dest = path6.join(featuresDir, f.localName);
2222
- await fs7.mkdir(dest, { recursive: true });
2223
- await fs7.cp(f.localSourceDir, dest, { recursive: true });
2224
- }
2225
- for (const f of resolvedFeatures) {
2226
- for (const sub of f.persistentHomePaths) {
2227
- await fs7.mkdir(path6.join(homeDir, sub), { recursive: true });
2228
- }
2229
- for (const entry2 of f.persistentHomeFiles) {
2230
- const filePath = path6.join(homeDir, entry2.path);
2231
- await fs7.mkdir(path6.dirname(filePath), { recursive: true });
2232
- if (!existsSync3(filePath)) {
2233
- await fs7.writeFile(filePath, entry2.initialContent);
2234
- }
2235
- }
2236
- }
2237
- await writePostCreateScript(devcontainerDir, opts);
2238
- const composePath = path6.join(devcontainerDir, "compose.yaml");
2239
- if (needsCompose(opts)) {
2240
- await fs7.writeFile(composePath, buildComposeYaml(opts, dockerMode));
2241
- } else if (existsSync3(composePath)) {
2242
- await fs7.rm(composePath);
2243
- }
2244
- const workspacePath = path6.join(targetDir, `${opts.name}.code-workspace`);
2245
- let existingWorkspace;
2246
- try {
2247
- const raw = await fs7.readFile(workspacePath, "utf8");
2248
- existingWorkspace = JSON.parse(raw);
2249
- } catch {
2250
- existingWorkspace = void 0;
2251
- }
2252
- const generated = buildCodeWorkspaceJson(opts);
2253
- const merged = mergeCodeWorkspace(existingWorkspace, generated);
2254
- await fs7.writeFile(workspacePath, JSON.stringify(merged, null, 2) + "\n");
2255
- }
2256
-
2257
- // src/modify/yml.ts
2258
- import {
2259
- isMap as isMap2,
2260
- isScalar,
2261
- isSeq,
2262
- Pair as Pair2,
2263
- Scalar as Scalar2,
2264
- YAMLMap as YAMLMap2,
2265
- YAMLSeq
2266
- } from "yaml";
2267
-
2268
- // src/init/manifest.ts
2269
- import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
2270
- import path7 from "path";
2271
- function resolveManifestPath(name, checkoutRoot) {
2272
- if (checkoutRoot) {
2273
- const checkoutPath = path7.join(
2274
- checkoutRoot,
2275
- "images",
2276
- "features",
2277
- name,
2278
- "devcontainer-feature.json"
2279
- );
2280
- if (existsSync4(checkoutPath)) return checkoutPath;
2281
- }
2282
- const bundlePath = path7.join(
2283
- bundledFeaturesDir(),
2284
- name,
2285
- "devcontainer-feature.json"
2286
- );
2287
- if (existsSync4(bundlePath)) return bundlePath;
2288
- return null;
2289
- }
2290
- function loadFeatureManifestSummary(ref, checkoutRoot = workbenchCheckoutRoot()) {
2291
- const match = matchMonocerosFeature(ref);
2292
- if (!match) return void 0;
2293
- const manifestPath = resolveManifestPath(match.name, checkoutRoot);
2294
- if (!manifestPath) return void 0;
2295
- try {
2296
- const text = readFileSync2(manifestPath, "utf8");
2297
- const parsed = JSON.parse(text);
2298
- const rawHints = parsed["x-monoceros"]?.optionHints;
2299
- const optionHints = Array.isArray(rawHints) ? rawHints.filter(
2300
- (x) => typeof x === "string" && x.length > 0
2301
- ) : [];
2302
- const rawNotes = parsed["x-monoceros"]?.usageNotes;
2303
- const usageNotes = Array.isArray(rawNotes) ? rawNotes.filter(
2304
- (x) => typeof x === "string" && x.length > 0
2305
- ) : [];
2306
- const optionDescriptions = {};
2307
- const optionTypes = {};
2308
- const optionNames = [];
2309
- if (parsed.options) {
2310
- for (const [key, opt] of Object.entries(parsed.options)) {
2311
- if (!opt || typeof opt !== "object") continue;
2312
- optionNames.push(key);
2313
- if (typeof opt.description === "string" && opt.description.length > 0) {
2314
- optionDescriptions[key] = opt.description;
2315
- }
2316
- if (opt.type === "boolean") {
2317
- optionTypes[key] = "boolean";
2318
- } else if (opt.type === "string") {
2319
- optionTypes[key] = "string";
2320
- }
2321
- }
2322
- }
2323
- const name = typeof parsed.name === "string" ? parsed.name : "";
2324
- const description = typeof parsed.description === "string" ? parsed.description : "";
2325
- const rawUrl = typeof parsed.documentationURL === "string" ? parsed.documentationURL.trim() : "";
2326
- const documentationURL = rawUrl.length > 0 && rawUrl.toLowerCase() !== "tbd" ? rawUrl : void 0;
2327
- return {
2328
- name,
2329
- description,
2330
- documentationURL,
2331
- optionHints,
2332
- optionDescriptions,
2333
- optionNames,
2334
- optionTypes,
2335
- usageNotes
2336
- };
2337
- } catch {
2338
- return void 0;
2339
- }
2340
- }
2341
-
2342
- // src/init/feature-doc.ts
2343
- function buildFeatureHeaderLines(summary, width) {
2344
- const paragraphs = buildHeaderParagraphs(summary);
2345
- const wrapped = [];
2346
- for (const para of paragraphs) {
2347
- for (const line of wrapToComment(para, width)) {
2348
- wrapped.push(line);
2349
- }
2350
- }
2351
- return wrapped;
2352
- }
2353
- function buildFeatureHeaderCommentBefore(summary, width) {
2354
- const lines = buildFeatureHeaderLines(summary, width);
2355
- return lines.map((l) => ` ${l}`).join("\n");
2356
- }
2357
- function buildHeaderParagraphs(summary) {
2358
- if (!summary) return [];
2359
- const out = [];
2360
- const tagline = summary.name?.trim();
2361
- const description = summary.description?.trim();
2362
- if (tagline && description) {
2363
- out.push(`${tagline} \u2014 ${description}`);
2364
- } else if (tagline) {
2365
- out.push(tagline);
2366
- } else if (description) {
2367
- out.push(description);
2368
- }
2369
- for (const note of summary.usageNotes) {
2370
- const trimmed = note.trim();
2371
- if (trimmed.length > 0) out.push(trimmed);
2372
- }
2373
- if (summary.optionHints.length > 0) {
2374
- const parts = summary.optionHints.map((key) => {
2375
- const desc = summary.optionDescriptions[key];
2376
- const short = desc ? shortenOptionDescription(desc) : void 0;
2377
- return short ? `${key} (${short})` : key;
2378
- });
2379
- out.push(`Options: ${parts.join(", ")}.`);
2380
- }
2381
- if (summary.documentationURL) {
2382
- out.push(`See ${summary.documentationURL} for further information.`);
2383
- }
2384
- return out;
2385
- }
2386
- function shortenOptionDescription(desc) {
2387
- const firstSentence = desc.split(/(?<=[.!?])\s+/)[0]?.trim() ?? desc.trim();
2388
- return firstSentence.replace(/[.!?]+$/, "").trim();
2389
- }
2390
- function wrapToComment(text, width) {
2391
- const words = text.split(/\s+/).filter((w) => w.length > 0);
2392
- if (words.length === 0) return [""];
2393
- const usable = Math.max(width, 20);
2394
- const lines = [];
2395
- let current = "";
2396
- for (const w of words) {
2397
- if (current.length === 0) {
2398
- current = w;
2399
- continue;
2648
+ const dest = path8.join(featuresDir, f.localName);
2649
+ await fs7.mkdir(dest, { recursive: true });
2650
+ await fs7.cp(f.localSourceDir, dest, { recursive: true });
2651
+ }
2652
+ for (const f of resolvedFeatures) {
2653
+ for (const sub of f.persistentHomePaths) {
2654
+ await fs7.mkdir(path8.join(homeDir, sub), { recursive: true });
2400
2655
  }
2401
- if (current.length + 1 + w.length <= usable) {
2402
- current += " " + w;
2403
- } else {
2404
- lines.push(current);
2405
- current = w;
2656
+ for (const entry2 of f.persistentHomeFiles) {
2657
+ const filePath = path8.join(homeDir, entry2.path);
2658
+ await fs7.mkdir(path8.dirname(filePath), { recursive: true });
2659
+ if (!existsSync5(filePath)) {
2660
+ await fs7.writeFile(filePath, entry2.initialContent);
2661
+ }
2406
2662
  }
2407
2663
  }
2408
- if (current.length > 0) lines.push(current);
2409
- return lines;
2664
+ await writePostCreateScript(devcontainerDir, opts);
2665
+ const composePath = path8.join(devcontainerDir, "compose.yaml");
2666
+ if (needsCompose(opts)) {
2667
+ await fs7.writeFile(composePath, buildComposeYaml(opts, dockerMode));
2668
+ } else if (existsSync5(composePath)) {
2669
+ await fs7.rm(composePath);
2670
+ }
2671
+ const workspacePath = path8.join(targetDir, `${opts.name}.code-workspace`);
2672
+ let existingWorkspace;
2673
+ try {
2674
+ const raw = await fs7.readFile(workspacePath, "utf8");
2675
+ existingWorkspace = JSON.parse(raw);
2676
+ } catch {
2677
+ existingWorkspace = void 0;
2678
+ }
2679
+ const generated = buildCodeWorkspaceJson(opts);
2680
+ const merged = mergeCodeWorkspace(existingWorkspace, generated);
2681
+ await fs7.writeFile(workspacePath, JSON.stringify(merged, null, 2) + "\n");
2410
2682
  }
2411
- var FEATURE_HEADER_WIDTH = 76 - 2;
2412
2683
 
2413
2684
  // src/modify/yml.ts
2685
+ import {
2686
+ isMap as isMap2,
2687
+ isScalar,
2688
+ isSeq,
2689
+ Pair as Pair2,
2690
+ parseDocument as parseDocument3,
2691
+ Scalar as Scalar2,
2692
+ YAMLMap as YAMLMap2,
2693
+ YAMLSeq
2694
+ } from "yaml";
2414
2695
  function ensureSeq(doc, key) {
2415
2696
  const existing = doc.get(key, true);
2416
2697
  if (existing && isSeq(existing)) return existing;
@@ -2433,11 +2714,24 @@ function addLanguageToDoc(doc, lang) {
2433
2714
  seq.add(lang);
2434
2715
  return true;
2435
2716
  }
2436
- function addServiceToDoc(doc, service) {
2717
+ function findServiceItem(seq, name) {
2718
+ for (const item of seq.items) {
2719
+ if (isMap2(item) && item.get("name") === name) return item;
2720
+ }
2721
+ return void 0;
2722
+ }
2723
+ function addServiceEntryToDoc(doc, name, image, bodyLines, scaffoldComment) {
2437
2724
  const seq = ensureSeq(doc, "services");
2438
- if (seq.items.some((i) => scalarValue(i) === service)) return false;
2439
- seq.add(service);
2440
- return true;
2725
+ const existing = findServiceItem(seq, name);
2726
+ if (existing) {
2727
+ const existingImage = existing.get("image");
2728
+ if (existingImage === image) return { outcome: "exists" };
2729
+ return { outcome: "conflict", existingImage: String(existingImage) };
2730
+ }
2731
+ const node = parseDocument3(bodyLines.join("\n")).contents;
2732
+ if (scaffoldComment) node.comment = scaffoldComment;
2733
+ seq.add(node);
2734
+ return { outcome: "added" };
2441
2735
  }
2442
2736
  function addAptPackagesToDoc(doc, packages) {
2443
2737
  const seq = ensureSeq(doc, "aptPackages");
@@ -2657,6 +2951,12 @@ function addFeatureToDoc(doc, ref, options = {}, displayName) {
2657
2951
  entry2.commentBefore = headerBefore;
2658
2952
  entry2.spaceBefore = true;
2659
2953
  }
2954
+ const hints = featureOptionHints(summary, ref, Object.keys(options));
2955
+ if (hints.length > 0) {
2956
+ const commentLines = [" options:"];
2957
+ for (const h of hints) commentLines.push(` ${h.key}: ${h.placeholder}`);
2958
+ entry2.comment = commentLines.join("\n");
2959
+ }
2660
2960
  seq.add(entry2);
2661
2961
  return true;
2662
2962
  }
@@ -2733,7 +3033,15 @@ function removeLanguageFromDoc(doc, lang) {
2733
3033
  return removeScalarFromSeq(doc, "languages", lang);
2734
3034
  }
2735
3035
  function removeServiceFromDoc(doc, service) {
2736
- return removeScalarFromSeq(doc, "services", service);
3036
+ const node = doc.get("services", true);
3037
+ if (!node || !isSeq(node)) return false;
3038
+ const idx = node.items.findIndex(
3039
+ (i) => isMap2(i) && i.get("name") === service
3040
+ );
3041
+ if (idx === -1) return false;
3042
+ node.items.splice(idx, 1);
3043
+ pruneEmptySeq(doc, "services");
3044
+ return true;
2737
3045
  }
2738
3046
  function removeAptPackageFromDoc(doc, pkg) {
2739
3047
  return removeScalarFromSeq(doc, "aptPackages", pkg);
@@ -2773,8 +3081,8 @@ function removeRepoFromDoc(doc, urlOrPath) {
2773
3081
  if (!isMap2(item)) return false;
2774
3082
  const url = item.get("url");
2775
3083
  if (url === urlOrPath) return true;
2776
- const path18 = item.get("path");
2777
- const effectivePath = typeof path18 === "string" ? path18 : typeof url === "string" ? deriveRepoName(url) : void 0;
3084
+ const path21 = item.get("path");
3085
+ const effectivePath = typeof path21 === "string" ? path21 : typeof url === "string" ? deriveRepoName(url) : void 0;
2778
3086
  return effectivePath === urlOrPath;
2779
3087
  });
2780
3088
  if (idx < 0) return false;
@@ -2801,13 +3109,38 @@ function runAddLanguage(input) {
2801
3109
  }
2802
3110
  return mutate(input, (doc) => addLanguageToDoc(doc, input.language));
2803
3111
  }
2804
- function runAddService(input) {
2805
- if (!SERVICE_CATALOG[input.service]) {
3112
+ async function runAddService(input) {
3113
+ const arg = input.service;
3114
+ const curated = isCuratedService(arg);
3115
+ if (input.as !== void 0 && !/^[a-z0-9][a-z0-9_-]*$/.test(input.as)) {
2806
3116
  throw new Error(
2807
- `Unknown service: ${input.service}. Known: ${knownServices().join(", ")}.`
3117
+ `Invalid --as name ${JSON.stringify(input.as)}. Use lowercase letters, digits, '_' or '-' (must start with a letter or digit).`
3118
+ );
3119
+ }
3120
+ const name = input.as ?? (curated ? arg : deriveServiceName(arg));
3121
+ const image = curated ? expandCuratedService(arg).image : arg;
3122
+ const custom = curated ? null : renderCustomService(name, arg);
3123
+ const bodyLines = curated ? renderServiceObjectBody({ ...expandCuratedService(arg), name }) : custom.bodyLines;
3124
+ const scaffoldComment = curated ? void 0 : custom.comment;
3125
+ const result = await mutate(input, (doc) => {
3126
+ const r = addServiceEntryToDoc(
3127
+ doc,
3128
+ name,
3129
+ image,
3130
+ bodyLines,
3131
+ scaffoldComment
2808
3132
  );
3133
+ if (r.outcome === "conflict") {
3134
+ throw new Error(
3135
+ `A service named '${name}' already exists with a different image (${r.existingImage}). Add it under a different name with \`--as <name>\`, or remove the existing one first (\`monoceros remove-service ${input.name} ${name}\`).`
3136
+ );
3137
+ }
3138
+ return r.outcome === "added";
3139
+ });
3140
+ if (result.status === "updated" && !curated) {
3141
+ (input.logger ?? defaultLogger()).info(customServiceHint(name));
2809
3142
  }
2810
- return mutate(input, (doc) => addServiceToDoc(doc, input.service));
3143
+ return result;
2811
3144
  }
2812
3145
  function runAddAptPackages(input) {
2813
3146
  if (input.packages.length === 0) {
@@ -2824,7 +3157,7 @@ async function runAddRepo(input) {
2824
3157
  "Missing repo URL. Usage: monoceros add-repo <containername> <url>."
2825
3158
  );
2826
3159
  }
2827
- const path18 = (input.path ?? deriveRepoName(url)).trim();
3160
+ const path21 = (input.path ?? deriveRepoName(url)).trim();
2828
3161
  const hasName = typeof input.gitName === "string" && input.gitName.trim().length > 0;
2829
3162
  const hasEmail = typeof input.gitEmail === "string" && input.gitEmail.trim().length > 0;
2830
3163
  if (hasName !== hasEmail) {
@@ -2853,7 +3186,7 @@ async function runAddRepo(input) {
2853
3186
  const providerToWrite = !canonical && explicitProvider ? explicitProvider : void 0;
2854
3187
  const entry2 = {
2855
3188
  url,
2856
- path: path18,
3189
+ path: path21,
2857
3190
  ...hasName && hasEmail ? {
2858
3191
  gitUser: {
2859
3192
  name: input.gitName.trim(),
@@ -2969,7 +3302,7 @@ async function tryCloneInRunningContainer(input, entry2) {
2969
3302
  logger.info(
2970
3303
  `Cloned ${entry2.url} into /workspaces/${containerName2}/${targetRel} inside the running container.`
2971
3304
  );
2972
- void path8;
3305
+ void path9;
2973
3306
  }
2974
3307
  function shquote(value) {
2975
3308
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -3046,10 +3379,33 @@ async function runAddFeature(input) {
3046
3379
  ...resolved.defaultOptions,
3047
3380
  ...input.options ?? {}
3048
3381
  };
3049
- return mutate(
3382
+ const result = await mutate(
3050
3383
  input,
3051
3384
  (doc) => addFeatureToDoc(doc, resolved.ref, merged, raw)
3052
3385
  );
3386
+ if (result.status === "updated") {
3387
+ const summary = loadFeatureManifestSummary(resolved.ref);
3388
+ const vars = featureOptionHints(
3389
+ summary,
3390
+ resolved.ref,
3391
+ Object.keys(merged)
3392
+ ).map((h) => h.envVar);
3393
+ if (vars.length > 0) {
3394
+ const home = input.monocerosHome ?? monocerosHome();
3395
+ await ensureEnvGitignored(containerConfigsDir(home));
3396
+ const seeded = await ensureEnvVars(
3397
+ containerEnvPath(input.name, home),
3398
+ input.name,
3399
+ vars
3400
+ );
3401
+ if (seeded.added.length > 0) {
3402
+ (input.logger ?? defaultLogger()).info(
3403
+ `Seeded ${seeded.added.join(", ")} into ${input.name}.env \u2014 fill in the values.`
3404
+ );
3405
+ }
3406
+ }
3407
+ }
3408
+ return result;
3053
3409
  }
3054
3410
  async function resolveFeatureRefOrShortname(input) {
3055
3411
  if (REGEX.featureRef.test(input)) {
@@ -3609,7 +3965,7 @@ var addServiceCommand = defineCommand7({
3609
3965
  meta: {
3610
3966
  name: "add-service",
3611
3967
  group: "edit",
3612
- description: "Add a compose service (postgres, mysql, redis, \u2026) to the container config. Idempotent, prints a diff before writing."
3968
+ description: "Add a backing service to the container config. A curated name (postgres, mysql, redis) expands to a full editable block; any other image (e.g. rustfs/rustfs:latest) drops in name + image plus a commented scaffold. Idempotent, prints a diff before writing."
3613
3969
  },
3614
3970
  args: {
3615
3971
  name: {
@@ -3619,9 +3975,13 @@ var addServiceCommand = defineCommand7({
3619
3975
  },
3620
3976
  service: {
3621
3977
  type: "positional",
3622
- description: "Service identifier (postgres, mysql, redis).",
3978
+ description: "Curated name (postgres, mysql, redis) or any image ref (e.g. rustfs/rustfs:latest).",
3623
3979
  required: true
3624
3980
  },
3981
+ as: {
3982
+ type: "string",
3983
+ description: "Override the service name (the compose service / DNS name / data dir). Lets you add the same image more than once \u2014 e.g. two postgres servers as postgres-app and postgres-analytics."
3984
+ },
3625
3985
  yes: {
3626
3986
  type: "boolean",
3627
3987
  description: "Skip the interactive confirmation and apply the diff.",
@@ -3634,6 +3994,7 @@ var addServiceCommand = defineCommand7({
3634
3994
  const result = await runAddService({
3635
3995
  name: args.name,
3636
3996
  service: args.service,
3997
+ ...args.as ? { as: args.as } : {},
3637
3998
  yes: args.yes
3638
3999
  });
3639
4000
  process.exit(result.status === "aborted" ? 1 : 0);
@@ -3648,12 +4009,12 @@ var addServiceCommand = defineCommand7({
3648
4009
  import { defineCommand as defineCommand8 } from "citty";
3649
4010
 
3650
4011
  // src/apply/index.ts
3651
- import { existsSync as existsSync6, promises as fs11 } from "fs";
4012
+ import { existsSync as existsSync8, promises as fs12 } from "fs";
3652
4013
  import { consola as consola11 } from "consola";
3653
4014
 
3654
4015
  // src/config/state.ts
3655
4016
  import { promises as fs9 } from "fs";
3656
- import path9 from "path";
4017
+ import path10 from "path";
3657
4018
  function buildStateFile(opts) {
3658
4019
  return {
3659
4020
  schemaVersion: CONFIG_SCHEMA_VERSION,
@@ -3663,7 +4024,7 @@ function buildStateFile(opts) {
3663
4024
  };
3664
4025
  }
3665
4026
  function stateFilePath(targetDir) {
3666
- return path9.join(targetDir, ".monoceros", "state.json");
4027
+ return path10.join(targetDir, ".monoceros", "state.json");
3667
4028
  }
3668
4029
  async function readStateFile(targetDir) {
3669
4030
  try {
@@ -3674,7 +4035,7 @@ async function readStateFile(targetDir) {
3674
4035
  }
3675
4036
  }
3676
4037
  async function writeStateFile(targetDir, state) {
3677
- const monocerosDir = path9.join(targetDir, ".monoceros");
4038
+ const monocerosDir = path10.join(targetDir, ".monoceros");
3678
4039
  await fs9.mkdir(monocerosDir, { recursive: true });
3679
4040
  await fs9.writeFile(
3680
4041
  stateFilePath(targetDir),
@@ -3695,7 +4056,11 @@ function solutionConfigToCreateOptions(config, featureDefaults = {}) {
3695
4056
  const result = {
3696
4057
  name: config.name,
3697
4058
  languages: [...config.languages],
3698
- services: [...config.services]
4059
+ // Normalize every services[] entry (curated string or explicit
4060
+ // object) to the canonical ResolvedService shape. `${VAR}` values
4061
+ // survive untouched here — apply interpolates them against
4062
+ // <name>.env afterwards.
4063
+ services: config.services.map(resolveService)
3699
4064
  };
3700
4065
  if (config.externalServices.postgres !== void 0) {
3701
4066
  result.postgresUrl = config.externalServices.postgres;
@@ -3746,8 +4111,8 @@ function solutionConfigToCreateOptions(config, featureDefaults = {}) {
3746
4111
 
3747
4112
  // src/devcontainer/compose.ts
3748
4113
  import { spawn as spawn5 } from "child_process";
3749
- import { existsSync as existsSync5 } from "fs";
3750
- import path11 from "path";
4114
+ import { existsSync as existsSync6 } from "fs";
4115
+ import path12 from "path";
3751
4116
  import { consola as consola9 } from "consola";
3752
4117
 
3753
4118
  // src/util/mask-secrets.ts
@@ -3808,9 +4173,9 @@ function createSecretMaskStream() {
3808
4173
 
3809
4174
  // src/devcontainer/cli.ts
3810
4175
  import { spawn as spawn4 } from "child_process";
3811
- import { readFileSync as readFileSync3 } from "fs";
4176
+ import { readFileSync as readFileSync4 } from "fs";
3812
4177
  import { createRequire } from "module";
3813
- import path10 from "path";
4178
+ import path11 from "path";
3814
4179
 
3815
4180
  // src/devcontainer/runtime-pull-hint.ts
3816
4181
  import { Transform as Transform2 } from "stream";
@@ -3856,12 +4221,12 @@ var cachedBinaryPath = null;
3856
4221
  function devcontainerCliPath() {
3857
4222
  if (cachedBinaryPath) return cachedBinaryPath;
3858
4223
  const pkgJsonPath = require_.resolve("@devcontainers/cli/package.json");
3859
- const pkg = JSON.parse(readFileSync3(pkgJsonPath, "utf8"));
4224
+ const pkg = JSON.parse(readFileSync4(pkgJsonPath, "utf8"));
3860
4225
  const binEntry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.devcontainer ?? "";
3861
4226
  if (!binEntry) {
3862
4227
  throw new Error("Could not resolve @devcontainers/cli bin entry.");
3863
4228
  }
3864
- cachedBinaryPath = path10.resolve(path10.dirname(pkgJsonPath), binEntry);
4229
+ cachedBinaryPath = path11.resolve(path11.dirname(pkgJsonPath), binEntry);
3865
4230
  return cachedBinaryPath;
3866
4231
  }
3867
4232
  var spawnDevcontainer = (args, cwd, options = {}) => {
@@ -3979,16 +4344,16 @@ async function cleanupDockerObjects(opts) {
3979
4344
  return { exitCode: rmExit, removedIds: ids };
3980
4345
  }
3981
4346
  function composeProjectName(root) {
3982
- return `${path11.basename(root)}_devcontainer`;
4347
+ return `${path12.basename(root)}_devcontainer`;
3983
4348
  }
3984
4349
  function resolveCompose(root) {
3985
- if (!existsSync5(path11.join(root, ".devcontainer"))) {
4350
+ if (!existsSync6(path12.join(root, ".devcontainer"))) {
3986
4351
  throw new Error(
3987
4352
  `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
3988
4353
  );
3989
4354
  }
3990
- const composeFile = path11.join(root, ".devcontainer", "compose.yaml");
3991
- if (!existsSync5(composeFile)) {
4355
+ const composeFile = path12.join(root, ".devcontainer", "compose.yaml");
4356
+ if (!existsSync6(composeFile)) {
3992
4357
  throw new Error(
3993
4358
  `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.`
3994
4359
  );
@@ -4179,6 +4544,12 @@ function formatUnreachableReposError(failures) {
4179
4544
  lines.push(headerForKind(kind));
4180
4545
  for (const e of entries) {
4181
4546
  lines.push(` \u2022 ${e.url}`);
4547
+ if (e.detail) {
4548
+ for (const detailLine of e.detail.split("\n")) {
4549
+ const trimmed = detailLine.trim();
4550
+ if (trimmed) lines.push(` git: ${trimmed}`);
4551
+ }
4552
+ }
4182
4553
  }
4183
4554
  for (const advice of adviceForKind(kind)) {
4184
4555
  lines.push(` - ${advice}`);
@@ -4225,11 +4596,85 @@ function adviceForKind(kind) {
4225
4596
  }
4226
4597
  }
4227
4598
 
4228
- // src/devcontainer/docker-mode.ts
4599
+ // src/devcontainer/repo-clone.ts
4229
4600
  import { spawn as spawn7 } from "child_process";
4601
+ import { existsSync as existsSync7, promises as fs10 } from "fs";
4602
+ import path13 from "path";
4603
+ var realGitClone = (url, dest) => {
4604
+ return new Promise((resolve, reject) => {
4605
+ const child = spawn7("git", ["clone", "--", url, dest], {
4606
+ stdio: ["ignore", "pipe", "pipe"],
4607
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
4608
+ });
4609
+ let stdout = "";
4610
+ let stderr = "";
4611
+ child.stdout.on("data", (c) => {
4612
+ stdout += c.toString();
4613
+ });
4614
+ child.stderr.on("data", (c) => {
4615
+ stderr += c.toString();
4616
+ });
4617
+ child.on("error", reject);
4618
+ child.on(
4619
+ "exit",
4620
+ (code) => resolve({ stdout, stderr, exitCode: code ?? 0 })
4621
+ );
4622
+ });
4623
+ };
4624
+ async function cloneReposHostSide(containerRoot, repos, options = {}) {
4625
+ const spawnFn = options.spawn ?? realGitClone;
4626
+ const results = [];
4627
+ for (const repo of repos) {
4628
+ const dest = path13.join(containerRoot, "projects", repo.path);
4629
+ if (existsSync7(dest)) {
4630
+ results.push({ path: repo.path, url: repo.url, status: "skipped" });
4631
+ continue;
4632
+ }
4633
+ await fs10.mkdir(path13.dirname(dest), { recursive: true });
4634
+ let r;
4635
+ try {
4636
+ r = await spawnFn(repo.url, dest);
4637
+ } catch (err) {
4638
+ results.push({
4639
+ path: repo.path,
4640
+ url: repo.url,
4641
+ status: "failed",
4642
+ detail: err instanceof Error ? err.message : String(err)
4643
+ });
4644
+ continue;
4645
+ }
4646
+ results.push(
4647
+ r.exitCode === 0 ? { path: repo.path, url: repo.url, status: "cloned" } : {
4648
+ path: repo.path,
4649
+ url: repo.url,
4650
+ status: "failed",
4651
+ detail: r.stderr.trim()
4652
+ }
4653
+ );
4654
+ }
4655
+ return results;
4656
+ }
4657
+ function formatCloneFailuresError(failures) {
4658
+ const lines = failures.length === 1 ? [`Failed to clone declared repo: ${failures[0].url}`, ""] : [`Failed to clone ${failures.length} declared repos:`, ""];
4659
+ for (const f of failures) {
4660
+ lines.push(` \u2022 ${f.url} \u2192 projects/${f.path}`);
4661
+ if (f.detail) lines.push(` ${f.detail}`);
4662
+ }
4663
+ lines.push("");
4664
+ lines.push(
4665
+ "Reachability was confirmed earlier, so this is usually a local issue"
4666
+ );
4667
+ lines.push(
4668
+ "(disk space, a leftover non-empty target dir). Fix it and re-run " + cyan2("monoceros apply") + "."
4669
+ );
4670
+ return lines.join("\n");
4671
+ }
4672
+
4673
+ // src/devcontainer/docker-mode.ts
4674
+ import { spawn as spawn8 } from "child_process";
4230
4675
  var realDockerInfo = () => {
4231
4676
  return new Promise((resolve, reject) => {
4232
- const child = spawn7(
4677
+ const child = spawn8(
4233
4678
  "docker",
4234
4679
  ["info", "--format", "{{json .SecurityOptions}}"],
4235
4680
  {
@@ -4288,13 +4733,13 @@ function formatRootlessNotSupportedError() {
4288
4733
  }
4289
4734
 
4290
4735
  // src/devcontainer/identity.ts
4291
- import { spawn as spawn8 } from "child_process";
4292
- import { promises as fs10 } from "fs";
4293
- import path12 from "path";
4736
+ import { spawn as spawn9 } from "child_process";
4737
+ import { promises as fs11 } from "fs";
4738
+ import path14 from "path";
4294
4739
  import { consola as consola10 } from "consola";
4295
4740
  var realGitConfigGet = (key) => {
4296
4741
  return new Promise((resolve, reject) => {
4297
- const child = spawn8("git", ["config", "--global", "--get", key], {
4742
+ const child = spawn9("git", ["config", "--global", "--get", key], {
4298
4743
  stdio: ["ignore", "pipe", "inherit"]
4299
4744
  });
4300
4745
  let stdout = "";
@@ -4404,8 +4849,8 @@ async function resolveIdentityWithPrompt(options = {}) {
4404
4849
  };
4405
4850
  }
4406
4851
  async function collectGitIdentity(devContainerRoot, options = {}) {
4407
- const gitconfigDir = path12.join(devContainerRoot, ".monoceros");
4408
- const gitconfigPath = path12.join(gitconfigDir, "gitconfig");
4852
+ const gitconfigDir = path14.join(devContainerRoot, ".monoceros");
4853
+ const gitconfigPath = path14.join(gitconfigDir, "gitconfig");
4409
4854
  const logger = options.logger ?? { info: () => {
4410
4855
  }, warn: () => {
4411
4856
  } };
@@ -4418,8 +4863,8 @@ async function collectGitIdentity(devContainerRoot, options = {}) {
4418
4863
  const lines = ["[user]"];
4419
4864
  if (resolved.name !== void 0) lines.push(` name = ${resolved.name}`);
4420
4865
  if (resolved.email !== void 0) lines.push(` email = ${resolved.email}`);
4421
- await fs10.mkdir(gitconfigDir, { recursive: true });
4422
- await fs10.writeFile(gitconfigPath, lines.join("\n") + "\n");
4866
+ await fs11.mkdir(gitconfigDir, { recursive: true });
4867
+ await fs11.writeFile(gitconfigPath, lines.join("\n") + "\n");
4423
4868
  return {
4424
4869
  ...resolved.name !== void 0 ? { name: resolved.name } : {},
4425
4870
  ...resolved.email !== void 0 ? { email: resolved.email } : {},
@@ -4462,7 +4907,7 @@ async function readKeyFromHost(spawnFn, key, logger) {
4462
4907
  }
4463
4908
  async function readExistingGitconfig(filePath) {
4464
4909
  try {
4465
- const content = await fs10.readFile(filePath, "utf8");
4910
+ const content = await fs11.readFile(filePath, "utf8");
4466
4911
  const result = {};
4467
4912
  const nameMatch = /^\s*name\s*=\s*(.+?)\s*$/m.exec(content);
4468
4913
  const emailMatch = /^\s*email\s*=\s*(.+?)\s*$/m.exec(content);
@@ -4495,7 +4940,7 @@ ${sectionLine(label)}
4495
4940
  );
4496
4941
  }
4497
4942
  const ymlPath = containerConfigPath(opts.name, home);
4498
- if (!existsSync6(ymlPath)) {
4943
+ if (!existsSync8(ymlPath)) {
4499
4944
  throw new Error(
4500
4945
  `No such config: ${ymlPath}. Run \`monoceros init <template> ${opts.name}\` first.`
4501
4946
  );
@@ -4512,6 +4957,20 @@ ${sectionLine(label)}
4512
4957
  globalConfig?.defaults?.features ?? {}
4513
4958
  )
4514
4959
  );
4960
+ const envPath = containerEnvPath(opts.name, home);
4961
+ await ensureEnvGitignored(containerConfigsDir(home));
4962
+ const envVars = readEnvFile(envPath);
4963
+ const interpServices = interpolateServices(createOpts.services, envVars);
4964
+ const interpFeatures = interpolateFeatures(
4965
+ createOpts.features ?? {},
4966
+ envVars
4967
+ );
4968
+ const missingVars = [...interpServices.missing, ...interpFeatures.missing];
4969
+ if (missingVars.length > 0) {
4970
+ throw new Error(formatMissingVarsError(missingVars, prettyPath(envPath)));
4971
+ }
4972
+ createOpts.services = interpServices.services;
4973
+ if (createOpts.features) createOpts.features = interpFeatures.features;
4515
4974
  validateOptions(createOpts);
4516
4975
  logger.success(`yml validated ${dim(`(${prettyPath(ymlPath)})`)}`);
4517
4976
  const hasRepos = (createOpts.repos ?? []).length > 0;
@@ -4566,7 +5025,7 @@ ${sectionLine(label)}
4566
5025
  if (dockerMode === "rootless") {
4567
5026
  throw new Error(formatRootlessNotSupportedError());
4568
5027
  }
4569
- await fs11.mkdir(targetDir, { recursive: true });
5028
+ await fs12.mkdir(targetDir, { recursive: true });
4570
5029
  await writeScaffold(createOpts, targetDir, { dockerMode });
4571
5030
  await writeStateFile(
4572
5031
  targetDir,
@@ -4577,6 +5036,23 @@ ${sectionLine(label)}
4577
5036
  })
4578
5037
  );
4579
5038
  logger.success(`materialized into ${prettyPath(targetDir)}`);
5039
+ const reposToClone = createOpts.repos ?? [];
5040
+ if (reposToClone.length > 0) {
5041
+ const cloneResults = await cloneReposHostSide(targetDir, reposToClone, {
5042
+ ...opts.cloneSpawn ? { spawn: opts.cloneSpawn } : {}
5043
+ });
5044
+ for (const r of cloneResults) {
5045
+ if (r.status === "cloned") {
5046
+ logger.success(`cloned ${cyan2(r.path)} ${dim(`(${r.url})`)}`);
5047
+ } else if (r.status === "skipped") {
5048
+ logger.info(`projects/${r.path} already present \u2014 skipped clone`);
5049
+ }
5050
+ }
5051
+ const cloneFailures = cloneResults.filter((r) => r.status === "failed");
5052
+ if (cloneFailures.length > 0) {
5053
+ throw new Error(formatCloneFailuresError(cloneFailures));
5054
+ }
5055
+ }
4580
5056
  section("Container");
4581
5057
  const featureRefs = parsed.config.features.map((f) => f.ref);
4582
5058
  if (featureRefs.length > 0) {
@@ -4624,8 +5100,8 @@ ${sectionLine(label)}
4624
5100
  return { targetDir, configPath: ymlPath, containerExitCode: exitCode };
4625
5101
  }
4626
5102
  async function assertSafeTargetDir(targetDir, expectedOrigin) {
4627
- if (!existsSync6(targetDir)) return;
4628
- const entries = await fs11.readdir(targetDir);
5103
+ if (!existsSync8(targetDir)) return;
5104
+ const entries = await fs12.readdir(targetDir);
4629
5105
  if (entries.length === 0) return;
4630
5106
  const state = await readStateFile(targetDir);
4631
5107
  if (state) {
@@ -4695,7 +5171,7 @@ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
4695
5171
  }
4696
5172
  if (wantContainer) {
4697
5173
  try {
4698
- const text = await fs11.readFile(ymlPath, "utf8");
5174
+ const text = await fs12.readFile(ymlPath, "utf8");
4699
5175
  const parsed = parseConfig(text, ymlPath);
4700
5176
  const changed = setContainerGitUserInDoc(parsed.doc, {
4701
5177
  name: prompted.name,
@@ -4703,7 +5179,7 @@ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
4703
5179
  });
4704
5180
  if (changed) {
4705
5181
  const out = stringifyConfig(parsed.doc);
4706
- await fs11.writeFile(ymlPath, out, "utf8");
5182
+ await fs12.writeFile(ymlPath, out, "utf8");
4707
5183
  logger.info(
4708
5184
  `Saved identity in this container \u2014 wrote git.user into ${prettyPath(ymlPath)}.`
4709
5185
  );
@@ -4717,7 +5193,7 @@ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
4717
5193
  }
4718
5194
 
4719
5195
  // src/version.ts
4720
- var CLI_VERSION = true ? "1.12.0" : "dev";
5196
+ var CLI_VERSION = true ? "1.13.0" : "dev";
4721
5197
 
4722
5198
  // src/commands/_dispatch.ts
4723
5199
  import { consola as consola12 } from "consola";
@@ -4877,8 +5353,8 @@ var completionCommand = defineCommand9({
4877
5353
  import { defineCommand as defineCommand10 } from "citty";
4878
5354
 
4879
5355
  // src/completion/resolve.ts
4880
- import { existsSync as existsSync7, promises as fs12 } from "fs";
4881
- import path13 from "path";
5356
+ import { existsSync as existsSync9, promises as fs13 } from "fs";
5357
+ import path15 from "path";
4882
5358
  async function resolveCompletions(line, point, opts = {}) {
4883
5359
  const { prev, current } = parseCompletionLine(line, point);
4884
5360
  const ctx = { prev, current, opts };
@@ -5026,15 +5502,11 @@ function filterPrefix(values, fragment) {
5026
5502
  }
5027
5503
  async function listContainerNames(ctx) {
5028
5504
  const home = ctx.opts.monocerosHome ?? monocerosHome();
5029
- const dir = path13.join(home, "container-configs");
5030
- if (!existsSync7(dir)) return [];
5031
- const entries = await fs12.readdir(dir);
5505
+ const dir = path15.join(home, "container-configs");
5506
+ if (!existsSync9(dir)) return [];
5507
+ const entries = await fs13.readdir(dir);
5032
5508
  return entries.filter((e) => e.endsWith(".yml")).map((e) => e.slice(0, -".yml".length)).sort();
5033
5509
  }
5034
- async function listAllCatalogComponents() {
5035
- const catalog = await loadComponentCatalog();
5036
- return [...catalog.keys()].sort();
5037
- }
5038
5510
  async function listFeatureComponents() {
5039
5511
  const catalog = await loadComponentCatalog();
5040
5512
  return [...catalog.values()].filter((c) => c.file.category === "feature").map((c) => c.name).sort();
@@ -5136,8 +5608,14 @@ var COMMAND_SPECS = {
5136
5608
  // flag suggestions.
5137
5609
  positionalCount: 1,
5138
5610
  flags: {
5139
- "--with": { type: "value", values: () => listAllCatalogComponents() },
5140
- "--with-repo": { type: "value" },
5611
+ "--with-languages": { type: "value", values: () => listLanguageNames() },
5612
+ "--with-features": {
5613
+ type: "value",
5614
+ values: () => listFeatureComponents()
5615
+ },
5616
+ "--with-services": { type: "value", values: () => listServiceNames() },
5617
+ "--with-apt-packages": { type: "value" },
5618
+ "--with-repos": { type: "value" },
5141
5619
  "--with-ports": { type: "value" }
5142
5620
  }
5143
5621
  },
@@ -5281,22 +5759,22 @@ import { defineCommand as defineCommand11 } from "citty";
5281
5759
  import { consola as consola14 } from "consola";
5282
5760
 
5283
5761
  // src/init/index.ts
5284
- import { existsSync as existsSync8, promises as fs13 } from "fs";
5762
+ import { existsSync as existsSync10, promises as fs14 } from "fs";
5763
+ import path16 from "path";
5285
5764
  import { consola as consola13 } from "consola";
5286
5765
 
5287
5766
  // src/init/generator.ts
5288
5767
  var SCHEMA_HEADER_ACTIVE = "# Solution-config \u2014 describes what should be inside your dev-container.\n# Edit any section, then run `monoceros apply <name>` to (re-)build.";
5289
5768
  var SCHEMA_HEADER_DOCUMENTED = "# Solution-config \u2014 describes what should be inside your dev-container.\n# Every section is commented out by default; un-comment what you need\n# (strip one `#` per line of the block), then run `monoceros apply <name>`.";
5290
5769
  var COMMENT_WIDTH = 76;
5291
- function generateComposedYml(name, components, lookupManifest, repoUrls = [], ports = []) {
5292
- const merged = mergeComponents(components);
5770
+ function generateComposedYml(name, composed, lookupManifest, repoUrls = [], ports = []) {
5293
5771
  const lines = [];
5294
5772
  pushHeader(lines, SCHEMA_HEADER_ACTIVE, name);
5295
5773
  lines.push("");
5296
5774
  lines.push("schemaVersion: 1");
5297
5775
  lines.push(`name: ${name}`);
5298
5776
  lines.push("");
5299
- if (merged.languages.length > 0) {
5777
+ if (composed.languages.length > 0) {
5300
5778
  pushSectionHeader(
5301
5779
  lines,
5302
5780
  LANGUAGES_HEADER,
@@ -5304,10 +5782,21 @@ function generateComposedYml(name, components, lookupManifest, repoUrls = [], po
5304
5782
  false
5305
5783
  );
5306
5784
  lines.push("languages:");
5307
- for (const lang of merged.languages) lines.push(` - ${lang}`);
5785
+ for (const lang of composed.languages) lines.push(` - ${lang}`);
5786
+ lines.push("");
5787
+ }
5788
+ if (composed.aptPackages.length > 0) {
5789
+ pushSectionHeader(
5790
+ lines,
5791
+ APT_PACKAGES_HEADER,
5792
+ /* commented */
5793
+ false
5794
+ );
5795
+ lines.push("aptPackages:");
5796
+ for (const pkg of composed.aptPackages) lines.push(` - ${pkg}`);
5308
5797
  lines.push("");
5309
5798
  }
5310
- if (merged.services.length > 0) {
5799
+ if (composed.services.length > 0) {
5311
5800
  pushSectionHeader(
5312
5801
  lines,
5313
5802
  SERVICES_HEADER,
@@ -5315,10 +5804,10 @@ function generateComposedYml(name, components, lookupManifest, repoUrls = [], po
5315
5804
  false
5316
5805
  );
5317
5806
  lines.push("services:");
5318
- for (const svc of merged.services) lines.push(` - ${svc}`);
5807
+ for (const svc of composed.services) pushServiceEntry(lines, svc);
5319
5808
  lines.push("");
5320
5809
  }
5321
- if (merged.features.length > 0) {
5810
+ if (composed.features.length > 0) {
5322
5811
  pushSectionHeader(
5323
5812
  lines,
5324
5813
  FEATURES_HEADER_ACTIVE,
@@ -5326,7 +5815,7 @@ function generateComposedYml(name, components, lookupManifest, repoUrls = [], po
5326
5815
  false
5327
5816
  );
5328
5817
  lines.push("features:");
5329
- for (const f of merged.features) {
5818
+ for (const f of composed.features) {
5330
5819
  lines.push("");
5331
5820
  renderFeatureBlock(
5332
5821
  lines,
@@ -5407,7 +5896,9 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
5407
5896
  lines.push("# services:");
5408
5897
  for (const c of byCategory.service) {
5409
5898
  for (const svc of c.file.contributes.services ?? []) {
5410
- lines.push(`# - ${svc}`);
5899
+ const body = renderServiceObjectBody(expandCuratedService(svc));
5900
+ lines.push(`# - ${body[0]}`);
5901
+ for (const line of body.slice(1)) lines.push(`# ${line}`);
5411
5902
  }
5412
5903
  }
5413
5904
  lines.push("");
@@ -5514,6 +6005,22 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
5514
6005
  }
5515
6006
  var LANGUAGES_HEADER = "Language runtimes installed inside the dev-container. Pick the ones your projects build against. The catalog of available runtimes is shown by `monoceros list-components`.";
5516
6007
  var SERVICES_HEADER = "Sibling containers that run alongside the dev-container (databases, caches, message queues, \u2026). Each service is reachable from inside the dev-container by its name as hostname (e.g. `postgres://postgres:5432`). Activating any service switches the container to docker-compose mode automatically.";
6008
+ var APT_PACKAGES_HEADER = "Debian/Ubuntu apt packages installed in the dev-container at build time. No curated list \u2014 any apt package name works; an invalid name surfaces as an apt error during build.";
6009
+ function pushServiceEntry(out, svc) {
6010
+ if (svc.kind === "custom") {
6011
+ const { bodyLines, comment } = renderCustomService(
6012
+ svc.name,
6013
+ svc.image ?? ""
6014
+ );
6015
+ out.push(` - ${bodyLines[0]}`);
6016
+ for (const line of bodyLines.slice(1)) out.push(` ${line}`);
6017
+ for (const cl of comment.split("\n")) out.push(` #${cl}`);
6018
+ return;
6019
+ }
6020
+ const body = renderServiceObjectBody(expandCuratedService(svc.name));
6021
+ out.push(` - ${body[0]}`);
6022
+ for (const line of body.slice(1)) out.push(` ${line}`);
6023
+ }
5517
6024
  var FEATURES_HEADER_ACTIVE = "A Monoceros dev-container is shaped by features \u2014 pluggable units that drop tooling (AI assistants, language CLIs, cloud SDKs, \u2026) into the container and bring their own options. The features active for this container are listed below; adjust their options as needed. Shared credentials used across containers belong in monoceros-config.yml under `defaults.features.<ref>` rather than here. Full catalog: `monoceros list-components`.";
5518
6025
  var FEATURES_HEADER_DOCUMENTED = "A Monoceros dev-container is shaped by features \u2014 pluggable units that drop tooling (AI assistants, language CLIs, cloud SDKs, \u2026) into the container and bring their own options. Un-comment the blocks below for the features you want active. Shared credentials used across containers belong in monoceros-config.yml under `defaults.features.<ref>` rather than here. Full catalog: `monoceros list-components`.";
5519
6026
  var REPOS_HEADER = "Git repositories cloned into `projects/` on container start-up. HTTPS URLs only. The provider is auto-detected for github.com / gitlab.com / bitbucket.org; for any other host (self-hosted GitLab, Gitea, \u2026) declare `provider:` explicitly. Add more later with `monoceros add-repo`.";
@@ -5528,15 +6035,15 @@ function renderFeatureBlock(out, feature, summary, commented) {
5528
6035
  out.push(`${yamlPrefix} - ref: ${feature.ref}`);
5529
6036
  const options = feature.options ?? {};
5530
6037
  const activeKeys = Object.entries(options);
5531
- const hintKeys = (summary?.optionHints ?? []).filter((h) => !(h in options));
5532
- if (activeKeys.length === 0 && hintKeys.length === 0) return;
6038
+ const hints = featureOptionHints(summary, feature.ref, Object.keys(options));
6039
+ if (activeKeys.length === 0 && hints.length === 0) return;
5533
6040
  if (commented) {
5534
6041
  out.push(`${yamlPrefix} options:`);
5535
6042
  for (const [key, value] of activeKeys) {
5536
6043
  out.push(`${yamlPrefix} ${key}: ${renderScalarValue(value)}`);
5537
6044
  }
5538
- for (const key of hintKeys) {
5539
- out.push(`${yamlPrefix} ${key}:`);
6045
+ for (const hint of hints) {
6046
+ out.push(`${yamlPrefix} ${hint.key}: ${hint.placeholder}`);
5540
6047
  }
5541
6048
  return;
5542
6049
  }
@@ -5546,10 +6053,10 @@ function renderFeatureBlock(out, feature, summary, commented) {
5546
6053
  out.push(` ${key}: ${renderScalarValue(value)}`);
5547
6054
  }
5548
6055
  }
5549
- if (hintKeys.length > 0) {
6056
+ if (hints.length > 0) {
5550
6057
  out.push(` # options:`);
5551
- for (const key of hintKeys) {
5552
- out.push(` # ${key}:`);
6058
+ for (const hint of hints) {
6059
+ out.push(` # ${hint.key}: ${hint.placeholder}`);
5553
6060
  }
5554
6061
  }
5555
6062
  }
@@ -5603,7 +6110,7 @@ async function runInit(opts) {
5603
6110
  );
5604
6111
  }
5605
6112
  const dest = containerConfigPath(opts.name, home);
5606
- if (existsSync8(dest)) {
6113
+ if (existsSync10(dest)) {
5607
6114
  throw new Error(
5608
6115
  `Config already exists: ${dest}. Delete it manually before re-running \`monoceros init\` \u2014 this protects any hand-edits.`
5609
6116
  );
@@ -5673,15 +6180,28 @@ async function runInit(opts) {
5673
6180
  });
5674
6181
  }
5675
6182
  let text;
5676
- const requested = opts.with ?? [];
5677
- if (requested.length === 0) {
6183
+ const composed = resolveComposedInit(catalog, {
6184
+ languages: opts.languages ?? [],
6185
+ features: opts.features ?? [],
6186
+ services: opts.services ?? [],
6187
+ aptPackages: opts.aptPackages ?? []
6188
+ });
6189
+ const anyComposed = composed.languages.length > 0 || composed.features.length > 0 || composed.services.length > 0 || composed.aptPackages.length > 0;
6190
+ if (!anyComposed) {
5678
6191
  text = generateDocumentedYml(opts.name, catalog, lookup, repos, ports);
5679
6192
  } else {
5680
- const components = resolveComponents(catalog, requested);
5681
- text = generateComposedYml(opts.name, components, lookup, repos, ports);
5682
- }
5683
- await fs13.mkdir(containerConfigsDir(home), { recursive: true });
5684
- await fs13.writeFile(dest, text, "utf8");
6193
+ text = generateComposedYml(opts.name, composed, lookup, repos, ports);
6194
+ }
6195
+ await fs14.mkdir(containerConfigsDir(home), { recursive: true });
6196
+ await ensureEnvGitignored(containerConfigsDir(home));
6197
+ await fs14.writeFile(dest, text, "utf8");
6198
+ const envPath = containerEnvPath(opts.name, home);
6199
+ const featureVars = composed.features.flatMap(
6200
+ (f) => featureOptionHints(lookup(f.ref), f.ref, Object.keys(f.options ?? {})).map(
6201
+ (h) => h.envVar
6202
+ )
6203
+ );
6204
+ await ensureEnvVars(envPath, opts.name, featureVars);
5685
6205
  if (promptedIdentity?.prompted) {
5686
6206
  const { name, email, scope } = promptedIdentity.prompted;
5687
6207
  if (scope === "g" || scope === "b") {
@@ -5711,11 +6231,11 @@ async function runInit(opts) {
5711
6231
  }
5712
6232
  if (scope === "c" || scope === "b") {
5713
6233
  try {
5714
- const written = await fs13.readFile(dest, "utf8");
6234
+ const written = await fs14.readFile(dest, "utf8");
5715
6235
  const parsed = parseConfig(written, dest);
5716
6236
  const changed = setContainerGitUserInDoc(parsed.doc, { name, email });
5717
6237
  if (changed) {
5718
- await fs13.writeFile(dest, stringifyConfig(parsed.doc), "utf8");
6238
+ await fs14.writeFile(dest, stringifyConfig(parsed.doc), "utf8");
5719
6239
  logger.info(
5720
6240
  `Saved identity in ${prettyPath(dest)} (container-level git.user).`
5721
6241
  );
@@ -5727,29 +6247,136 @@ async function runInit(opts) {
5727
6247
  }
5728
6248
  }
5729
6249
  }
5730
- const documented = requested.length === 0;
5731
- const displayPath = prettyPath(dest);
6250
+ const documented = !anyComposed;
6251
+ const ymlRel = path16.relative(home, dest);
6252
+ const envRel = path16.relative(home, envPath);
5732
6253
  if (documented) {
5733
- logger.success(
5734
- `Wrote documented default to ${displayPath}. Un-comment what you need, then \`monoceros apply ${opts.name}\`.`
6254
+ logger.success(`Wrote documented default to ${ymlRel} and ${envRel}.`);
6255
+ logger.info(
6256
+ `Un-comment what you need, then \`monoceros apply ${opts.name}\`.`
5735
6257
  );
5736
6258
  } else {
5737
- logger.success(
5738
- `Composed ${requested.length} component(s) into ${displayPath}: ${requested.join(", ")}`
5739
- );
6259
+ logger.success(`Composed into ${ymlRel} and ${envRel}.`);
5740
6260
  logger.info(
5741
- `Edit the file if you need to tweak, then \`monoceros apply ${opts.name}\`.`
6261
+ `Edit the files if you need to tweak, then \`monoceros apply ${opts.name}\`.`
5742
6262
  );
5743
6263
  }
5744
6264
  return { configPath: dest, documented };
5745
6265
  }
6266
+ function resolveComposedInit(catalog, raw) {
6267
+ return {
6268
+ languages: resolveInitLanguages(raw.languages),
6269
+ aptPackages: resolveInitAptPackages(raw.aptPackages),
6270
+ services: resolveInitServices(raw.services),
6271
+ features: resolveInitFeatures(catalog, raw.features)
6272
+ };
6273
+ }
6274
+ function resolveInitLanguages(entries) {
6275
+ const known = new Set(knownLanguages());
6276
+ const out = [];
6277
+ const seen = /* @__PURE__ */ new Set();
6278
+ const unknown = [];
6279
+ for (const raw of entries) {
6280
+ const e = raw.trim();
6281
+ if (!e || seen.has(e)) continue;
6282
+ const spec = parseLanguageSpec(e);
6283
+ if (!spec || !known.has(spec.name)) {
6284
+ unknown.push(e);
6285
+ continue;
6286
+ }
6287
+ seen.add(e);
6288
+ out.push(e);
6289
+ }
6290
+ if (unknown.length > 0) {
6291
+ throw new Error(
6292
+ `Unknown language${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. Known: ${knownLanguages().join(", ")}.`
6293
+ );
6294
+ }
6295
+ return out;
6296
+ }
6297
+ function resolveInitAptPackages(entries) {
6298
+ const out = [];
6299
+ const seen = /* @__PURE__ */ new Set();
6300
+ const bad = [];
6301
+ for (const raw of entries) {
6302
+ const e = raw.trim();
6303
+ if (!e || seen.has(e)) continue;
6304
+ if (!REGEX.aptPackage.test(e)) {
6305
+ bad.push(e);
6306
+ continue;
6307
+ }
6308
+ seen.add(e);
6309
+ out.push(e);
6310
+ }
6311
+ if (bad.length > 0) {
6312
+ throw new Error(
6313
+ `Invalid apt package name${bad.length > 1 ? "s" : ""}: ${bad.join(", ")}. Expected lowercase alphanumeric plus '.+-'.`
6314
+ );
6315
+ }
6316
+ return out;
6317
+ }
6318
+ function resolveInitServices(entries) {
6319
+ const out = [];
6320
+ const byName = /* @__PURE__ */ new Map();
6321
+ for (const raw of entries) {
6322
+ const e = raw.trim();
6323
+ if (!e) continue;
6324
+ const svc = isCuratedService(e) ? { kind: "curated", name: e } : { kind: "custom", name: deriveServiceName(e), image: e };
6325
+ const existing = byName.get(svc.name);
6326
+ if (existing) {
6327
+ if (existing.kind === svc.kind && existing.image === svc.image) continue;
6328
+ throw new Error(
6329
+ `Two --with-services entries resolve to the service name '${svc.name}'. Add one after init with \`monoceros add-service ${"<name>"} <image> --as=<other>\`.`
6330
+ );
6331
+ }
6332
+ byName.set(svc.name, svc);
6333
+ out.push(svc);
6334
+ }
6335
+ return out;
6336
+ }
6337
+ function resolveInitFeatures(catalog, entries) {
6338
+ const byRef = /* @__PURE__ */ new Map();
6339
+ const unknown = [];
6340
+ for (const raw of entries) {
6341
+ const e = raw.trim();
6342
+ if (!e) continue;
6343
+ if (REGEX.featureRef.test(e)) {
6344
+ if (!byRef.has(e)) byRef.set(e, { ref: e, options: {} });
6345
+ continue;
6346
+ }
6347
+ const c = catalog.get(e);
6348
+ if (!c || c.file.category !== "feature") {
6349
+ unknown.push(e);
6350
+ continue;
6351
+ }
6352
+ for (const f of c.file.contributes.features ?? []) {
6353
+ const existing = byRef.get(f.ref);
6354
+ if (!existing) {
6355
+ byRef.set(f.ref, { ref: f.ref, options: { ...f.options ?? {} } });
6356
+ } else {
6357
+ existing.options = mergeFeatureOptions(
6358
+ existing.options,
6359
+ f.options ?? {}
6360
+ );
6361
+ }
6362
+ }
6363
+ }
6364
+ if (unknown.length > 0) {
6365
+ const featureNames = [...catalog.values()].filter((c) => c.file.category === "feature").map((c) => c.name).sort();
6366
+ throw new Error(
6367
+ `Unknown feature${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.
6368
+ Use a catalog short name (${featureNames.join(", ")}) or a full OCI ref (ghcr.io/\u2026/<name>:<tag>).`
6369
+ );
6370
+ }
6371
+ return [...byRef.values()];
6372
+ }
5746
6373
 
5747
6374
  // src/commands/init.ts
5748
6375
  var initCommand = defineCommand11({
5749
6376
  meta: {
5750
6377
  name: "init",
5751
6378
  group: "lifecycle",
5752
- description: "Create a fresh container-config yml at .local/container-configs/<name>.yml. Without --with, the file is a documented default with every component commented out. With --with=<names>, the named components are composed into an active, immediately-applyable yml. Then run `monoceros apply <name>`."
6379
+ description: "Create a fresh container-config yml at <MONOCEROS_HOME>/container-configs/<name>.yml. Without any --with-* flag, the file is a documented default with every component commented out. With --with-languages / --with-features / --with-services / --with-apt-packages, the named pieces are composed into an active, immediately-applyable yml. Then run `monoceros apply <name>`."
5753
6380
  },
5754
6381
  args: {
5755
6382
  name: {
@@ -5757,14 +6384,29 @@ var initCommand = defineCommand11({
5757
6384
  description: "Config name. The yml lands at <MONOCEROS_HOME>/container-configs/<name>.yml and becomes the source-of-truth for `monoceros apply <name>`.",
5758
6385
  required: true
5759
6386
  },
5760
- with: {
6387
+ "with-languages": {
6388
+ type: "string",
6389
+ description: "Language runtimes to install, comma-separated or repeated, e.g. --with-languages=java,node. Optional :version (java:17). Curated catalog only \u2014 see `monoceros list-components`.",
6390
+ required: false
6391
+ },
6392
+ "with-features": {
6393
+ type: "string",
6394
+ description: "Features (AI tools, language CLIs, \u2026), comma-separated or repeated. Catalog short name (claude, atlassian/twg) or a full OCI ref (ghcr.io/foo/bar:1).",
6395
+ required: false
6396
+ },
6397
+ "with-services": {
6398
+ type: "string",
6399
+ description: "Backing services, comma-separated or repeated. Curated name (postgres, mysql, redis) \u2192 full editable block; any other image (rustfs/rustfs:latest) \u2192 name + image + commented scaffold.",
6400
+ required: false
6401
+ },
6402
+ "with-apt-packages": {
5761
6403
  type: "string",
5762
- description: "Comma-separated list of component names to compose, e.g. 'node,postgres,github,claude'. Sub-components use a slash, e.g. 'atlassian/twg'. When omitted, init writes a documented default with every catalog component commented out.",
6404
+ description: "Debian/Ubuntu apt packages to install, comma-separated or repeated, e.g. --with-apt-packages=openssl,make. No curated list.",
5763
6405
  required: false
5764
6406
  },
5765
- "with-repo": {
6407
+ "with-repos": {
5766
6408
  type: "string",
5767
- description: "Git URL of a repo to clone into projects/ on first apply. Repeatable: pass --with-repo=URL1 --with-repo=URL2 for multiple repos. Folder name derived from URL (foo.git \u2192 projects/foo/); use `monoceros add-repo --path=...` post-init for subfolder paths.",
6409
+ description: "Git URLs to clone into projects/ on first apply, comma-separated or repeated. Folder name derived from URL (foo.git \u2192 projects/foo/); use `monoceros add-repo --path=...` post-init for subfolder paths. Canonical hosts only (github.com / gitlab.com / bitbucket.org).",
5768
6410
  required: false
5769
6411
  },
5770
6412
  "with-ports": {
@@ -5775,14 +6417,20 @@ var initCommand = defineCommand11({
5775
6417
  },
5776
6418
  async run({ args, rawArgs }) {
5777
6419
  try {
5778
- const withList = collectWithList(args.with, rawArgs);
5779
- const withRepoList = collectWithRepoList(rawArgs);
5780
- const withPortsList = collectWithPortsList(args["with-ports"], rawArgs);
6420
+ const languages = collectListFlag("--with-languages", rawArgs);
6421
+ const features = collectListFlag("--with-features", rawArgs);
6422
+ const services = collectListFlag("--with-services", rawArgs);
6423
+ const aptPackages = collectListFlag("--with-apt-packages", rawArgs);
6424
+ const repos = collectListFlag("--with-repos", rawArgs);
6425
+ const ports = collectWithPortsList(args["with-ports"], rawArgs);
5781
6426
  await runInit({
5782
6427
  name: args.name,
5783
- ...withList ? { with: withList } : {},
5784
- ...withRepoList.length > 0 ? { withRepo: withRepoList } : {},
5785
- ...withPortsList && withPortsList.length > 0 ? { withPorts: withPortsList } : {}
6428
+ ...languages.length > 0 ? { languages } : {},
6429
+ ...features.length > 0 ? { features } : {},
6430
+ ...services.length > 0 ? { services } : {},
6431
+ ...aptPackages.length > 0 ? { aptPackages } : {},
6432
+ ...repos.length > 0 ? { withRepo: repos } : {},
6433
+ ...ports && ports.length > 0 ? { withPorts: ports } : {}
5786
6434
  });
5787
6435
  } catch (err) {
5788
6436
  consola14.error(err instanceof Error ? err.message : String(err));
@@ -5790,6 +6438,30 @@ var initCommand = defineCommand11({
5790
6438
  }
5791
6439
  }
5792
6440
  });
6441
+ function collectListFlag(flag, rawArgs) {
6442
+ const eq = `${flag}=`;
6443
+ const pieces = [];
6444
+ for (let i = 0; i < rawArgs.length; i += 1) {
6445
+ const t = rawArgs[i];
6446
+ let scanStart = -1;
6447
+ if (t === flag) {
6448
+ scanStart = i + 1;
6449
+ } else if (t.startsWith(eq)) {
6450
+ pieces.push(t.slice(eq.length));
6451
+ scanStart = i + 1;
6452
+ }
6453
+ if (scanStart < 0) continue;
6454
+ let j = scanStart;
6455
+ while (j < rawArgs.length) {
6456
+ const u = rawArgs[j];
6457
+ if (u.startsWith("-")) break;
6458
+ pieces.push(u);
6459
+ j += 1;
6460
+ }
6461
+ i = j - 1;
6462
+ }
6463
+ return pieces.flatMap((s) => s.split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
6464
+ }
5793
6465
  function collectWithPortsList(_withPortsArg, rawArgs) {
5794
6466
  const pieces = [];
5795
6467
  for (let i = 0; i < rawArgs.length; i += 1) {
@@ -5825,43 +6497,6 @@ function collectWithPortsList(_withPortsArg, rawArgs) {
5825
6497
  }
5826
6498
  return out;
5827
6499
  }
5828
- function collectWithRepoList(rawArgs) {
5829
- const urls = [];
5830
- for (let i = 0; i < rawArgs.length; i += 1) {
5831
- const t = rawArgs[i];
5832
- if (t === "--with-repo") {
5833
- const next = rawArgs[i + 1];
5834
- if (typeof next === "string" && !next.startsWith("-")) {
5835
- urls.push(next);
5836
- i += 1;
5837
- }
5838
- } else if (t.startsWith("--with-repo=")) {
5839
- urls.push(t.slice("--with-repo=".length));
5840
- }
5841
- }
5842
- return urls;
5843
- }
5844
- function collectWithList(withArg, rawArgs) {
5845
- if (typeof withArg !== "string" || withArg.trim().length === 0) {
5846
- return void 0;
5847
- }
5848
- let combined = withArg.trim();
5849
- const startIdx = rawArgs.findIndex(
5850
- (t) => t === "--with" || t.startsWith("--with=")
5851
- );
5852
- if (startIdx >= 0) {
5853
- let scanFrom = startIdx + 1;
5854
- if (rawArgs[startIdx] === "--with") scanFrom += 1;
5855
- for (let i = scanFrom; i < rawArgs.length; i += 1) {
5856
- const t = rawArgs[i];
5857
- if (t.startsWith("--") || t === "-h" || t === "--help") break;
5858
- const sep = combined.endsWith(",") ? "" : ",";
5859
- combined += sep + t;
5860
- }
5861
- }
5862
- const pieces = combined.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
5863
- return pieces.length > 0 ? pieces : void 0;
5864
- }
5865
6500
 
5866
6501
  // src/commands/list-components.ts
5867
6502
  import { defineCommand as defineCommand12 } from "citty";
@@ -6149,8 +6784,8 @@ import { consola as consola20 } from "consola";
6149
6784
  import { createInterface } from "readline/promises";
6150
6785
 
6151
6786
  // src/remove/index.ts
6152
- import { existsSync as existsSync9, promises as fs14 } from "fs";
6153
- import path14 from "path";
6787
+ import { existsSync as existsSync11, promises as fs15 } from "fs";
6788
+ import path17 from "path";
6154
6789
  import { consola as consola19 } from "consola";
6155
6790
  async function runRemove(opts) {
6156
6791
  const home = opts.monocerosHome ?? monocerosHome();
@@ -6165,9 +6800,11 @@ async function runRemove(opts) {
6165
6800
  );
6166
6801
  }
6167
6802
  const ymlPath = containerConfigPath(opts.name, home);
6803
+ const envPath = containerEnvPath(opts.name, home);
6168
6804
  const containerPath = containerDir(opts.name, home);
6169
- const hasYml = existsSync9(ymlPath);
6170
- const hasContainer = existsSync9(containerPath);
6805
+ const hasYml = existsSync11(ymlPath);
6806
+ const hasEnv = existsSync11(envPath);
6807
+ const hasContainer = existsSync11(containerPath);
6171
6808
  if (!hasYml && !hasContainer) {
6172
6809
  throw new Error(
6173
6810
  `Nothing to remove for '${opts.name}': neither ${ymlPath} nor ${containerPath} exists.`
@@ -6191,24 +6828,30 @@ async function runRemove(opts) {
6191
6828
  let backupPath = null;
6192
6829
  if (!opts.noBackup && (hasYml || hasContainer)) {
6193
6830
  const ts = (opts.now ?? /* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
6194
- backupPath = path14.join(home, "container-backups", `${opts.name}-${ts}`);
6195
- await fs14.mkdir(backupPath, { recursive: true });
6831
+ backupPath = path17.join(home, "container-backups", `${opts.name}-${ts}`);
6832
+ await fs15.mkdir(backupPath, { recursive: true });
6196
6833
  if (hasYml) {
6197
- await fs14.copyFile(ymlPath, path14.join(backupPath, `${opts.name}.yml`));
6834
+ await fs15.copyFile(ymlPath, path17.join(backupPath, `${opts.name}.yml`));
6835
+ }
6836
+ if (hasEnv) {
6837
+ await fs15.copyFile(envPath, path17.join(backupPath, `${opts.name}.env`));
6198
6838
  }
6199
6839
  if (hasContainer) {
6200
- await fs14.cp(containerPath, path14.join(backupPath, "container"), {
6840
+ await fs15.cp(containerPath, path17.join(backupPath, "container"), {
6201
6841
  recursive: true
6202
6842
  });
6203
6843
  }
6204
6844
  logger.info(`Backup written to ${prettyPath(backupPath)}.`);
6205
6845
  }
6206
6846
  if (hasYml) {
6207
- await fs14.rm(ymlPath, { force: true });
6847
+ await fs15.rm(ymlPath, { force: true });
6848
+ }
6849
+ if (hasEnv) {
6850
+ await fs15.rm(envPath, { force: true });
6208
6851
  }
6209
6852
  if (hasContainer) {
6210
6853
  try {
6211
- await fs14.rm(containerPath, { recursive: true, force: true });
6854
+ await fs15.rm(containerPath, { recursive: true, force: true });
6212
6855
  } catch (err) {
6213
6856
  const code = err.code;
6214
6857
  if (code !== "EACCES" && code !== "EPERM") {
@@ -6234,7 +6877,7 @@ async function runRemove(opts) {
6234
6877
  `docker-based cleanup of ${containerPath} exited ${exit}. Inspect with \`sudo ls -la ${containerPath}\` and clean manually.`
6235
6878
  );
6236
6879
  }
6237
- await fs14.rm(containerPath, { recursive: true, force: true });
6880
+ await fs15.rm(containerPath, { recursive: true, force: true });
6238
6881
  }
6239
6882
  }
6240
6883
  logger.success(
@@ -6337,8 +6980,8 @@ import { defineCommand as defineCommand18 } from "citty";
6337
6980
  import { consola as consola22 } from "consola";
6338
6981
 
6339
6982
  // src/restore/index.ts
6340
- import { existsSync as existsSync10, promises as fs15 } from "fs";
6341
- import path15 from "path";
6983
+ import { existsSync as existsSync12, promises as fs16 } from "fs";
6984
+ import path18 from "path";
6342
6985
  import { consola as consola21 } from "consola";
6343
6986
  async function runRestore(opts) {
6344
6987
  const home = opts.monocerosHome ?? monocerosHome();
@@ -6346,15 +6989,15 @@ async function runRestore(opts) {
6346
6989
  info: (msg) => consola21.info(msg),
6347
6990
  success: (msg) => consola21.success(msg)
6348
6991
  };
6349
- const backup = path15.resolve(opts.backupPath);
6350
- if (!existsSync10(backup)) {
6992
+ const backup = path18.resolve(opts.backupPath);
6993
+ if (!existsSync12(backup)) {
6351
6994
  throw new Error(`Backup not found: ${backup}.`);
6352
6995
  }
6353
- const stat = await fs15.stat(backup);
6996
+ const stat = await fs16.stat(backup);
6354
6997
  if (!stat.isDirectory()) {
6355
6998
  throw new Error(`Backup path is not a directory: ${backup}.`);
6356
6999
  }
6357
- const entries = await fs15.readdir(backup);
7000
+ const entries = await fs16.readdir(backup);
6358
7001
  const ymlFiles = entries.filter((f) => f.endsWith(".yml"));
6359
7002
  if (ymlFiles.length === 0) {
6360
7003
  throw new Error(
@@ -6368,24 +7011,29 @@ async function runRestore(opts) {
6368
7011
  }
6369
7012
  const ymlFile = ymlFiles[0];
6370
7013
  const name = ymlFile.replace(/\.yml$/, "");
6371
- const containerInBackup = path15.join(backup, "container");
6372
- const hasContainer = existsSync10(containerInBackup);
7014
+ const containerInBackup = path18.join(backup, "container");
7015
+ const hasContainer = existsSync12(containerInBackup);
7016
+ const envInBackup = path18.join(backup, `${name}.env`);
7017
+ const hasEnv = existsSync12(envInBackup);
6373
7018
  const destYml = containerConfigPath(name, home);
6374
7019
  const destContainer = containerDir(name, home);
6375
- if (existsSync10(destYml)) {
7020
+ if (existsSync12(destYml)) {
6376
7021
  throw new Error(
6377
7022
  `Refusing to restore: ${destYml} already exists. Remove the current container first (\`monoceros remove ${name}\`) or rename the existing config.`
6378
7023
  );
6379
7024
  }
6380
- if (hasContainer && existsSync10(destContainer)) {
7025
+ if (hasContainer && existsSync12(destContainer)) {
6381
7026
  throw new Error(
6382
7027
  `Refusing to restore: ${destContainer} already exists. Remove the current container first (\`monoceros remove ${name}\`).`
6383
7028
  );
6384
7029
  }
6385
- await fs15.mkdir(containerConfigsDir(home), { recursive: true });
6386
- await fs15.copyFile(path15.join(backup, ymlFile), destYml);
7030
+ await fs16.mkdir(containerConfigsDir(home), { recursive: true });
7031
+ await fs16.copyFile(path18.join(backup, ymlFile), destYml);
7032
+ if (hasEnv) {
7033
+ await fs16.copyFile(envInBackup, containerEnvPath(name, home));
7034
+ }
6387
7035
  if (hasContainer) {
6388
- await fs15.cp(containerInBackup, destContainer, { recursive: true });
7036
+ await fs16.cp(containerInBackup, destContainer, { recursive: true });
6389
7037
  }
6390
7038
  logger.success(`Restored '${name}' from ${prettyPath(backup)}.`);
6391
7039
  logger.info(
@@ -6643,8 +7291,8 @@ import { defineCommand as defineCommand24 } from "citty";
6643
7291
  import { consola as consola28 } from "consola";
6644
7292
 
6645
7293
  // src/devcontainer/shell.ts
6646
- import { existsSync as existsSync11 } from "fs";
6647
- import path16 from "path";
7294
+ import { existsSync as existsSync13 } from "fs";
7295
+ import path19 from "path";
6648
7296
  async function runShell(opts) {
6649
7297
  assertContainerExists(opts.root);
6650
7298
  const spawnFn = opts.spawn ?? spawnDevcontainer;
@@ -6667,7 +7315,7 @@ async function runShell(opts) {
6667
7315
  );
6668
7316
  }
6669
7317
  function assertContainerExists(root) {
6670
- if (!existsSync11(path16.join(root, ".devcontainer"))) {
7318
+ if (!existsSync13(path19.join(root, ".devcontainer"))) {
6671
7319
  throw new Error(
6672
7320
  `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
6673
7321
  );
@@ -6879,15 +7527,15 @@ import { defineCommand as defineCommand29 } from "citty";
6879
7527
  import { consola as consola33 } from "consola";
6880
7528
 
6881
7529
  // src/tunnel/run.ts
6882
- import { spawn as spawn9 } from "child_process";
7530
+ import { spawn as spawn10 } from "child_process";
6883
7531
  import { consola as consola32 } from "consola";
6884
7532
 
6885
7533
  // src/tunnel/resolve.ts
6886
- import { existsSync as existsSync12 } from "fs";
6887
- import path17 from "path";
7534
+ import { existsSync as existsSync14 } from "fs";
7535
+ import path20 from "path";
6888
7536
  async function resolveTunnelTarget(opts) {
6889
7537
  const ymlPath = containerConfigPath(opts.name, opts.monocerosHome);
6890
- if (!existsSync12(ymlPath)) {
7538
+ if (!existsSync14(ymlPath)) {
6891
7539
  throw new Error(
6892
7540
  `No yml profile for '${opts.name}' at ${ymlPath}. Run \`monoceros init ${opts.name}\` first.`
6893
7541
  );
@@ -6895,13 +7543,13 @@ async function resolveTunnelTarget(opts) {
6895
7543
  const parsed = await readConfig(ymlPath);
6896
7544
  const config = parsed.config;
6897
7545
  const containerRoot = containerDir(opts.name, opts.monocerosHome);
6898
- if (!existsSync12(containerRoot)) {
7546
+ if (!existsSync14(containerRoot)) {
6899
7547
  throw new Error(
6900
7548
  `Container '${opts.name}' is not materialised at ${containerRoot}. Run \`monoceros apply ${opts.name}\` first.`
6901
7549
  );
6902
7550
  }
6903
- const composePath = path17.join(containerRoot, ".devcontainer", "compose.yaml");
6904
- const isCompose = existsSync12(composePath);
7551
+ const composePath = path20.join(containerRoot, ".devcontainer", "compose.yaml");
7552
+ const isCompose = existsSync14(composePath);
6905
7553
  const parsedTarget = parseTargetArg(opts.target, config);
6906
7554
  const docker = opts.docker ?? defaultDockerExec;
6907
7555
  if (isCompose) {
@@ -6920,23 +7568,41 @@ async function resolveTunnelTarget(opts) {
6920
7568
  });
6921
7569
  }
6922
7570
  function parseTargetArg(raw, config) {
7571
+ const colon = raw.indexOf(":");
7572
+ if (colon > 0) {
7573
+ const name = raw.slice(0, colon);
7574
+ const port = Number(raw.slice(colon + 1));
7575
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
7576
+ throw new Error(
7577
+ `Invalid target '${raw}'. Use <service>:<port> with a numeric port (1\u201365535), a bare port number, or a configured service name.`
7578
+ );
7579
+ }
7580
+ findConfiguredService(config, name);
7581
+ return { kind: "service", service: name, port };
7582
+ }
6923
7583
  const asNumber = Number(raw);
6924
7584
  if (Number.isInteger(asNumber) && asNumber > 0 && asNumber < 65536) {
6925
7585
  return { kind: "port", port: asNumber };
6926
7586
  }
6927
- const entry2 = SERVICE_CATALOG[raw];
6928
- if (!entry2) {
6929
- const candidates = knownServices().join(", ");
7587
+ const match = findConfiguredService(config, raw);
7588
+ if (match.port === void 0) {
6930
7589
  throw new Error(
6931
- `Unknown service '${raw}'. Known services: ${candidates}. Or pass a port number (e.g. \`monoceros tunnel <name> 8080\`).`
7590
+ `Service '${raw}' declares no port, so tunnel can't know what to forward. Add \`port: <n>\` to the service in the yml and re-apply, or pass one explicitly: \`monoceros tunnel <name> ${raw}:<port>\`.`
6932
7591
  );
6933
7592
  }
6934
- if (!config.services.includes(raw)) {
7593
+ return { kind: "service", service: raw, port: match.port };
7594
+ }
7595
+ function findConfiguredService(config, name) {
7596
+ const services = config.services.map(resolveService);
7597
+ const match = services.find((s) => s.name === name);
7598
+ if (!match) {
7599
+ const names = services.map((s) => s.name);
7600
+ const list = names.length > 0 ? names.join(", ") : "(none configured)";
6935
7601
  throw new Error(
6936
- `Service '${raw}' is not declared in this container's yml. Add it with \`monoceros add-service ${config.services.length === 0 ? "<name>" : "\u2026"} ${raw}\` and re-apply.`
7602
+ `Service '${name}' is not configured in this container's yml. Configured services: ${list}. Or pass a port number (e.g. \`monoceros tunnel <name> 8080\`).`
6937
7603
  );
6938
7604
  }
6939
- return { kind: "service", service: raw, port: entry2.defaultPort };
7605
+ return match;
6940
7606
  }
6941
7607
  function resolveCompose2(args) {
6942
7608
  const network = `${composeProjectName(args.containerRoot)}_default`;
@@ -6945,7 +7611,7 @@ function resolveCompose2(args) {
6945
7611
  network,
6946
7612
  targetHost: args.parsedTarget.service,
6947
7613
  internalPort: args.parsedTarget.port,
6948
- display: `${args.name}/${args.parsedTarget.service}`
7614
+ display: `${args.name}/${args.parsedTarget.service}:${args.parsedTarget.port}`
6949
7615
  };
6950
7616
  }
6951
7617
  return {
@@ -7104,7 +7770,7 @@ function formatLocalPortHeldError(port, address, result) {
7104
7770
  // src/tunnel/run.ts
7105
7771
  var SOCAT_IMAGE = "alpine/socat:1.8.0.3";
7106
7772
  var defaultDockerSpawn = (args) => {
7107
- const child = spawn9("docker", args, {
7773
+ const child = spawn10("docker", args, {
7108
7774
  stdio: "inherit"
7109
7775
  });
7110
7776
  const exited = new Promise((resolve, reject) => {
@@ -7227,7 +7893,7 @@ var tunnelCommand = defineCommand29({
7227
7893
  },
7228
7894
  target: {
7229
7895
  type: "positional",
7230
- description: "Service name from the container yml (e.g. `postgres`) or an in-container port number (e.g. `8080`).",
7896
+ description: "Service name from the container yml (e.g. `postgres`), `service:port` for an explicit in-container port (e.g. `rustfs:9001`), or a bare in-container port number \u2192 workspace (e.g. `8080`).",
7231
7897
  required: true
7232
7898
  },
7233
7899
  "local-port": {