@getmonoceros/workbench 1.12.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/bin.js +1260 -515
  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,330 @@ 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 entries = Array.isArray(vars) ? vars.map((v) => [v, ""]) : Object.entries(vars);
746
+ const exists = existsSync2(envPath);
747
+ let content = exists ? readFileSync(envPath, "utf8") : buildEnvStub(name);
748
+ const present = new Set(Object.keys(parseEnvFile(content)));
749
+ const seen = /* @__PURE__ */ new Set();
750
+ const toAdd = entries.filter(([k]) => {
751
+ if (present.has(k) || seen.has(k)) return false;
752
+ seen.add(k);
753
+ return true;
754
+ });
755
+ const added = toAdd.map(([k]) => k);
756
+ if (!exists || added.length > 0) {
757
+ if (content.length > 0 && !content.endsWith("\n")) content += "\n";
758
+ for (const [k, v] of toAdd) content += `${k}=${v}
759
+ `;
760
+ await fsp.mkdir(path2.dirname(envPath), { recursive: true });
761
+ await fsp.writeFile(envPath, content);
762
+ }
763
+ return { created: !exists, added };
764
+ }
765
+ function formatMissingVarsError(missing, envPathPretty) {
766
+ const lines = missing.map((m) => ` - \${${m.name}} (${m.location})`);
767
+ const uniqueNames = [...new Set(missing.map((m) => m.name))];
768
+ return `Unresolved \${VAR} references in the container yml:
769
+ ${lines.join("\n")}
770
+
771
+ Define them in ${envPathPretty}, e.g.
772
+ ` + uniqueNames.map((n) => ` ${n}=<value>`).join("\n");
773
+ }
774
+
775
+ // src/init/feature-doc.ts
776
+ function buildFeatureHeaderLines(summary, width) {
777
+ const paragraphs = buildHeaderParagraphs(summary);
778
+ const wrapped = [];
779
+ for (const para of paragraphs) {
780
+ for (const line of wrapToComment(para, width)) {
781
+ wrapped.push(line);
782
+ }
783
+ }
784
+ return wrapped;
785
+ }
786
+ function buildFeatureHeaderCommentBefore(summary, width) {
787
+ const lines = buildFeatureHeaderLines(summary, width);
788
+ return lines.map((l) => ` ${l}`).join("\n");
789
+ }
790
+ function buildHeaderParagraphs(summary) {
791
+ if (!summary) return [];
792
+ const out = [];
793
+ const tagline = summary.name?.trim();
794
+ const description = summary.description?.trim();
795
+ if (tagline && description) {
796
+ out.push(`${tagline} \u2014 ${description}`);
797
+ } else if (tagline) {
798
+ out.push(tagline);
799
+ } else if (description) {
800
+ out.push(description);
801
+ }
802
+ for (const note of summary.usageNotes) {
803
+ const trimmed = note.trim();
804
+ if (trimmed.length > 0) out.push(trimmed);
805
+ }
806
+ if (summary.optionHints.length > 0) {
807
+ const parts = summary.optionHints.map((key) => {
808
+ const desc = summary.optionDescriptions[key];
809
+ const short = desc ? shortenOptionDescription(desc) : void 0;
810
+ return short ? `${key} (${short})` : key;
811
+ });
812
+ out.push(`Options: ${parts.join(", ")}.`);
813
+ }
814
+ if (summary.documentationURL) {
815
+ out.push(`See ${summary.documentationURL} for further information.`);
816
+ }
817
+ return out;
818
+ }
819
+ function shortenOptionDescription(desc) {
820
+ const firstSentence = desc.split(/(?<=[.!?])\s+/)[0]?.trim() ?? desc.trim();
821
+ return firstSentence.replace(/[.!?]+$/, "").trim();
822
+ }
823
+ function wrapToComment(text, width) {
824
+ const words = text.split(/\s+/).filter((w) => w.length > 0);
825
+ if (words.length === 0) return [""];
826
+ const usable = Math.max(width, 20);
827
+ const lines = [];
828
+ let current = "";
829
+ for (const w of words) {
830
+ if (current.length === 0) {
831
+ current = w;
832
+ continue;
833
+ }
834
+ if (current.length + 1 + w.length <= usable) {
835
+ current += " " + w;
836
+ } else {
837
+ lines.push(current);
838
+ current = w;
839
+ }
840
+ }
841
+ if (current.length > 0) lines.push(current);
842
+ return lines;
843
+ }
844
+ var FEATURE_HEADER_WIDTH = 76 - 2;
845
+ function featureOptionVarName(ref, optionKey) {
846
+ const leaf = ref.split("/").pop() ?? ref;
847
+ const id = leaf.split("@")[0].split(":")[0];
848
+ const idSnake = id.replace(/[^A-Za-z0-9]+/g, "_").toUpperCase();
849
+ const optSnake = optionKey.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").toUpperCase();
850
+ return `${idSnake}_${optSnake}`;
851
+ }
852
+ function featureOptionHints(summary, ref, activeKeys = []) {
853
+ return (summary?.optionHints ?? []).filter((key) => !activeKeys.includes(key)).map((key) => {
854
+ const envVar = featureOptionVarName(ref, key);
855
+ return { key, envVar, placeholder: `\${${envVar}}` };
856
+ });
857
+ }
858
+
859
+ // src/init/manifest.ts
860
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
861
+ import path3 from "path";
862
+
863
+ // src/util/ref.ts
864
+ var FEATURE_NAME_CHARSET = "[a-z0-9._-]+";
865
+ var FEATURE_TAG_CHARSET = "[a-z0-9._-]+";
866
+ var MONOCEROS_FEATURE_RE = new RegExp(
867
+ `^ghcr\\.io/getmonoceros/monoceros-features/(${FEATURE_NAME_CHARSET}):${FEATURE_TAG_CHARSET}$`
868
+ );
869
+ var DEPRECATED_MONOCEROS_FEATURE_RE = new RegExp(
870
+ `^ghcr\\.io/monoceros/features/(${FEATURE_NAME_CHARSET}):(${FEATURE_TAG_CHARSET})$`
871
+ );
872
+ function matchMonocerosFeature(ref) {
873
+ const match = MONOCEROS_FEATURE_RE.exec(ref);
874
+ if (!match) return null;
875
+ return { name: match[1] };
876
+ }
877
+ function migrateDeprecatedFeatureRef(ref) {
878
+ const match = DEPRECATED_MONOCEROS_FEATURE_RE.exec(ref);
879
+ if (!match) return null;
880
+ const name = match[1];
881
+ const tag = match[2];
882
+ return `ghcr.io/getmonoceros/monoceros-features/${name}:${tag}`;
883
+ }
884
+
885
+ // src/init/manifest.ts
886
+ function resolveManifestPath(name, checkoutRoot) {
887
+ if (checkoutRoot) {
888
+ const checkoutPath = path3.join(
889
+ checkoutRoot,
890
+ "images",
891
+ "features",
892
+ name,
893
+ "devcontainer-feature.json"
894
+ );
895
+ if (existsSync3(checkoutPath)) return checkoutPath;
896
+ }
897
+ const bundlePath = path3.join(
898
+ bundledFeaturesDir(),
899
+ name,
900
+ "devcontainer-feature.json"
901
+ );
902
+ if (existsSync3(bundlePath)) return bundlePath;
903
+ return null;
904
+ }
905
+ function loadFeatureManifestSummary(ref, checkoutRoot = workbenchCheckoutRoot()) {
906
+ const match = matchMonocerosFeature(ref);
907
+ if (!match) return void 0;
908
+ const manifestPath = resolveManifestPath(match.name, checkoutRoot);
909
+ if (!manifestPath) return void 0;
910
+ try {
911
+ const text = readFileSync2(manifestPath, "utf8");
912
+ const parsed = JSON.parse(text);
913
+ const rawHints = parsed["x-monoceros"]?.optionHints;
914
+ const optionHints = Array.isArray(rawHints) ? rawHints.filter(
915
+ (x) => typeof x === "string" && x.length > 0
916
+ ) : [];
917
+ const rawNotes = parsed["x-monoceros"]?.usageNotes;
918
+ const usageNotes = Array.isArray(rawNotes) ? rawNotes.filter(
919
+ (x) => typeof x === "string" && x.length > 0
920
+ ) : [];
921
+ const optionDescriptions = {};
922
+ const optionTypes = {};
923
+ const optionNames = [];
924
+ if (parsed.options) {
925
+ for (const [key, opt] of Object.entries(parsed.options)) {
926
+ if (!opt || typeof opt !== "object") continue;
927
+ optionNames.push(key);
928
+ if (typeof opt.description === "string" && opt.description.length > 0) {
929
+ optionDescriptions[key] = opt.description;
930
+ }
931
+ if (opt.type === "boolean") {
932
+ optionTypes[key] = "boolean";
933
+ } else if (opt.type === "string") {
934
+ optionTypes[key] = "string";
935
+ }
936
+ }
937
+ }
938
+ const name = typeof parsed.name === "string" ? parsed.name : "";
939
+ const description = typeof parsed.description === "string" ? parsed.description : "";
940
+ const rawUrl = typeof parsed.documentationURL === "string" ? parsed.documentationURL.trim() : "";
941
+ const documentationURL = rawUrl.length > 0 && rawUrl.toLowerCase() !== "tbd" ? rawUrl : void 0;
942
+ return {
943
+ name,
944
+ description,
945
+ documentationURL,
946
+ optionHints,
947
+ optionDescriptions,
948
+ optionNames,
949
+ optionTypes,
950
+ usageNotes
951
+ };
952
+ } catch {
953
+ return void 0;
954
+ }
955
+ }
956
+
572
957
  // src/devcontainer/credentials.ts
573
958
  import { spawn } from "child_process";
574
959
  import { promises as fs2 } from "fs";
575
- import path2 from "path";
960
+ import path4 from "path";
576
961
 
577
962
  // src/util/format.ts
578
963
  var ESC = "\x1B[";
@@ -788,8 +1173,8 @@ function formatCredentialLine(host, username, password) {
788
1173
  return `https://${encUser}:${encPass}@${host}`;
789
1174
  }
790
1175
  async function collectGitCredentials(devContainerRoot, hosts, options = {}) {
791
- const credsDir = path2.join(devContainerRoot, ".monoceros");
792
- const credentialsPath = path2.join(credsDir, "git-credentials");
1176
+ const credsDir = path4.join(devContainerRoot, ".monoceros");
1177
+ const credentialsPath = path4.join(credsDir, "git-credentials");
793
1178
  const spawnFn = options.spawn ?? realGitCredentialFill;
794
1179
  const approveFn = options.approve ?? realGitCredentialApprove;
795
1180
  const logger = options.logger ?? { info: () => {
@@ -1186,8 +1571,8 @@ ${existing}` : leakedComment;
1186
1571
  }
1187
1572
 
1188
1573
  // src/init/components.ts
1189
- import { existsSync as existsSync2, promises as fs4 } from "fs";
1190
- import path3 from "path";
1574
+ import { existsSync as existsSync4, promises as fs4 } from "fs";
1575
+ import path5 from "path";
1191
1576
  import { z as z3 } from "zod";
1192
1577
  import { parse as parseYaml } from "yaml";
1193
1578
  var CategorySchema = z3.enum(["language", "service", "feature"]);
@@ -1234,7 +1619,7 @@ var ComponentFileSchema = z3.object({
1234
1619
  }
1235
1620
  });
1236
1621
  async function loadComponentCatalog(rootDir = componentsDir()) {
1237
- if (!existsSync2(rootDir)) {
1622
+ if (!existsSync4(rootDir)) {
1238
1623
  return /* @__PURE__ */ new Map();
1239
1624
  }
1240
1625
  const out = /* @__PURE__ */ new Map();
@@ -1244,14 +1629,14 @@ async function loadComponentCatalog(rootDir = componentsDir()) {
1244
1629
  async function walk(baseDir, currentDir, out) {
1245
1630
  const entries = await fs4.readdir(currentDir, { withFileTypes: true });
1246
1631
  for (const entry2 of entries) {
1247
- const full = path3.join(currentDir, entry2.name);
1632
+ const full = path5.join(currentDir, entry2.name);
1248
1633
  if (entry2.isDirectory()) {
1249
1634
  await walk(baseDir, full, out);
1250
1635
  continue;
1251
1636
  }
1252
1637
  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("/");
1638
+ const relative = path5.relative(baseDir, full);
1639
+ const name = relative.replace(/\.yml$/, "").split(path5.sep).join("/");
1255
1640
  const text = await fs4.readFile(full, "utf8");
1256
1641
  let raw;
1257
1642
  try {
@@ -1273,42 +1658,6 @@ ${issues}`);
1273
1658
  out.set(name, { name, sourcePath: full, file: parsed.data });
1274
1659
  }
1275
1660
  }
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
1661
  function mergeFeatureOptions(a, b) {
1313
1662
  const result = { ...a };
1314
1663
  for (const [key, valueB] of Object.entries(b)) {
@@ -1321,39 +1670,11 @@ function mergeFeatureOptions(a, b) {
1321
1670
  }
1322
1671
  return result;
1323
1672
  }
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
1673
 
1353
1674
  // src/proxy/index.ts
1354
1675
  import { spawn as spawn3 } from "child_process";
1355
1676
  import { promises as fs5 } from "fs";
1356
- import path4 from "path";
1677
+ import path6 from "path";
1357
1678
  var PROXY_CONTAINER_NAME = "monoceros-proxy";
1358
1679
  var PROXY_NETWORK_NAME = "monoceros-proxy";
1359
1680
  var TRAEFIK_IMAGE = "traefik:v3.3";
@@ -1379,7 +1700,7 @@ var defaultDockerExec = (args) => {
1379
1700
  };
1380
1701
  var realDocker = defaultDockerExec;
1381
1702
  function proxyDynamicDir(home) {
1382
- return path4.join(home ?? monocerosHome(), "traefik", "dynamic");
1703
+ return path6.join(home ?? monocerosHome(), "traefik", "dynamic");
1383
1704
  }
1384
1705
  async function ensureProxy(opts = {}) {
1385
1706
  const docker = opts.docker ?? realDocker;
@@ -1471,7 +1792,7 @@ async function maybeStopProxy(opts = {}) {
1471
1792
 
1472
1793
  // src/proxy/dynamic.ts
1473
1794
  import { promises as fs6 } from "fs";
1474
- import path5 from "path";
1795
+ import path7 from "path";
1475
1796
  async function writeDynamicConfig(name, ports, opts = {}) {
1476
1797
  if (ports.length === 0) {
1477
1798
  throw new Error(
@@ -1480,12 +1801,12 @@ async function writeDynamicConfig(name, ports, opts = {}) {
1480
1801
  }
1481
1802
  const dir = proxyDynamicDir(opts.monocerosHome);
1482
1803
  await fs6.mkdir(dir, { recursive: true });
1483
- const file = path5.join(dir, `${name}.yml`);
1804
+ const file = path7.join(dir, `${name}.yml`);
1484
1805
  await fs6.writeFile(file, renderDynamicConfig(name, ports), "utf8");
1485
1806
  return file;
1486
1807
  }
1487
1808
  async function removeDynamicConfig(name, opts = {}) {
1488
- const file = path5.join(proxyDynamicDir(opts.monocerosHome), `${name}.yml`);
1809
+ const file = path7.join(proxyDynamicDir(opts.monocerosHome), `${name}.yml`);
1489
1810
  await fs6.rm(file, { force: true });
1490
1811
  }
1491
1812
  function renderDynamicConfig(name, ports) {
@@ -1658,6 +1979,19 @@ var SERVICE_CATALOG = {
1658
1979
  POSTGRES_PASSWORD: "monoceros",
1659
1980
  POSTGRES_DB: "monoceros"
1660
1981
  },
1982
+ healthcheck: {
1983
+ test: [
1984
+ "CMD",
1985
+ "pg_isready",
1986
+ "-U",
1987
+ "${POSTGRES_USER}",
1988
+ "-d",
1989
+ "${POSTGRES_DB}"
1990
+ ],
1991
+ interval: "10s",
1992
+ timeout: "5s",
1993
+ retries: 5
1994
+ },
1661
1995
  // Postgres 18+ stores data under /var/lib/postgresql/<major>/, so
1662
1996
  // the recommended mount is the parent directory; pre-18 used
1663
1997
  // /var/lib/postgresql/data directly. See
@@ -1672,12 +2006,33 @@ var SERVICE_CATALOG = {
1672
2006
  MYSQL_ROOT_PASSWORD: "monoceros",
1673
2007
  MYSQL_DATABASE: "monoceros"
1674
2008
  },
2009
+ healthcheck: {
2010
+ test: [
2011
+ "CMD",
2012
+ "mysqladmin",
2013
+ "ping",
2014
+ "-h",
2015
+ "127.0.0.1",
2016
+ "-u",
2017
+ "root",
2018
+ "-p${MYSQL_ROOT_PASSWORD}"
2019
+ ],
2020
+ interval: "10s",
2021
+ timeout: "5s",
2022
+ retries: 5
2023
+ },
1675
2024
  dataMount: "/var/lib/mysql",
1676
2025
  defaultPort: 3306
1677
2026
  },
1678
2027
  redis: {
1679
2028
  id: "redis",
1680
2029
  image: "redis:8",
2030
+ healthcheck: {
2031
+ test: ["CMD", "redis-cli", "ping"],
2032
+ interval: "10s",
2033
+ timeout: "5s",
2034
+ retries: 5
2035
+ },
1681
2036
  dataMount: "/data",
1682
2037
  defaultPort: 6379
1683
2038
  }
@@ -1688,34 +2043,107 @@ function knownLanguages() {
1688
2043
  function knownServices() {
1689
2044
  return Object.keys(SERVICE_CATALOG).sort();
1690
2045
  }
2046
+ function resolveService(entry2) {
2047
+ return {
2048
+ name: entry2.name,
2049
+ image: entry2.image,
2050
+ ...entry2.port !== void 0 ? { port: entry2.port } : {},
2051
+ env: entry2.env ? { ...entry2.env } : {},
2052
+ volumes: entry2.volumes ? [...entry2.volumes] : [],
2053
+ ...entry2.healthcheck ? { healthcheck: entry2.healthcheck } : {},
2054
+ ...entry2.restart ? { restart: entry2.restart } : {},
2055
+ ...entry2.command ? { command: entry2.command } : {}
2056
+ };
2057
+ }
2058
+ function isCuratedService(name) {
2059
+ return Object.prototype.hasOwnProperty.call(SERVICE_CATALOG, name);
2060
+ }
2061
+ function expandCuratedService(name) {
2062
+ const def = SERVICE_CATALOG[name];
2063
+ if (!def) {
2064
+ throw new Error(
2065
+ `Unknown service '${name}'. Known catalog services: ${knownServices().join(", ")}.`
2066
+ );
2067
+ }
2068
+ return {
2069
+ name: def.id,
2070
+ image: def.image,
2071
+ port: def.defaultPort,
2072
+ ...def.env ? {
2073
+ env: Object.fromEntries(
2074
+ Object.keys(def.env).map((k) => [k, `\${${k}}`])
2075
+ )
2076
+ } : {},
2077
+ ...def.dataMount ? { volumes: [`data:${def.dataMount}`] } : {},
2078
+ ...def.healthcheck ? { healthcheck: def.healthcheck } : {},
2079
+ restart: "unless-stopped"
2080
+ };
2081
+ }
2082
+ function curatedServiceEnvDefaults(name) {
2083
+ const def = SERVICE_CATALOG[name];
2084
+ return def?.env ? { ...def.env } : {};
2085
+ }
2086
+ function deriveServiceName(image) {
2087
+ const lastSegment = image.split("/").pop() ?? image;
2088
+ const noTag = lastSegment.split("@")[0].split(":")[0];
2089
+ return noTag.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
2090
+ }
1691
2091
 
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] };
2092
+ // src/init/service-doc.ts
2093
+ function renderServiceObjectBody(svc) {
2094
+ const lines = [`name: ${svc.name}`, `image: ${svc.image}`];
2095
+ if (svc.port !== void 0) lines.push(`port: ${svc.port}`);
2096
+ if (svc.env && Object.keys(svc.env).length > 0) {
2097
+ lines.push("env:");
2098
+ for (const [k, v] of Object.entries(svc.env)) {
2099
+ lines.push(` ${k}: ${v}`);
2100
+ }
2101
+ }
2102
+ if (svc.volumes && svc.volumes.length > 0) {
2103
+ lines.push("volumes:");
2104
+ for (const vol of svc.volumes) lines.push(` - ${vol}`);
2105
+ }
2106
+ if (svc.restart) lines.push(`restart: ${svc.restart}`);
2107
+ if (svc.command !== void 0) lines.push(`command: ${svc.command}`);
2108
+ if (svc.healthcheck) {
2109
+ lines.push("healthcheck:");
2110
+ const test = svc.healthcheck.test;
2111
+ lines.push(
2112
+ Array.isArray(test) ? ` test: [${test.map((t) => JSON.stringify(t)).join(", ")}]` : ` test: ${test}`
2113
+ );
2114
+ if (svc.healthcheck.interval)
2115
+ lines.push(` interval: ${svc.healthcheck.interval}`);
2116
+ if (svc.healthcheck.timeout)
2117
+ lines.push(` timeout: ${svc.healthcheck.timeout}`);
2118
+ if (svc.healthcheck.retries !== void 0)
2119
+ lines.push(` retries: ${svc.healthcheck.retries}`);
2120
+ if (svc.healthcheck.startPeriod)
2121
+ lines.push(` startPeriod: ${svc.healthcheck.startPeriod}`);
2122
+ }
2123
+ return lines;
1709
2124
  }
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}`;
2125
+ function renderCustomService(name, image) {
2126
+ const bodyLines = [`name: ${name}`, `image: ${image}`];
2127
+ const comment = [
2128
+ " port: 8080 # in-container port \u2192 `monoceros tunnel`",
2129
+ " env: # values resolved from <name>.env",
2130
+ " KEY: ${SOME_VAR}",
2131
+ " volumes:",
2132
+ ` - data:/data # persistent host bind-mount under data/${name}`,
2133
+ " - rel/host/path:/in/container:ro",
2134
+ " healthcheck:",
2135
+ " test: curl -f http://localhost:8080/health",
2136
+ " restart: unless-stopped"
2137
+ ].join("\n");
2138
+ return { bodyLines, comment };
2139
+ }
2140
+ function customServiceHint(name) {
2141
+ 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
2142
  }
1717
2143
 
1718
2144
  // src/create/scaffold.ts
2145
+ import { existsSync as existsSync5, readFileSync as readFileSync3, promises as fs7 } from "fs";
2146
+ import path8 from "path";
1719
2147
  var APT_PACKAGE_NAME_RE2 = /^[a-z0-9][a-z0-9.+-]*$/;
1720
2148
  var FEATURE_REF_RE2 = /^[a-z0-9.-]+(\/[a-z0-9._-]+)+:[a-z0-9._-]+$/;
1721
2149
  var INSTALL_URL_RE2 = /^https:\/\/[A-Za-z0-9.\-_~/:?#[\]@!&'()*+,;=%]+$/;
@@ -1745,12 +2173,24 @@ function validateOptions(opts) {
1745
2173
  );
1746
2174
  }
1747
2175
  }
2176
+ const seenServiceNames = /* @__PURE__ */ new Set();
1748
2177
  for (const svc of opts.services) {
1749
- if (!SERVICE_CATALOG[svc]) {
2178
+ if (!svc.image) {
1750
2179
  throw new Error(
1751
- `Unknown service: ${svc}. Known: ${knownServices().join(", ")}.`
2180
+ `Service ${JSON.stringify(svc.name)} has no image. Every service needs an 'image:'.`
1752
2181
  );
1753
2182
  }
2183
+ if (svc.name === "workspace") {
2184
+ throw new Error(
2185
+ `Invalid service name 'workspace': it collides with the reserved devcontainer workspace service. Pick another name.`
2186
+ );
2187
+ }
2188
+ if (seenServiceNames.has(svc.name)) {
2189
+ throw new Error(
2190
+ `Duplicate service name: ${JSON.stringify(svc.name)}. Each services[] entry must have a unique name.`
2191
+ );
2192
+ }
2193
+ seenServiceNames.add(svc.name);
1754
2194
  }
1755
2195
  for (const pkg of opts.aptPackages ?? []) {
1756
2196
  if (!APT_PACKAGE_NAME_RE2.test(pkg)) {
@@ -1800,10 +2240,14 @@ function validateOptions(opts) {
1800
2240
  }
1801
2241
  function normalizeOptions(opts) {
1802
2242
  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");
2243
+ const serviceByName = /* @__PURE__ */ new Map();
2244
+ for (const svc of opts.services) {
2245
+ if (opts.postgresUrl && svc.name === "postgres") continue;
2246
+ serviceByName.set(svc.name, svc);
1806
2247
  }
2248
+ const services = [...serviceByName.values()].sort(
2249
+ (a, b) => a.name.localeCompare(b.name)
2250
+ );
1807
2251
  const aptPackages = [...new Set(opts.aptPackages ?? [])].sort();
1808
2252
  const features = opts.features ? Object.fromEntries(
1809
2253
  Object.entries(opts.features).sort(([a], [b]) => a.localeCompare(b))
@@ -1862,8 +2306,8 @@ function resolveFeatures(opts) {
1862
2306
  if (match) {
1863
2307
  const name = match.name;
1864
2308
  const checkout = workbenchCheckoutRoot();
1865
- const localSourceDir = checkout ? path6.join(checkout, "images", "features", name) : null;
1866
- if (localSourceDir && existsSync3(localSourceDir)) {
2309
+ const localSourceDir = checkout ? path8.join(checkout, "images", "features", name) : null;
2310
+ if (localSourceDir && existsSync5(localSourceDir)) {
1867
2311
  const { paths, files } = readPersistentHomeEntries(localSourceDir);
1868
2312
  resolved.push({
1869
2313
  devcontainerKey: `./features/${name}`,
@@ -1887,9 +2331,9 @@ function resolveFeatures(opts) {
1887
2331
  return resolved;
1888
2332
  }
1889
2333
  function readPersistentHomeEntries(localSourceDir) {
1890
- const manifestPath = path6.join(localSourceDir, "devcontainer-feature.json");
2334
+ const manifestPath = path8.join(localSourceDir, "devcontainer-feature.json");
1891
2335
  try {
1892
- const text = readFileSync(manifestPath, "utf8");
2336
+ const text = readFileSync3(manifestPath, "utf8");
1893
2337
  const parsed = JSON.parse(text);
1894
2338
  return {
1895
2339
  paths: filterSubpaths(parsed["x-monoceros"]?.persistentHomePaths),
@@ -1964,7 +2408,7 @@ function buildDevcontainerJson(opts, dockerMode = "rootful") {
1964
2408
  name: opts.name,
1965
2409
  dockerComposeFile: "compose.yaml",
1966
2410
  service: "workspace",
1967
- ...opts.services.length > 0 ? { runServices: opts.services } : {},
2411
+ ...opts.services.length > 0 ? { runServices: opts.services.map((s) => s.name) } : {},
1968
2412
  workspaceFolder: `/workspaces/${opts.name}`,
1969
2413
  remoteUser: "node",
1970
2414
  forwardPorts: ports,
@@ -1997,6 +2441,18 @@ function buildDevcontainerJson(opts, dockerMode = "rootful") {
1997
2441
  ...customizationsField ?? {}
1998
2442
  };
1999
2443
  }
2444
+ function composeScalar(value) {
2445
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t");
2446
+ return `"${escaped}"`;
2447
+ }
2448
+ function composeVolumeSource(spec, serviceName) {
2449
+ const parts = spec.split(":");
2450
+ const src = parts[0];
2451
+ const rest = parts.slice(1).join(":");
2452
+ if (src === "data") return `../data/${serviceName}:${rest}`;
2453
+ const relative = src.startsWith("./") ? src.slice(2) : src;
2454
+ return `../${relative}:${rest}`;
2455
+ }
2000
2456
  function buildComposeYaml(opts, dockerMode = "rootful") {
2001
2457
  void dockerMode;
2002
2458
  const hasPorts = (opts.ports?.length ?? 0) > 0;
@@ -2025,20 +2481,42 @@ function buildComposeYaml(opts, dockerMode = "rootful") {
2025
2481
  lines.push(` - ../home/${sub}:/home/node/${sub}`);
2026
2482
  }
2027
2483
  }
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) {
2484
+ for (const svc of opts.services) {
2485
+ lines.push(` ${svc.name}:`);
2486
+ lines.push(` image: ${svc.image}`);
2487
+ if (svc.restart) {
2488
+ lines.push(` restart: ${svc.restart}`);
2489
+ }
2490
+ if (svc.command !== void 0) {
2491
+ lines.push(` command: ${composeScalar(svc.command)}`);
2492
+ }
2493
+ const envKeys = Object.keys(svc.env);
2494
+ if (envKeys.length > 0) {
2034
2495
  lines.push(" environment:");
2035
- for (const [k, v] of Object.entries(def.env)) {
2036
- lines.push(` ${k}: ${v}`);
2496
+ for (const k of envKeys) {
2497
+ lines.push(` ${k}: ${composeScalar(svc.env[k])}`);
2037
2498
  }
2038
2499
  }
2039
- if (def.dataMount) {
2500
+ if (svc.volumes.length > 0) {
2040
2501
  lines.push(" volumes:");
2041
- lines.push(` - ../data/${def.id}:${def.dataMount}`);
2502
+ for (const vol of svc.volumes) {
2503
+ lines.push(` - ${composeVolumeSource(vol, svc.name)}`);
2504
+ }
2505
+ }
2506
+ if (svc.healthcheck) {
2507
+ const hc = svc.healthcheck;
2508
+ lines.push(" healthcheck:");
2509
+ if (Array.isArray(hc.test)) {
2510
+ lines.push(` test: [${hc.test.map(composeScalar).join(", ")}]`);
2511
+ } else {
2512
+ lines.push(` test: ${composeScalar(hc.test)}`);
2513
+ }
2514
+ if (hc.interval) lines.push(` interval: ${hc.interval}`);
2515
+ if (hc.timeout) lines.push(` timeout: ${hc.timeout}`);
2516
+ if (hc.retries !== void 0) lines.push(` retries: ${hc.retries}`);
2517
+ if (hc.startPeriod) {
2518
+ lines.push(` start_period: ${hc.startPeriod}`);
2519
+ }
2042
2520
  }
2043
2521
  }
2044
2522
  if (hasPorts) {
@@ -2172,245 +2650,99 @@ function buildPostCreateScript(opts) {
2172
2650
  return lines.join("\n") + "\n";
2173
2651
  }
2174
2652
  async function writePostCreateScript(devcontainerDir, opts) {
2175
- const dest = path6.join(devcontainerDir, "post-create.sh");
2653
+ const dest = path8.join(devcontainerDir, "post-create.sh");
2176
2654
  await fs7.writeFile(dest, buildPostCreateScript(opts));
2177
2655
  await fs7.chmod(dest, 493);
2178
2656
  }
2179
2657
  async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2180
2658
  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");
2659
+ const devcontainerDir = path8.join(targetDir, ".devcontainer");
2660
+ const monocerosDir = path8.join(targetDir, ".monoceros");
2661
+ const projectsDir = path8.join(targetDir, "projects");
2662
+ const homeDir = path8.join(targetDir, "home");
2663
+ const dataDir = path8.join(targetDir, "data");
2186
2664
  await fs7.mkdir(devcontainerDir, { recursive: true });
2187
2665
  await fs7.mkdir(monocerosDir, { recursive: true });
2188
2666
  await fs7.mkdir(projectsDir, { recursive: true });
2189
2667
  await fs7.mkdir(homeDir, { recursive: true });
2190
2668
  if (needsCompose(opts)) {
2191
2669
  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 });
2670
+ for (const svc of opts.services) {
2671
+ const hasDataVolume = svc.volumes.some((v) => v.split(":")[0] === "data");
2672
+ if (hasDataVolume) {
2673
+ await fs7.mkdir(path8.join(dataDir, svc.name), { recursive: true });
2196
2674
  }
2197
2675
  }
2198
2676
  }
2199
- const containerGitignore = path6.join(targetDir, ".gitignore");
2677
+ const containerGitignore = path8.join(targetDir, ".gitignore");
2200
2678
  await fs7.writeFile(containerGitignore, "/home/\n/.monoceros/\n/data/\n");
2201
- const gitkeep = path6.join(projectsDir, ".gitkeep");
2202
- if (!existsSync3(gitkeep)) {
2679
+ const gitkeep = path8.join(projectsDir, ".gitkeep");
2680
+ if (!existsSync5(gitkeep)) {
2203
2681
  await fs7.writeFile(gitkeep, "");
2204
2682
  }
2205
2683
  await fs7.writeFile(
2206
- path6.join(monocerosDir, ".gitignore"),
2684
+ path8.join(monocerosDir, ".gitignore"),
2207
2685
  "git-credentials*\ngitconfig\n"
2208
2686
  );
2209
2687
  const devcontainerJson = buildDevcontainerJson(opts, dockerMode);
2210
2688
  await fs7.writeFile(
2211
- path6.join(devcontainerDir, "devcontainer.json"),
2689
+ path8.join(devcontainerDir, "devcontainer.json"),
2212
2690
  JSON.stringify(devcontainerJson, null, 2) + "\n"
2213
2691
  );
2214
- const featuresDir = path6.join(devcontainerDir, "features");
2215
- if (existsSync3(featuresDir)) {
2692
+ const featuresDir = path8.join(devcontainerDir, "features");
2693
+ if (existsSync5(featuresDir)) {
2216
2694
  await fs7.rm(featuresDir, { recursive: true, force: true });
2217
2695
  }
2218
2696
  const resolvedFeatures = resolveFeatures(opts);
2219
2697
  for (const f of resolvedFeatures) {
2220
2698
  if (!f.localSourceDir || !f.localName) continue;
2221
- const dest = path6.join(featuresDir, f.localName);
2699
+ const dest = path8.join(featuresDir, f.localName);
2222
2700
  await fs7.mkdir(dest, { recursive: true });
2223
2701
  await fs7.cp(f.localSourceDir, dest, { recursive: true });
2224
2702
  }
2225
2703
  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;
2400
- }
2401
- if (current.length + 1 + w.length <= usable) {
2402
- current += " " + w;
2403
- } else {
2404
- lines.push(current);
2405
- current = w;
2704
+ for (const sub of f.persistentHomePaths) {
2705
+ await fs7.mkdir(path8.join(homeDir, sub), { recursive: true });
2706
+ }
2707
+ for (const entry2 of f.persistentHomeFiles) {
2708
+ const filePath = path8.join(homeDir, entry2.path);
2709
+ await fs7.mkdir(path8.dirname(filePath), { recursive: true });
2710
+ if (!existsSync5(filePath)) {
2711
+ await fs7.writeFile(filePath, entry2.initialContent);
2712
+ }
2406
2713
  }
2407
2714
  }
2408
- if (current.length > 0) lines.push(current);
2409
- return lines;
2715
+ await writePostCreateScript(devcontainerDir, opts);
2716
+ const composePath = path8.join(devcontainerDir, "compose.yaml");
2717
+ if (needsCompose(opts)) {
2718
+ await fs7.writeFile(composePath, buildComposeYaml(opts, dockerMode));
2719
+ } else if (existsSync5(composePath)) {
2720
+ await fs7.rm(composePath);
2721
+ }
2722
+ const workspacePath = path8.join(targetDir, `${opts.name}.code-workspace`);
2723
+ let existingWorkspace;
2724
+ try {
2725
+ const raw = await fs7.readFile(workspacePath, "utf8");
2726
+ existingWorkspace = JSON.parse(raw);
2727
+ } catch {
2728
+ existingWorkspace = void 0;
2729
+ }
2730
+ const generated = buildCodeWorkspaceJson(opts);
2731
+ const merged = mergeCodeWorkspace(existingWorkspace, generated);
2732
+ await fs7.writeFile(workspacePath, JSON.stringify(merged, null, 2) + "\n");
2410
2733
  }
2411
- var FEATURE_HEADER_WIDTH = 76 - 2;
2412
2734
 
2413
2735
  // src/modify/yml.ts
2736
+ import {
2737
+ isMap as isMap2,
2738
+ isScalar,
2739
+ isSeq,
2740
+ Pair as Pair2,
2741
+ parseDocument as parseDocument3,
2742
+ Scalar as Scalar2,
2743
+ YAMLMap as YAMLMap2,
2744
+ YAMLSeq
2745
+ } from "yaml";
2414
2746
  function ensureSeq(doc, key) {
2415
2747
  const existing = doc.get(key, true);
2416
2748
  if (existing && isSeq(existing)) return existing;
@@ -2433,11 +2765,24 @@ function addLanguageToDoc(doc, lang) {
2433
2765
  seq.add(lang);
2434
2766
  return true;
2435
2767
  }
2436
- function addServiceToDoc(doc, service) {
2768
+ function findServiceItem(seq, name) {
2769
+ for (const item of seq.items) {
2770
+ if (isMap2(item) && item.get("name") === name) return item;
2771
+ }
2772
+ return void 0;
2773
+ }
2774
+ function addServiceEntryToDoc(doc, name, image, bodyLines, scaffoldComment) {
2437
2775
  const seq = ensureSeq(doc, "services");
2438
- if (seq.items.some((i) => scalarValue(i) === service)) return false;
2439
- seq.add(service);
2440
- return true;
2776
+ const existing = findServiceItem(seq, name);
2777
+ if (existing) {
2778
+ const existingImage = existing.get("image");
2779
+ if (existingImage === image) return { outcome: "exists" };
2780
+ return { outcome: "conflict", existingImage: String(existingImage) };
2781
+ }
2782
+ const node = parseDocument3(bodyLines.join("\n")).contents;
2783
+ if (scaffoldComment) node.comment = scaffoldComment;
2784
+ seq.add(node);
2785
+ return { outcome: "added" };
2441
2786
  }
2442
2787
  function addAptPackagesToDoc(doc, packages) {
2443
2788
  const seq = ensureSeq(doc, "aptPackages");
@@ -2657,6 +3002,12 @@ function addFeatureToDoc(doc, ref, options = {}, displayName) {
2657
3002
  entry2.commentBefore = headerBefore;
2658
3003
  entry2.spaceBefore = true;
2659
3004
  }
3005
+ const hints = featureOptionHints(summary, ref, Object.keys(options));
3006
+ if (hints.length > 0) {
3007
+ const commentLines = [" options:"];
3008
+ for (const h of hints) commentLines.push(` ${h.key}: ${h.placeholder}`);
3009
+ entry2.comment = commentLines.join("\n");
3010
+ }
2660
3011
  seq.add(entry2);
2661
3012
  return true;
2662
3013
  }
@@ -2733,7 +3084,15 @@ function removeLanguageFromDoc(doc, lang) {
2733
3084
  return removeScalarFromSeq(doc, "languages", lang);
2734
3085
  }
2735
3086
  function removeServiceFromDoc(doc, service) {
2736
- return removeScalarFromSeq(doc, "services", service);
3087
+ const node = doc.get("services", true);
3088
+ if (!node || !isSeq(node)) return false;
3089
+ const idx = node.items.findIndex(
3090
+ (i) => isMap2(i) && i.get("name") === service
3091
+ );
3092
+ if (idx === -1) return false;
3093
+ node.items.splice(idx, 1);
3094
+ pruneEmptySeq(doc, "services");
3095
+ return true;
2737
3096
  }
2738
3097
  function removeAptPackageFromDoc(doc, pkg) {
2739
3098
  return removeScalarFromSeq(doc, "aptPackages", pkg);
@@ -2773,8 +3132,8 @@ function removeRepoFromDoc(doc, urlOrPath) {
2773
3132
  if (!isMap2(item)) return false;
2774
3133
  const url = item.get("url");
2775
3134
  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;
3135
+ const path21 = item.get("path");
3136
+ const effectivePath = typeof path21 === "string" ? path21 : typeof url === "string" ? deriveRepoName(url) : void 0;
2778
3137
  return effectivePath === urlOrPath;
2779
3138
  });
2780
3139
  if (idx < 0) return false;
@@ -2801,13 +3160,56 @@ function runAddLanguage(input) {
2801
3160
  }
2802
3161
  return mutate(input, (doc) => addLanguageToDoc(doc, input.language));
2803
3162
  }
2804
- function runAddService(input) {
2805
- if (!SERVICE_CATALOG[input.service]) {
3163
+ async function runAddService(input) {
3164
+ const arg = input.service;
3165
+ const curated = isCuratedService(arg);
3166
+ if (input.as !== void 0 && !/^[a-z0-9][a-z0-9_-]*$/.test(input.as)) {
2806
3167
  throw new Error(
2807
- `Unknown service: ${input.service}. Known: ${knownServices().join(", ")}.`
3168
+ `Invalid --as name ${JSON.stringify(input.as)}. Use lowercase letters, digits, '_' or '-' (must start with a letter or digit).`
3169
+ );
3170
+ }
3171
+ const name = input.as ?? (curated ? arg : deriveServiceName(arg));
3172
+ const image = curated ? expandCuratedService(arg).image : arg;
3173
+ const custom = curated ? null : renderCustomService(name, arg);
3174
+ const bodyLines = curated ? renderServiceObjectBody({ ...expandCuratedService(arg), name }) : custom.bodyLines;
3175
+ const scaffoldComment = curated ? void 0 : custom.comment;
3176
+ const result = await mutate(input, (doc) => {
3177
+ const r = addServiceEntryToDoc(
3178
+ doc,
3179
+ name,
3180
+ image,
3181
+ bodyLines,
3182
+ scaffoldComment
2808
3183
  );
3184
+ if (r.outcome === "conflict") {
3185
+ throw new Error(
3186
+ `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}\`).`
3187
+ );
3188
+ }
3189
+ return r.outcome === "added";
3190
+ });
3191
+ if (result.status === "updated") {
3192
+ if (curated) {
3193
+ const defaults = curatedServiceEnvDefaults(arg);
3194
+ if (Object.keys(defaults).length > 0) {
3195
+ const home = input.monocerosHome ?? monocerosHome();
3196
+ await ensureEnvGitignored(containerConfigsDir(home));
3197
+ const seeded = await ensureEnvVars(
3198
+ containerEnvPath(input.name, home),
3199
+ input.name,
3200
+ defaults
3201
+ );
3202
+ if (seeded.added.length > 0) {
3203
+ (input.logger ?? defaultLogger()).info(
3204
+ `Seeded ${seeded.added.join(", ")} into ${input.name}.env (dev-defaults \u2014 change them there if needed).`
3205
+ );
3206
+ }
3207
+ }
3208
+ } else {
3209
+ (input.logger ?? defaultLogger()).info(customServiceHint(name));
3210
+ }
2809
3211
  }
2810
- return mutate(input, (doc) => addServiceToDoc(doc, input.service));
3212
+ return result;
2811
3213
  }
2812
3214
  function runAddAptPackages(input) {
2813
3215
  if (input.packages.length === 0) {
@@ -2824,7 +3226,7 @@ async function runAddRepo(input) {
2824
3226
  "Missing repo URL. Usage: monoceros add-repo <containername> <url>."
2825
3227
  );
2826
3228
  }
2827
- const path18 = (input.path ?? deriveRepoName(url)).trim();
3229
+ const path21 = (input.path ?? deriveRepoName(url)).trim();
2828
3230
  const hasName = typeof input.gitName === "string" && input.gitName.trim().length > 0;
2829
3231
  const hasEmail = typeof input.gitEmail === "string" && input.gitEmail.trim().length > 0;
2830
3232
  if (hasName !== hasEmail) {
@@ -2853,7 +3255,7 @@ async function runAddRepo(input) {
2853
3255
  const providerToWrite = !canonical && explicitProvider ? explicitProvider : void 0;
2854
3256
  const entry2 = {
2855
3257
  url,
2856
- path: path18,
3258
+ path: path21,
2857
3259
  ...hasName && hasEmail ? {
2858
3260
  gitUser: {
2859
3261
  name: input.gitName.trim(),
@@ -2969,7 +3371,7 @@ async function tryCloneInRunningContainer(input, entry2) {
2969
3371
  logger.info(
2970
3372
  `Cloned ${entry2.url} into /workspaces/${containerName2}/${targetRel} inside the running container.`
2971
3373
  );
2972
- void path8;
3374
+ void path9;
2973
3375
  }
2974
3376
  function shquote(value) {
2975
3377
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -3046,10 +3448,33 @@ async function runAddFeature(input) {
3046
3448
  ...resolved.defaultOptions,
3047
3449
  ...input.options ?? {}
3048
3450
  };
3049
- return mutate(
3451
+ const result = await mutate(
3050
3452
  input,
3051
3453
  (doc) => addFeatureToDoc(doc, resolved.ref, merged, raw)
3052
3454
  );
3455
+ if (result.status === "updated") {
3456
+ const summary = loadFeatureManifestSummary(resolved.ref);
3457
+ const vars = featureOptionHints(
3458
+ summary,
3459
+ resolved.ref,
3460
+ Object.keys(merged)
3461
+ ).map((h) => h.envVar);
3462
+ if (vars.length > 0) {
3463
+ const home = input.monocerosHome ?? monocerosHome();
3464
+ await ensureEnvGitignored(containerConfigsDir(home));
3465
+ const seeded = await ensureEnvVars(
3466
+ containerEnvPath(input.name, home),
3467
+ input.name,
3468
+ vars
3469
+ );
3470
+ if (seeded.added.length > 0) {
3471
+ (input.logger ?? defaultLogger()).info(
3472
+ `Seeded ${seeded.added.join(", ")} into ${input.name}.env \u2014 fill in the values.`
3473
+ );
3474
+ }
3475
+ }
3476
+ }
3477
+ return result;
3053
3478
  }
3054
3479
  async function resolveFeatureRefOrShortname(input) {
3055
3480
  if (REGEX.featureRef.test(input)) {
@@ -3609,7 +4034,7 @@ var addServiceCommand = defineCommand7({
3609
4034
  meta: {
3610
4035
  name: "add-service",
3611
4036
  group: "edit",
3612
- description: "Add a compose service (postgres, mysql, redis, \u2026) to the container config. Idempotent, prints a diff before writing."
4037
+ 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
4038
  },
3614
4039
  args: {
3615
4040
  name: {
@@ -3619,9 +4044,13 @@ var addServiceCommand = defineCommand7({
3619
4044
  },
3620
4045
  service: {
3621
4046
  type: "positional",
3622
- description: "Service identifier (postgres, mysql, redis).",
4047
+ description: "Curated name (postgres, mysql, redis) or any image ref (e.g. rustfs/rustfs:latest).",
3623
4048
  required: true
3624
4049
  },
4050
+ as: {
4051
+ type: "string",
4052
+ 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."
4053
+ },
3625
4054
  yes: {
3626
4055
  type: "boolean",
3627
4056
  description: "Skip the interactive confirmation and apply the diff.",
@@ -3634,6 +4063,7 @@ var addServiceCommand = defineCommand7({
3634
4063
  const result = await runAddService({
3635
4064
  name: args.name,
3636
4065
  service: args.service,
4066
+ ...args.as ? { as: args.as } : {},
3637
4067
  yes: args.yes
3638
4068
  });
3639
4069
  process.exit(result.status === "aborted" ? 1 : 0);
@@ -3648,12 +4078,12 @@ var addServiceCommand = defineCommand7({
3648
4078
  import { defineCommand as defineCommand8 } from "citty";
3649
4079
 
3650
4080
  // src/apply/index.ts
3651
- import { existsSync as existsSync6, promises as fs11 } from "fs";
4081
+ import { existsSync as existsSync8, promises as fs12 } from "fs";
3652
4082
  import { consola as consola11 } from "consola";
3653
4083
 
3654
4084
  // src/config/state.ts
3655
4085
  import { promises as fs9 } from "fs";
3656
- import path9 from "path";
4086
+ import path10 from "path";
3657
4087
  function buildStateFile(opts) {
3658
4088
  return {
3659
4089
  schemaVersion: CONFIG_SCHEMA_VERSION,
@@ -3663,7 +4093,7 @@ function buildStateFile(opts) {
3663
4093
  };
3664
4094
  }
3665
4095
  function stateFilePath(targetDir) {
3666
- return path9.join(targetDir, ".monoceros", "state.json");
4096
+ return path10.join(targetDir, ".monoceros", "state.json");
3667
4097
  }
3668
4098
  async function readStateFile(targetDir) {
3669
4099
  try {
@@ -3674,7 +4104,7 @@ async function readStateFile(targetDir) {
3674
4104
  }
3675
4105
  }
3676
4106
  async function writeStateFile(targetDir, state) {
3677
- const monocerosDir = path9.join(targetDir, ".monoceros");
4107
+ const monocerosDir = path10.join(targetDir, ".monoceros");
3678
4108
  await fs9.mkdir(monocerosDir, { recursive: true });
3679
4109
  await fs9.writeFile(
3680
4110
  stateFilePath(targetDir),
@@ -3695,7 +4125,11 @@ function solutionConfigToCreateOptions(config, featureDefaults = {}) {
3695
4125
  const result = {
3696
4126
  name: config.name,
3697
4127
  languages: [...config.languages],
3698
- services: [...config.services]
4128
+ // Normalize every services[] entry (curated string or explicit
4129
+ // object) to the canonical ResolvedService shape. `${VAR}` values
4130
+ // survive untouched here — apply interpolates them against
4131
+ // <name>.env afterwards.
4132
+ services: config.services.map(resolveService)
3699
4133
  };
3700
4134
  if (config.externalServices.postgres !== void 0) {
3701
4135
  result.postgresUrl = config.externalServices.postgres;
@@ -3746,8 +4180,8 @@ function solutionConfigToCreateOptions(config, featureDefaults = {}) {
3746
4180
 
3747
4181
  // src/devcontainer/compose.ts
3748
4182
  import { spawn as spawn5 } from "child_process";
3749
- import { existsSync as existsSync5 } from "fs";
3750
- import path11 from "path";
4183
+ import { existsSync as existsSync6 } from "fs";
4184
+ import path12 from "path";
3751
4185
  import { consola as consola9 } from "consola";
3752
4186
 
3753
4187
  // src/util/mask-secrets.ts
@@ -3808,9 +4242,9 @@ function createSecretMaskStream() {
3808
4242
 
3809
4243
  // src/devcontainer/cli.ts
3810
4244
  import { spawn as spawn4 } from "child_process";
3811
- import { readFileSync as readFileSync3 } from "fs";
4245
+ import { readFileSync as readFileSync4 } from "fs";
3812
4246
  import { createRequire } from "module";
3813
- import path10 from "path";
4247
+ import path11 from "path";
3814
4248
 
3815
4249
  // src/devcontainer/runtime-pull-hint.ts
3816
4250
  import { Transform as Transform2 } from "stream";
@@ -3856,12 +4290,12 @@ var cachedBinaryPath = null;
3856
4290
  function devcontainerCliPath() {
3857
4291
  if (cachedBinaryPath) return cachedBinaryPath;
3858
4292
  const pkgJsonPath = require_.resolve("@devcontainers/cli/package.json");
3859
- const pkg = JSON.parse(readFileSync3(pkgJsonPath, "utf8"));
4293
+ const pkg = JSON.parse(readFileSync4(pkgJsonPath, "utf8"));
3860
4294
  const binEntry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.devcontainer ?? "";
3861
4295
  if (!binEntry) {
3862
4296
  throw new Error("Could not resolve @devcontainers/cli bin entry.");
3863
4297
  }
3864
- cachedBinaryPath = path10.resolve(path10.dirname(pkgJsonPath), binEntry);
4298
+ cachedBinaryPath = path11.resolve(path11.dirname(pkgJsonPath), binEntry);
3865
4299
  return cachedBinaryPath;
3866
4300
  }
3867
4301
  var spawnDevcontainer = (args, cwd, options = {}) => {
@@ -3979,16 +4413,16 @@ async function cleanupDockerObjects(opts) {
3979
4413
  return { exitCode: rmExit, removedIds: ids };
3980
4414
  }
3981
4415
  function composeProjectName(root) {
3982
- return `${path11.basename(root)}_devcontainer`;
4416
+ return `${path12.basename(root)}_devcontainer`;
3983
4417
  }
3984
4418
  function resolveCompose(root) {
3985
- if (!existsSync5(path11.join(root, ".devcontainer"))) {
4419
+ if (!existsSync6(path12.join(root, ".devcontainer"))) {
3986
4420
  throw new Error(
3987
4421
  `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
3988
4422
  );
3989
4423
  }
3990
- const composeFile = path11.join(root, ".devcontainer", "compose.yaml");
3991
- if (!existsSync5(composeFile)) {
4424
+ const composeFile = path12.join(root, ".devcontainer", "compose.yaml");
4425
+ if (!existsSync6(composeFile)) {
3992
4426
  throw new Error(
3993
4427
  `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
4428
  );
@@ -4179,6 +4613,12 @@ function formatUnreachableReposError(failures) {
4179
4613
  lines.push(headerForKind(kind));
4180
4614
  for (const e of entries) {
4181
4615
  lines.push(` \u2022 ${e.url}`);
4616
+ if (e.detail) {
4617
+ for (const detailLine of e.detail.split("\n")) {
4618
+ const trimmed = detailLine.trim();
4619
+ if (trimmed) lines.push(` git: ${trimmed}`);
4620
+ }
4621
+ }
4182
4622
  }
4183
4623
  for (const advice of adviceForKind(kind)) {
4184
4624
  lines.push(` - ${advice}`);
@@ -4225,11 +4665,85 @@ function adviceForKind(kind) {
4225
4665
  }
4226
4666
  }
4227
4667
 
4228
- // src/devcontainer/docker-mode.ts
4668
+ // src/devcontainer/repo-clone.ts
4229
4669
  import { spawn as spawn7 } from "child_process";
4670
+ import { existsSync as existsSync7, promises as fs10 } from "fs";
4671
+ import path13 from "path";
4672
+ var realGitClone = (url, dest) => {
4673
+ return new Promise((resolve, reject) => {
4674
+ const child = spawn7("git", ["clone", "--", url, dest], {
4675
+ stdio: ["ignore", "pipe", "pipe"],
4676
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
4677
+ });
4678
+ let stdout = "";
4679
+ let stderr = "";
4680
+ child.stdout.on("data", (c) => {
4681
+ stdout += c.toString();
4682
+ });
4683
+ child.stderr.on("data", (c) => {
4684
+ stderr += c.toString();
4685
+ });
4686
+ child.on("error", reject);
4687
+ child.on(
4688
+ "exit",
4689
+ (code) => resolve({ stdout, stderr, exitCode: code ?? 0 })
4690
+ );
4691
+ });
4692
+ };
4693
+ async function cloneReposHostSide(containerRoot, repos, options = {}) {
4694
+ const spawnFn = options.spawn ?? realGitClone;
4695
+ const results = [];
4696
+ for (const repo of repos) {
4697
+ const dest = path13.join(containerRoot, "projects", repo.path);
4698
+ if (existsSync7(dest)) {
4699
+ results.push({ path: repo.path, url: repo.url, status: "skipped" });
4700
+ continue;
4701
+ }
4702
+ await fs10.mkdir(path13.dirname(dest), { recursive: true });
4703
+ let r;
4704
+ try {
4705
+ r = await spawnFn(repo.url, dest);
4706
+ } catch (err) {
4707
+ results.push({
4708
+ path: repo.path,
4709
+ url: repo.url,
4710
+ status: "failed",
4711
+ detail: err instanceof Error ? err.message : String(err)
4712
+ });
4713
+ continue;
4714
+ }
4715
+ results.push(
4716
+ r.exitCode === 0 ? { path: repo.path, url: repo.url, status: "cloned" } : {
4717
+ path: repo.path,
4718
+ url: repo.url,
4719
+ status: "failed",
4720
+ detail: r.stderr.trim()
4721
+ }
4722
+ );
4723
+ }
4724
+ return results;
4725
+ }
4726
+ function formatCloneFailuresError(failures) {
4727
+ const lines = failures.length === 1 ? [`Failed to clone declared repo: ${failures[0].url}`, ""] : [`Failed to clone ${failures.length} declared repos:`, ""];
4728
+ for (const f of failures) {
4729
+ lines.push(` \u2022 ${f.url} \u2192 projects/${f.path}`);
4730
+ if (f.detail) lines.push(` ${f.detail}`);
4731
+ }
4732
+ lines.push("");
4733
+ lines.push(
4734
+ "Reachability was confirmed earlier, so this is usually a local issue"
4735
+ );
4736
+ lines.push(
4737
+ "(disk space, a leftover non-empty target dir). Fix it and re-run " + cyan2("monoceros apply") + "."
4738
+ );
4739
+ return lines.join("\n");
4740
+ }
4741
+
4742
+ // src/devcontainer/docker-mode.ts
4743
+ import { spawn as spawn8 } from "child_process";
4230
4744
  var realDockerInfo = () => {
4231
4745
  return new Promise((resolve, reject) => {
4232
- const child = spawn7(
4746
+ const child = spawn8(
4233
4747
  "docker",
4234
4748
  ["info", "--format", "{{json .SecurityOptions}}"],
4235
4749
  {
@@ -4288,13 +4802,13 @@ function formatRootlessNotSupportedError() {
4288
4802
  }
4289
4803
 
4290
4804
  // src/devcontainer/identity.ts
4291
- import { spawn as spawn8 } from "child_process";
4292
- import { promises as fs10 } from "fs";
4293
- import path12 from "path";
4805
+ import { spawn as spawn9 } from "child_process";
4806
+ import { promises as fs11 } from "fs";
4807
+ import path14 from "path";
4294
4808
  import { consola as consola10 } from "consola";
4295
4809
  var realGitConfigGet = (key) => {
4296
4810
  return new Promise((resolve, reject) => {
4297
- const child = spawn8("git", ["config", "--global", "--get", key], {
4811
+ const child = spawn9("git", ["config", "--global", "--get", key], {
4298
4812
  stdio: ["ignore", "pipe", "inherit"]
4299
4813
  });
4300
4814
  let stdout = "";
@@ -4404,8 +4918,8 @@ async function resolveIdentityWithPrompt(options = {}) {
4404
4918
  };
4405
4919
  }
4406
4920
  async function collectGitIdentity(devContainerRoot, options = {}) {
4407
- const gitconfigDir = path12.join(devContainerRoot, ".monoceros");
4408
- const gitconfigPath = path12.join(gitconfigDir, "gitconfig");
4921
+ const gitconfigDir = path14.join(devContainerRoot, ".monoceros");
4922
+ const gitconfigPath = path14.join(gitconfigDir, "gitconfig");
4409
4923
  const logger = options.logger ?? { info: () => {
4410
4924
  }, warn: () => {
4411
4925
  } };
@@ -4418,8 +4932,8 @@ async function collectGitIdentity(devContainerRoot, options = {}) {
4418
4932
  const lines = ["[user]"];
4419
4933
  if (resolved.name !== void 0) lines.push(` name = ${resolved.name}`);
4420
4934
  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");
4935
+ await fs11.mkdir(gitconfigDir, { recursive: true });
4936
+ await fs11.writeFile(gitconfigPath, lines.join("\n") + "\n");
4423
4937
  return {
4424
4938
  ...resolved.name !== void 0 ? { name: resolved.name } : {},
4425
4939
  ...resolved.email !== void 0 ? { email: resolved.email } : {},
@@ -4462,7 +4976,7 @@ async function readKeyFromHost(spawnFn, key, logger) {
4462
4976
  }
4463
4977
  async function readExistingGitconfig(filePath) {
4464
4978
  try {
4465
- const content = await fs10.readFile(filePath, "utf8");
4979
+ const content = await fs11.readFile(filePath, "utf8");
4466
4980
  const result = {};
4467
4981
  const nameMatch = /^\s*name\s*=\s*(.+?)\s*$/m.exec(content);
4468
4982
  const emailMatch = /^\s*email\s*=\s*(.+?)\s*$/m.exec(content);
@@ -4495,7 +5009,7 @@ ${sectionLine(label)}
4495
5009
  );
4496
5010
  }
4497
5011
  const ymlPath = containerConfigPath(opts.name, home);
4498
- if (!existsSync6(ymlPath)) {
5012
+ if (!existsSync8(ymlPath)) {
4499
5013
  throw new Error(
4500
5014
  `No such config: ${ymlPath}. Run \`monoceros init <template> ${opts.name}\` first.`
4501
5015
  );
@@ -4512,6 +5026,20 @@ ${sectionLine(label)}
4512
5026
  globalConfig?.defaults?.features ?? {}
4513
5027
  )
4514
5028
  );
5029
+ const envPath = containerEnvPath(opts.name, home);
5030
+ await ensureEnvGitignored(containerConfigsDir(home));
5031
+ const envVars = readEnvFile(envPath);
5032
+ const interpServices = interpolateServices(createOpts.services, envVars);
5033
+ const interpFeatures = interpolateFeatures(
5034
+ createOpts.features ?? {},
5035
+ envVars
5036
+ );
5037
+ const missingVars = [...interpServices.missing, ...interpFeatures.missing];
5038
+ if (missingVars.length > 0) {
5039
+ throw new Error(formatMissingVarsError(missingVars, prettyPath(envPath)));
5040
+ }
5041
+ createOpts.services = interpServices.services;
5042
+ if (createOpts.features) createOpts.features = interpFeatures.features;
4515
5043
  validateOptions(createOpts);
4516
5044
  logger.success(`yml validated ${dim(`(${prettyPath(ymlPath)})`)}`);
4517
5045
  const hasRepos = (createOpts.repos ?? []).length > 0;
@@ -4566,7 +5094,7 @@ ${sectionLine(label)}
4566
5094
  if (dockerMode === "rootless") {
4567
5095
  throw new Error(formatRootlessNotSupportedError());
4568
5096
  }
4569
- await fs11.mkdir(targetDir, { recursive: true });
5097
+ await fs12.mkdir(targetDir, { recursive: true });
4570
5098
  await writeScaffold(createOpts, targetDir, { dockerMode });
4571
5099
  await writeStateFile(
4572
5100
  targetDir,
@@ -4577,6 +5105,23 @@ ${sectionLine(label)}
4577
5105
  })
4578
5106
  );
4579
5107
  logger.success(`materialized into ${prettyPath(targetDir)}`);
5108
+ const reposToClone = createOpts.repos ?? [];
5109
+ if (reposToClone.length > 0) {
5110
+ const cloneResults = await cloneReposHostSide(targetDir, reposToClone, {
5111
+ ...opts.cloneSpawn ? { spawn: opts.cloneSpawn } : {}
5112
+ });
5113
+ for (const r of cloneResults) {
5114
+ if (r.status === "cloned") {
5115
+ logger.success(`cloned ${cyan2(r.path)} ${dim(`(${r.url})`)}`);
5116
+ } else if (r.status === "skipped") {
5117
+ logger.info(`projects/${r.path} already present \u2014 skipped clone`);
5118
+ }
5119
+ }
5120
+ const cloneFailures = cloneResults.filter((r) => r.status === "failed");
5121
+ if (cloneFailures.length > 0) {
5122
+ throw new Error(formatCloneFailuresError(cloneFailures));
5123
+ }
5124
+ }
4580
5125
  section("Container");
4581
5126
  const featureRefs = parsed.config.features.map((f) => f.ref);
4582
5127
  if (featureRefs.length > 0) {
@@ -4624,8 +5169,8 @@ ${sectionLine(label)}
4624
5169
  return { targetDir, configPath: ymlPath, containerExitCode: exitCode };
4625
5170
  }
4626
5171
  async function assertSafeTargetDir(targetDir, expectedOrigin) {
4627
- if (!existsSync6(targetDir)) return;
4628
- const entries = await fs11.readdir(targetDir);
5172
+ if (!existsSync8(targetDir)) return;
5173
+ const entries = await fs12.readdir(targetDir);
4629
5174
  if (entries.length === 0) return;
4630
5175
  const state = await readStateFile(targetDir);
4631
5176
  if (state) {
@@ -4695,7 +5240,7 @@ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
4695
5240
  }
4696
5241
  if (wantContainer) {
4697
5242
  try {
4698
- const text = await fs11.readFile(ymlPath, "utf8");
5243
+ const text = await fs12.readFile(ymlPath, "utf8");
4699
5244
  const parsed = parseConfig(text, ymlPath);
4700
5245
  const changed = setContainerGitUserInDoc(parsed.doc, {
4701
5246
  name: prompted.name,
@@ -4703,7 +5248,7 @@ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
4703
5248
  });
4704
5249
  if (changed) {
4705
5250
  const out = stringifyConfig(parsed.doc);
4706
- await fs11.writeFile(ymlPath, out, "utf8");
5251
+ await fs12.writeFile(ymlPath, out, "utf8");
4707
5252
  logger.info(
4708
5253
  `Saved identity in this container \u2014 wrote git.user into ${prettyPath(ymlPath)}.`
4709
5254
  );
@@ -4717,7 +5262,7 @@ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
4717
5262
  }
4718
5263
 
4719
5264
  // src/version.ts
4720
- var CLI_VERSION = true ? "1.12.0" : "dev";
5265
+ var CLI_VERSION = true ? "1.13.1" : "dev";
4721
5266
 
4722
5267
  // src/commands/_dispatch.ts
4723
5268
  import { consola as consola12 } from "consola";
@@ -4877,8 +5422,8 @@ var completionCommand = defineCommand9({
4877
5422
  import { defineCommand as defineCommand10 } from "citty";
4878
5423
 
4879
5424
  // src/completion/resolve.ts
4880
- import { existsSync as existsSync7, promises as fs12 } from "fs";
4881
- import path13 from "path";
5425
+ import { existsSync as existsSync9, promises as fs13 } from "fs";
5426
+ import path15 from "path";
4882
5427
  async function resolveCompletions(line, point, opts = {}) {
4883
5428
  const { prev, current } = parseCompletionLine(line, point);
4884
5429
  const ctx = { prev, current, opts };
@@ -5026,15 +5571,11 @@ function filterPrefix(values, fragment) {
5026
5571
  }
5027
5572
  async function listContainerNames(ctx) {
5028
5573
  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);
5574
+ const dir = path15.join(home, "container-configs");
5575
+ if (!existsSync9(dir)) return [];
5576
+ const entries = await fs13.readdir(dir);
5032
5577
  return entries.filter((e) => e.endsWith(".yml")).map((e) => e.slice(0, -".yml".length)).sort();
5033
5578
  }
5034
- async function listAllCatalogComponents() {
5035
- const catalog = await loadComponentCatalog();
5036
- return [...catalog.keys()].sort();
5037
- }
5038
5579
  async function listFeatureComponents() {
5039
5580
  const catalog = await loadComponentCatalog();
5040
5581
  return [...catalog.values()].filter((c) => c.file.category === "feature").map((c) => c.name).sort();
@@ -5136,8 +5677,14 @@ var COMMAND_SPECS = {
5136
5677
  // flag suggestions.
5137
5678
  positionalCount: 1,
5138
5679
  flags: {
5139
- "--with": { type: "value", values: () => listAllCatalogComponents() },
5140
- "--with-repo": { type: "value" },
5680
+ "--with-languages": { type: "value", values: () => listLanguageNames() },
5681
+ "--with-features": {
5682
+ type: "value",
5683
+ values: () => listFeatureComponents()
5684
+ },
5685
+ "--with-services": { type: "value", values: () => listServiceNames() },
5686
+ "--with-apt-packages": { type: "value" },
5687
+ "--with-repos": { type: "value" },
5141
5688
  "--with-ports": { type: "value" }
5142
5689
  }
5143
5690
  },
@@ -5281,22 +5828,22 @@ import { defineCommand as defineCommand11 } from "citty";
5281
5828
  import { consola as consola14 } from "consola";
5282
5829
 
5283
5830
  // src/init/index.ts
5284
- import { existsSync as existsSync8, promises as fs13 } from "fs";
5831
+ import { existsSync as existsSync10, promises as fs14 } from "fs";
5832
+ import path16 from "path";
5285
5833
  import { consola as consola13 } from "consola";
5286
5834
 
5287
5835
  // src/init/generator.ts
5288
5836
  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
5837
  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
5838
  var COMMENT_WIDTH = 76;
5291
- function generateComposedYml(name, components, lookupManifest, repoUrls = [], ports = []) {
5292
- const merged = mergeComponents(components);
5839
+ function generateComposedYml(name, composed, lookupManifest, repoUrls = [], ports = []) {
5293
5840
  const lines = [];
5294
5841
  pushHeader(lines, SCHEMA_HEADER_ACTIVE, name);
5295
5842
  lines.push("");
5296
5843
  lines.push("schemaVersion: 1");
5297
5844
  lines.push(`name: ${name}`);
5298
5845
  lines.push("");
5299
- if (merged.languages.length > 0) {
5846
+ if (composed.languages.length > 0) {
5300
5847
  pushSectionHeader(
5301
5848
  lines,
5302
5849
  LANGUAGES_HEADER,
@@ -5304,10 +5851,21 @@ function generateComposedYml(name, components, lookupManifest, repoUrls = [], po
5304
5851
  false
5305
5852
  );
5306
5853
  lines.push("languages:");
5307
- for (const lang of merged.languages) lines.push(` - ${lang}`);
5854
+ for (const lang of composed.languages) lines.push(` - ${lang}`);
5855
+ lines.push("");
5856
+ }
5857
+ if (composed.aptPackages.length > 0) {
5858
+ pushSectionHeader(
5859
+ lines,
5860
+ APT_PACKAGES_HEADER,
5861
+ /* commented */
5862
+ false
5863
+ );
5864
+ lines.push("aptPackages:");
5865
+ for (const pkg of composed.aptPackages) lines.push(` - ${pkg}`);
5308
5866
  lines.push("");
5309
5867
  }
5310
- if (merged.services.length > 0) {
5868
+ if (composed.services.length > 0) {
5311
5869
  pushSectionHeader(
5312
5870
  lines,
5313
5871
  SERVICES_HEADER,
@@ -5315,10 +5873,10 @@ function generateComposedYml(name, components, lookupManifest, repoUrls = [], po
5315
5873
  false
5316
5874
  );
5317
5875
  lines.push("services:");
5318
- for (const svc of merged.services) lines.push(` - ${svc}`);
5876
+ for (const svc of composed.services) pushServiceEntry(lines, svc);
5319
5877
  lines.push("");
5320
5878
  }
5321
- if (merged.features.length > 0) {
5879
+ if (composed.features.length > 0) {
5322
5880
  pushSectionHeader(
5323
5881
  lines,
5324
5882
  FEATURES_HEADER_ACTIVE,
@@ -5326,7 +5884,7 @@ function generateComposedYml(name, components, lookupManifest, repoUrls = [], po
5326
5884
  false
5327
5885
  );
5328
5886
  lines.push("features:");
5329
- for (const f of merged.features) {
5887
+ for (const f of composed.features) {
5330
5888
  lines.push("");
5331
5889
  renderFeatureBlock(
5332
5890
  lines,
@@ -5407,7 +5965,9 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
5407
5965
  lines.push("# services:");
5408
5966
  for (const c of byCategory.service) {
5409
5967
  for (const svc of c.file.contributes.services ?? []) {
5410
- lines.push(`# - ${svc}`);
5968
+ const body = renderServiceObjectBody(expandCuratedService(svc));
5969
+ lines.push(`# - ${body[0]}`);
5970
+ for (const line of body.slice(1)) lines.push(`# ${line}`);
5411
5971
  }
5412
5972
  }
5413
5973
  lines.push("");
@@ -5514,6 +6074,22 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
5514
6074
  }
5515
6075
  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
6076
  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.";
6077
+ 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.";
6078
+ function pushServiceEntry(out, svc) {
6079
+ if (svc.kind === "custom") {
6080
+ const { bodyLines, comment } = renderCustomService(
6081
+ svc.name,
6082
+ svc.image ?? ""
6083
+ );
6084
+ out.push(` - ${bodyLines[0]}`);
6085
+ for (const line of bodyLines.slice(1)) out.push(` ${line}`);
6086
+ for (const cl of comment.split("\n")) out.push(` #${cl}`);
6087
+ return;
6088
+ }
6089
+ const body = renderServiceObjectBody(expandCuratedService(svc.name));
6090
+ out.push(` - ${body[0]}`);
6091
+ for (const line of body.slice(1)) out.push(` ${line}`);
6092
+ }
5517
6093
  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
6094
  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
6095
  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 +6104,15 @@ function renderFeatureBlock(out, feature, summary, commented) {
5528
6104
  out.push(`${yamlPrefix} - ref: ${feature.ref}`);
5529
6105
  const options = feature.options ?? {};
5530
6106
  const activeKeys = Object.entries(options);
5531
- const hintKeys = (summary?.optionHints ?? []).filter((h) => !(h in options));
5532
- if (activeKeys.length === 0 && hintKeys.length === 0) return;
6107
+ const hints = featureOptionHints(summary, feature.ref, Object.keys(options));
6108
+ if (activeKeys.length === 0 && hints.length === 0) return;
5533
6109
  if (commented) {
5534
6110
  out.push(`${yamlPrefix} options:`);
5535
6111
  for (const [key, value] of activeKeys) {
5536
6112
  out.push(`${yamlPrefix} ${key}: ${renderScalarValue(value)}`);
5537
6113
  }
5538
- for (const key of hintKeys) {
5539
- out.push(`${yamlPrefix} ${key}:`);
6114
+ for (const hint of hints) {
6115
+ out.push(`${yamlPrefix} ${hint.key}: ${hint.placeholder}`);
5540
6116
  }
5541
6117
  return;
5542
6118
  }
@@ -5546,10 +6122,10 @@ function renderFeatureBlock(out, feature, summary, commented) {
5546
6122
  out.push(` ${key}: ${renderScalarValue(value)}`);
5547
6123
  }
5548
6124
  }
5549
- if (hintKeys.length > 0) {
6125
+ if (hints.length > 0) {
5550
6126
  out.push(` # options:`);
5551
- for (const key of hintKeys) {
5552
- out.push(` # ${key}:`);
6127
+ for (const hint of hints) {
6128
+ out.push(` # ${hint.key}: ${hint.placeholder}`);
5553
6129
  }
5554
6130
  }
5555
6131
  }
@@ -5603,7 +6179,7 @@ async function runInit(opts) {
5603
6179
  );
5604
6180
  }
5605
6181
  const dest = containerConfigPath(opts.name, home);
5606
- if (existsSync8(dest)) {
6182
+ if (existsSync10(dest)) {
5607
6183
  throw new Error(
5608
6184
  `Config already exists: ${dest}. Delete it manually before re-running \`monoceros init\` \u2014 this protects any hand-edits.`
5609
6185
  );
@@ -5673,15 +6249,38 @@ async function runInit(opts) {
5673
6249
  });
5674
6250
  }
5675
6251
  let text;
5676
- const requested = opts.with ?? [];
5677
- if (requested.length === 0) {
6252
+ const composed = resolveComposedInit(catalog, {
6253
+ languages: opts.languages ?? [],
6254
+ features: opts.features ?? [],
6255
+ services: opts.services ?? [],
6256
+ aptPackages: opts.aptPackages ?? []
6257
+ });
6258
+ const anyComposed = composed.languages.length > 0 || composed.features.length > 0 || composed.services.length > 0 || composed.aptPackages.length > 0;
6259
+ if (!anyComposed) {
5678
6260
  text = generateDocumentedYml(opts.name, catalog, lookup, repos, ports);
5679
6261
  } else {
5680
- const components = resolveComponents(catalog, requested);
5681
- text = generateComposedYml(opts.name, components, lookup, repos, ports);
6262
+ text = generateComposedYml(opts.name, composed, lookup, repos, ports);
6263
+ }
6264
+ await fs14.mkdir(containerConfigsDir(home), { recursive: true });
6265
+ await ensureEnvGitignored(containerConfigsDir(home));
6266
+ await fs14.writeFile(dest, text, "utf8");
6267
+ const envPath = containerEnvPath(opts.name, home);
6268
+ const seedVars = {};
6269
+ for (const f of composed.features) {
6270
+ for (const h of featureOptionHints(
6271
+ lookup(f.ref),
6272
+ f.ref,
6273
+ Object.keys(f.options ?? {})
6274
+ )) {
6275
+ if (!(h.envVar in seedVars)) seedVars[h.envVar] = "";
6276
+ }
6277
+ }
6278
+ for (const svc of composed.services) {
6279
+ if (svc.kind === "curated") {
6280
+ Object.assign(seedVars, curatedServiceEnvDefaults(svc.name));
6281
+ }
5682
6282
  }
5683
- await fs13.mkdir(containerConfigsDir(home), { recursive: true });
5684
- await fs13.writeFile(dest, text, "utf8");
6283
+ await ensureEnvVars(envPath, opts.name, seedVars);
5685
6284
  if (promptedIdentity?.prompted) {
5686
6285
  const { name, email, scope } = promptedIdentity.prompted;
5687
6286
  if (scope === "g" || scope === "b") {
@@ -5711,11 +6310,11 @@ async function runInit(opts) {
5711
6310
  }
5712
6311
  if (scope === "c" || scope === "b") {
5713
6312
  try {
5714
- const written = await fs13.readFile(dest, "utf8");
6313
+ const written = await fs14.readFile(dest, "utf8");
5715
6314
  const parsed = parseConfig(written, dest);
5716
6315
  const changed = setContainerGitUserInDoc(parsed.doc, { name, email });
5717
6316
  if (changed) {
5718
- await fs13.writeFile(dest, stringifyConfig(parsed.doc), "utf8");
6317
+ await fs14.writeFile(dest, stringifyConfig(parsed.doc), "utf8");
5719
6318
  logger.info(
5720
6319
  `Saved identity in ${prettyPath(dest)} (container-level git.user).`
5721
6320
  );
@@ -5727,29 +6326,136 @@ async function runInit(opts) {
5727
6326
  }
5728
6327
  }
5729
6328
  }
5730
- const documented = requested.length === 0;
5731
- const displayPath = prettyPath(dest);
6329
+ const documented = !anyComposed;
6330
+ const ymlRel = path16.relative(home, dest);
6331
+ const envRel = path16.relative(home, envPath);
5732
6332
  if (documented) {
5733
- logger.success(
5734
- `Wrote documented default to ${displayPath}. Un-comment what you need, then \`monoceros apply ${opts.name}\`.`
6333
+ logger.success(`Wrote documented default to ${ymlRel} and ${envRel}.`);
6334
+ logger.info(
6335
+ `Un-comment what you need, then \`monoceros apply ${opts.name}\`.`
5735
6336
  );
5736
6337
  } else {
5737
- logger.success(
5738
- `Composed ${requested.length} component(s) into ${displayPath}: ${requested.join(", ")}`
5739
- );
6338
+ logger.success(`Composed into ${ymlRel} and ${envRel}.`);
5740
6339
  logger.info(
5741
- `Edit the file if you need to tweak, then \`monoceros apply ${opts.name}\`.`
6340
+ `Edit the files if you need to tweak, then \`monoceros apply ${opts.name}\`.`
5742
6341
  );
5743
6342
  }
5744
6343
  return { configPath: dest, documented };
5745
6344
  }
6345
+ function resolveComposedInit(catalog, raw) {
6346
+ return {
6347
+ languages: resolveInitLanguages(raw.languages),
6348
+ aptPackages: resolveInitAptPackages(raw.aptPackages),
6349
+ services: resolveInitServices(raw.services),
6350
+ features: resolveInitFeatures(catalog, raw.features)
6351
+ };
6352
+ }
6353
+ function resolveInitLanguages(entries) {
6354
+ const known = new Set(knownLanguages());
6355
+ const out = [];
6356
+ const seen = /* @__PURE__ */ new Set();
6357
+ const unknown = [];
6358
+ for (const raw of entries) {
6359
+ const e = raw.trim();
6360
+ if (!e || seen.has(e)) continue;
6361
+ const spec = parseLanguageSpec(e);
6362
+ if (!spec || !known.has(spec.name)) {
6363
+ unknown.push(e);
6364
+ continue;
6365
+ }
6366
+ seen.add(e);
6367
+ out.push(e);
6368
+ }
6369
+ if (unknown.length > 0) {
6370
+ throw new Error(
6371
+ `Unknown language${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. Known: ${knownLanguages().join(", ")}.`
6372
+ );
6373
+ }
6374
+ return out;
6375
+ }
6376
+ function resolveInitAptPackages(entries) {
6377
+ const out = [];
6378
+ const seen = /* @__PURE__ */ new Set();
6379
+ const bad = [];
6380
+ for (const raw of entries) {
6381
+ const e = raw.trim();
6382
+ if (!e || seen.has(e)) continue;
6383
+ if (!REGEX.aptPackage.test(e)) {
6384
+ bad.push(e);
6385
+ continue;
6386
+ }
6387
+ seen.add(e);
6388
+ out.push(e);
6389
+ }
6390
+ if (bad.length > 0) {
6391
+ throw new Error(
6392
+ `Invalid apt package name${bad.length > 1 ? "s" : ""}: ${bad.join(", ")}. Expected lowercase alphanumeric plus '.+-'.`
6393
+ );
6394
+ }
6395
+ return out;
6396
+ }
6397
+ function resolveInitServices(entries) {
6398
+ const out = [];
6399
+ const byName = /* @__PURE__ */ new Map();
6400
+ for (const raw of entries) {
6401
+ const e = raw.trim();
6402
+ if (!e) continue;
6403
+ const svc = isCuratedService(e) ? { kind: "curated", name: e } : { kind: "custom", name: deriveServiceName(e), image: e };
6404
+ const existing = byName.get(svc.name);
6405
+ if (existing) {
6406
+ if (existing.kind === svc.kind && existing.image === svc.image) continue;
6407
+ throw new Error(
6408
+ `Two --with-services entries resolve to the service name '${svc.name}'. Add one after init with \`monoceros add-service ${"<name>"} <image> --as=<other>\`.`
6409
+ );
6410
+ }
6411
+ byName.set(svc.name, svc);
6412
+ out.push(svc);
6413
+ }
6414
+ return out;
6415
+ }
6416
+ function resolveInitFeatures(catalog, entries) {
6417
+ const byRef = /* @__PURE__ */ new Map();
6418
+ const unknown = [];
6419
+ for (const raw of entries) {
6420
+ const e = raw.trim();
6421
+ if (!e) continue;
6422
+ if (REGEX.featureRef.test(e)) {
6423
+ if (!byRef.has(e)) byRef.set(e, { ref: e, options: {} });
6424
+ continue;
6425
+ }
6426
+ const c = catalog.get(e);
6427
+ if (!c || c.file.category !== "feature") {
6428
+ unknown.push(e);
6429
+ continue;
6430
+ }
6431
+ for (const f of c.file.contributes.features ?? []) {
6432
+ const existing = byRef.get(f.ref);
6433
+ if (!existing) {
6434
+ byRef.set(f.ref, { ref: f.ref, options: { ...f.options ?? {} } });
6435
+ } else {
6436
+ existing.options = mergeFeatureOptions(
6437
+ existing.options,
6438
+ f.options ?? {}
6439
+ );
6440
+ }
6441
+ }
6442
+ }
6443
+ if (unknown.length > 0) {
6444
+ const featureNames = [...catalog.values()].filter((c) => c.file.category === "feature").map((c) => c.name).sort();
6445
+ throw new Error(
6446
+ `Unknown feature${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.
6447
+ Use a catalog short name (${featureNames.join(", ")}) or a full OCI ref (ghcr.io/\u2026/<name>:<tag>).`
6448
+ );
6449
+ }
6450
+ return [...byRef.values()];
6451
+ }
5746
6452
 
5747
6453
  // src/commands/init.ts
5748
6454
  var initCommand = defineCommand11({
5749
6455
  meta: {
5750
6456
  name: "init",
5751
6457
  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>`."
6458
+ 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
6459
  },
5754
6460
  args: {
5755
6461
  name: {
@@ -5757,14 +6463,29 @@ var initCommand = defineCommand11({
5757
6463
  description: "Config name. The yml lands at <MONOCEROS_HOME>/container-configs/<name>.yml and becomes the source-of-truth for `monoceros apply <name>`.",
5758
6464
  required: true
5759
6465
  },
5760
- with: {
6466
+ "with-languages": {
6467
+ type: "string",
6468
+ 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`.",
6469
+ required: false
6470
+ },
6471
+ "with-features": {
6472
+ type: "string",
6473
+ 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).",
6474
+ required: false
6475
+ },
6476
+ "with-services": {
6477
+ type: "string",
6478
+ 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.",
6479
+ required: false
6480
+ },
6481
+ "with-apt-packages": {
5761
6482
  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.",
6483
+ description: "Debian/Ubuntu apt packages to install, comma-separated or repeated, e.g. --with-apt-packages=openssl,make. No curated list.",
5763
6484
  required: false
5764
6485
  },
5765
- "with-repo": {
6486
+ "with-repos": {
5766
6487
  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.",
6488
+ 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
6489
  required: false
5769
6490
  },
5770
6491
  "with-ports": {
@@ -5775,14 +6496,20 @@ var initCommand = defineCommand11({
5775
6496
  },
5776
6497
  async run({ args, rawArgs }) {
5777
6498
  try {
5778
- const withList = collectWithList(args.with, rawArgs);
5779
- const withRepoList = collectWithRepoList(rawArgs);
5780
- const withPortsList = collectWithPortsList(args["with-ports"], rawArgs);
6499
+ const languages = collectListFlag("--with-languages", rawArgs);
6500
+ const features = collectListFlag("--with-features", rawArgs);
6501
+ const services = collectListFlag("--with-services", rawArgs);
6502
+ const aptPackages = collectListFlag("--with-apt-packages", rawArgs);
6503
+ const repos = collectListFlag("--with-repos", rawArgs);
6504
+ const ports = collectWithPortsList(args["with-ports"], rawArgs);
5781
6505
  await runInit({
5782
6506
  name: args.name,
5783
- ...withList ? { with: withList } : {},
5784
- ...withRepoList.length > 0 ? { withRepo: withRepoList } : {},
5785
- ...withPortsList && withPortsList.length > 0 ? { withPorts: withPortsList } : {}
6507
+ ...languages.length > 0 ? { languages } : {},
6508
+ ...features.length > 0 ? { features } : {},
6509
+ ...services.length > 0 ? { services } : {},
6510
+ ...aptPackages.length > 0 ? { aptPackages } : {},
6511
+ ...repos.length > 0 ? { withRepo: repos } : {},
6512
+ ...ports && ports.length > 0 ? { withPorts: ports } : {}
5786
6513
  });
5787
6514
  } catch (err) {
5788
6515
  consola14.error(err instanceof Error ? err.message : String(err));
@@ -5790,6 +6517,30 @@ var initCommand = defineCommand11({
5790
6517
  }
5791
6518
  }
5792
6519
  });
6520
+ function collectListFlag(flag, rawArgs) {
6521
+ const eq = `${flag}=`;
6522
+ const pieces = [];
6523
+ for (let i = 0; i < rawArgs.length; i += 1) {
6524
+ const t = rawArgs[i];
6525
+ let scanStart = -1;
6526
+ if (t === flag) {
6527
+ scanStart = i + 1;
6528
+ } else if (t.startsWith(eq)) {
6529
+ pieces.push(t.slice(eq.length));
6530
+ scanStart = i + 1;
6531
+ }
6532
+ if (scanStart < 0) continue;
6533
+ let j = scanStart;
6534
+ while (j < rawArgs.length) {
6535
+ const u = rawArgs[j];
6536
+ if (u.startsWith("-")) break;
6537
+ pieces.push(u);
6538
+ j += 1;
6539
+ }
6540
+ i = j - 1;
6541
+ }
6542
+ return pieces.flatMap((s) => s.split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
6543
+ }
5793
6544
  function collectWithPortsList(_withPortsArg, rawArgs) {
5794
6545
  const pieces = [];
5795
6546
  for (let i = 0; i < rawArgs.length; i += 1) {
@@ -5825,43 +6576,6 @@ function collectWithPortsList(_withPortsArg, rawArgs) {
5825
6576
  }
5826
6577
  return out;
5827
6578
  }
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
6579
 
5866
6580
  // src/commands/list-components.ts
5867
6581
  import { defineCommand as defineCommand12 } from "citty";
@@ -6149,8 +6863,8 @@ import { consola as consola20 } from "consola";
6149
6863
  import { createInterface } from "readline/promises";
6150
6864
 
6151
6865
  // src/remove/index.ts
6152
- import { existsSync as existsSync9, promises as fs14 } from "fs";
6153
- import path14 from "path";
6866
+ import { existsSync as existsSync11, promises as fs15 } from "fs";
6867
+ import path17 from "path";
6154
6868
  import { consola as consola19 } from "consola";
6155
6869
  async function runRemove(opts) {
6156
6870
  const home = opts.monocerosHome ?? monocerosHome();
@@ -6165,9 +6879,11 @@ async function runRemove(opts) {
6165
6879
  );
6166
6880
  }
6167
6881
  const ymlPath = containerConfigPath(opts.name, home);
6882
+ const envPath = containerEnvPath(opts.name, home);
6168
6883
  const containerPath = containerDir(opts.name, home);
6169
- const hasYml = existsSync9(ymlPath);
6170
- const hasContainer = existsSync9(containerPath);
6884
+ const hasYml = existsSync11(ymlPath);
6885
+ const hasEnv = existsSync11(envPath);
6886
+ const hasContainer = existsSync11(containerPath);
6171
6887
  if (!hasYml && !hasContainer) {
6172
6888
  throw new Error(
6173
6889
  `Nothing to remove for '${opts.name}': neither ${ymlPath} nor ${containerPath} exists.`
@@ -6191,24 +6907,30 @@ async function runRemove(opts) {
6191
6907
  let backupPath = null;
6192
6908
  if (!opts.noBackup && (hasYml || hasContainer)) {
6193
6909
  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 });
6910
+ backupPath = path17.join(home, "container-backups", `${opts.name}-${ts}`);
6911
+ await fs15.mkdir(backupPath, { recursive: true });
6196
6912
  if (hasYml) {
6197
- await fs14.copyFile(ymlPath, path14.join(backupPath, `${opts.name}.yml`));
6913
+ await fs15.copyFile(ymlPath, path17.join(backupPath, `${opts.name}.yml`));
6914
+ }
6915
+ if (hasEnv) {
6916
+ await fs15.copyFile(envPath, path17.join(backupPath, `${opts.name}.env`));
6198
6917
  }
6199
6918
  if (hasContainer) {
6200
- await fs14.cp(containerPath, path14.join(backupPath, "container"), {
6919
+ await fs15.cp(containerPath, path17.join(backupPath, "container"), {
6201
6920
  recursive: true
6202
6921
  });
6203
6922
  }
6204
6923
  logger.info(`Backup written to ${prettyPath(backupPath)}.`);
6205
6924
  }
6206
6925
  if (hasYml) {
6207
- await fs14.rm(ymlPath, { force: true });
6926
+ await fs15.rm(ymlPath, { force: true });
6927
+ }
6928
+ if (hasEnv) {
6929
+ await fs15.rm(envPath, { force: true });
6208
6930
  }
6209
6931
  if (hasContainer) {
6210
6932
  try {
6211
- await fs14.rm(containerPath, { recursive: true, force: true });
6933
+ await fs15.rm(containerPath, { recursive: true, force: true });
6212
6934
  } catch (err) {
6213
6935
  const code = err.code;
6214
6936
  if (code !== "EACCES" && code !== "EPERM") {
@@ -6234,7 +6956,7 @@ async function runRemove(opts) {
6234
6956
  `docker-based cleanup of ${containerPath} exited ${exit}. Inspect with \`sudo ls -la ${containerPath}\` and clean manually.`
6235
6957
  );
6236
6958
  }
6237
- await fs14.rm(containerPath, { recursive: true, force: true });
6959
+ await fs15.rm(containerPath, { recursive: true, force: true });
6238
6960
  }
6239
6961
  }
6240
6962
  logger.success(
@@ -6337,8 +7059,8 @@ import { defineCommand as defineCommand18 } from "citty";
6337
7059
  import { consola as consola22 } from "consola";
6338
7060
 
6339
7061
  // src/restore/index.ts
6340
- import { existsSync as existsSync10, promises as fs15 } from "fs";
6341
- import path15 from "path";
7062
+ import { existsSync as existsSync12, promises as fs16 } from "fs";
7063
+ import path18 from "path";
6342
7064
  import { consola as consola21 } from "consola";
6343
7065
  async function runRestore(opts) {
6344
7066
  const home = opts.monocerosHome ?? monocerosHome();
@@ -6346,15 +7068,15 @@ async function runRestore(opts) {
6346
7068
  info: (msg) => consola21.info(msg),
6347
7069
  success: (msg) => consola21.success(msg)
6348
7070
  };
6349
- const backup = path15.resolve(opts.backupPath);
6350
- if (!existsSync10(backup)) {
7071
+ const backup = path18.resolve(opts.backupPath);
7072
+ if (!existsSync12(backup)) {
6351
7073
  throw new Error(`Backup not found: ${backup}.`);
6352
7074
  }
6353
- const stat = await fs15.stat(backup);
7075
+ const stat = await fs16.stat(backup);
6354
7076
  if (!stat.isDirectory()) {
6355
7077
  throw new Error(`Backup path is not a directory: ${backup}.`);
6356
7078
  }
6357
- const entries = await fs15.readdir(backup);
7079
+ const entries = await fs16.readdir(backup);
6358
7080
  const ymlFiles = entries.filter((f) => f.endsWith(".yml"));
6359
7081
  if (ymlFiles.length === 0) {
6360
7082
  throw new Error(
@@ -6368,24 +7090,29 @@ async function runRestore(opts) {
6368
7090
  }
6369
7091
  const ymlFile = ymlFiles[0];
6370
7092
  const name = ymlFile.replace(/\.yml$/, "");
6371
- const containerInBackup = path15.join(backup, "container");
6372
- const hasContainer = existsSync10(containerInBackup);
7093
+ const containerInBackup = path18.join(backup, "container");
7094
+ const hasContainer = existsSync12(containerInBackup);
7095
+ const envInBackup = path18.join(backup, `${name}.env`);
7096
+ const hasEnv = existsSync12(envInBackup);
6373
7097
  const destYml = containerConfigPath(name, home);
6374
7098
  const destContainer = containerDir(name, home);
6375
- if (existsSync10(destYml)) {
7099
+ if (existsSync12(destYml)) {
6376
7100
  throw new Error(
6377
7101
  `Refusing to restore: ${destYml} already exists. Remove the current container first (\`monoceros remove ${name}\`) or rename the existing config.`
6378
7102
  );
6379
7103
  }
6380
- if (hasContainer && existsSync10(destContainer)) {
7104
+ if (hasContainer && existsSync12(destContainer)) {
6381
7105
  throw new Error(
6382
7106
  `Refusing to restore: ${destContainer} already exists. Remove the current container first (\`monoceros remove ${name}\`).`
6383
7107
  );
6384
7108
  }
6385
- await fs15.mkdir(containerConfigsDir(home), { recursive: true });
6386
- await fs15.copyFile(path15.join(backup, ymlFile), destYml);
7109
+ await fs16.mkdir(containerConfigsDir(home), { recursive: true });
7110
+ await fs16.copyFile(path18.join(backup, ymlFile), destYml);
7111
+ if (hasEnv) {
7112
+ await fs16.copyFile(envInBackup, containerEnvPath(name, home));
7113
+ }
6387
7114
  if (hasContainer) {
6388
- await fs15.cp(containerInBackup, destContainer, { recursive: true });
7115
+ await fs16.cp(containerInBackup, destContainer, { recursive: true });
6389
7116
  }
6390
7117
  logger.success(`Restored '${name}' from ${prettyPath(backup)}.`);
6391
7118
  logger.info(
@@ -6643,8 +7370,8 @@ import { defineCommand as defineCommand24 } from "citty";
6643
7370
  import { consola as consola28 } from "consola";
6644
7371
 
6645
7372
  // src/devcontainer/shell.ts
6646
- import { existsSync as existsSync11 } from "fs";
6647
- import path16 from "path";
7373
+ import { existsSync as existsSync13 } from "fs";
7374
+ import path19 from "path";
6648
7375
  async function runShell(opts) {
6649
7376
  assertContainerExists(opts.root);
6650
7377
  const spawnFn = opts.spawn ?? spawnDevcontainer;
@@ -6667,7 +7394,7 @@ async function runShell(opts) {
6667
7394
  );
6668
7395
  }
6669
7396
  function assertContainerExists(root) {
6670
- if (!existsSync11(path16.join(root, ".devcontainer"))) {
7397
+ if (!existsSync13(path19.join(root, ".devcontainer"))) {
6671
7398
  throw new Error(
6672
7399
  `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
6673
7400
  );
@@ -6879,15 +7606,15 @@ import { defineCommand as defineCommand29 } from "citty";
6879
7606
  import { consola as consola33 } from "consola";
6880
7607
 
6881
7608
  // src/tunnel/run.ts
6882
- import { spawn as spawn9 } from "child_process";
7609
+ import { spawn as spawn10 } from "child_process";
6883
7610
  import { consola as consola32 } from "consola";
6884
7611
 
6885
7612
  // src/tunnel/resolve.ts
6886
- import { existsSync as existsSync12 } from "fs";
6887
- import path17 from "path";
7613
+ import { existsSync as existsSync14 } from "fs";
7614
+ import path20 from "path";
6888
7615
  async function resolveTunnelTarget(opts) {
6889
7616
  const ymlPath = containerConfigPath(opts.name, opts.monocerosHome);
6890
- if (!existsSync12(ymlPath)) {
7617
+ if (!existsSync14(ymlPath)) {
6891
7618
  throw new Error(
6892
7619
  `No yml profile for '${opts.name}' at ${ymlPath}. Run \`monoceros init ${opts.name}\` first.`
6893
7620
  );
@@ -6895,13 +7622,13 @@ async function resolveTunnelTarget(opts) {
6895
7622
  const parsed = await readConfig(ymlPath);
6896
7623
  const config = parsed.config;
6897
7624
  const containerRoot = containerDir(opts.name, opts.monocerosHome);
6898
- if (!existsSync12(containerRoot)) {
7625
+ if (!existsSync14(containerRoot)) {
6899
7626
  throw new Error(
6900
7627
  `Container '${opts.name}' is not materialised at ${containerRoot}. Run \`monoceros apply ${opts.name}\` first.`
6901
7628
  );
6902
7629
  }
6903
- const composePath = path17.join(containerRoot, ".devcontainer", "compose.yaml");
6904
- const isCompose = existsSync12(composePath);
7630
+ const composePath = path20.join(containerRoot, ".devcontainer", "compose.yaml");
7631
+ const isCompose = existsSync14(composePath);
6905
7632
  const parsedTarget = parseTargetArg(opts.target, config);
6906
7633
  const docker = opts.docker ?? defaultDockerExec;
6907
7634
  if (isCompose) {
@@ -6920,23 +7647,41 @@ async function resolveTunnelTarget(opts) {
6920
7647
  });
6921
7648
  }
6922
7649
  function parseTargetArg(raw, config) {
7650
+ const colon = raw.indexOf(":");
7651
+ if (colon > 0) {
7652
+ const name = raw.slice(0, colon);
7653
+ const port = Number(raw.slice(colon + 1));
7654
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
7655
+ throw new Error(
7656
+ `Invalid target '${raw}'. Use <service>:<port> with a numeric port (1\u201365535), a bare port number, or a configured service name.`
7657
+ );
7658
+ }
7659
+ findConfiguredService(config, name);
7660
+ return { kind: "service", service: name, port };
7661
+ }
6923
7662
  const asNumber = Number(raw);
6924
7663
  if (Number.isInteger(asNumber) && asNumber > 0 && asNumber < 65536) {
6925
7664
  return { kind: "port", port: asNumber };
6926
7665
  }
6927
- const entry2 = SERVICE_CATALOG[raw];
6928
- if (!entry2) {
6929
- const candidates = knownServices().join(", ");
7666
+ const match = findConfiguredService(config, raw);
7667
+ if (match.port === void 0) {
6930
7668
  throw new Error(
6931
- `Unknown service '${raw}'. Known services: ${candidates}. Or pass a port number (e.g. \`monoceros tunnel <name> 8080\`).`
7669
+ `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
7670
  );
6933
7671
  }
6934
- if (!config.services.includes(raw)) {
7672
+ return { kind: "service", service: raw, port: match.port };
7673
+ }
7674
+ function findConfiguredService(config, name) {
7675
+ const services = config.services.map(resolveService);
7676
+ const match = services.find((s) => s.name === name);
7677
+ if (!match) {
7678
+ const names = services.map((s) => s.name);
7679
+ const list = names.length > 0 ? names.join(", ") : "(none configured)";
6935
7680
  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.`
7681
+ `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
7682
  );
6938
7683
  }
6939
- return { kind: "service", service: raw, port: entry2.defaultPort };
7684
+ return match;
6940
7685
  }
6941
7686
  function resolveCompose2(args) {
6942
7687
  const network = `${composeProjectName(args.containerRoot)}_default`;
@@ -6945,7 +7690,7 @@ function resolveCompose2(args) {
6945
7690
  network,
6946
7691
  targetHost: args.parsedTarget.service,
6947
7692
  internalPort: args.parsedTarget.port,
6948
- display: `${args.name}/${args.parsedTarget.service}`
7693
+ display: `${args.name}/${args.parsedTarget.service}:${args.parsedTarget.port}`
6949
7694
  };
6950
7695
  }
6951
7696
  return {
@@ -7104,7 +7849,7 @@ function formatLocalPortHeldError(port, address, result) {
7104
7849
  // src/tunnel/run.ts
7105
7850
  var SOCAT_IMAGE = "alpine/socat:1.8.0.3";
7106
7851
  var defaultDockerSpawn = (args) => {
7107
- const child = spawn9("docker", args, {
7852
+ const child = spawn10("docker", args, {
7108
7853
  stdio: "inherit"
7109
7854
  });
7110
7855
  const exited = new Promise((resolve, reject) => {
@@ -7227,7 +7972,7 @@ var tunnelCommand = defineCommand29({
7227
7972
  },
7228
7973
  target: {
7229
7974
  type: "positional",
7230
- description: "Service name from the container yml (e.g. `postgres`) or an in-container port number (e.g. `8080`).",
7975
+ 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
7976
  required: true
7232
7977
  },
7233
7978
  "local-port": {