@getmonoceros/workbench 1.11.11 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/bin.js +2153 -1592
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -51,111 +51,23 @@ function shellQuote(arg) {
51
51
  return `'${arg.replace(/'/g, "'\\''")}'`;
52
52
  }
53
53
 
54
- // src/devcontainer/wsl-backend-bootstrap.ts
55
- import { spawnSync as spawnSync2 } from "child_process";
56
-
57
- // src/util/format.ts
58
- var ESC = "\x1B[";
59
- var ANSI_BOLD = `${ESC}1m`;
60
- var ANSI_UNDERLINE = `${ESC}4m`;
61
- var ANSI_CYAN = `${ESC}36m`;
62
- var ANSI_GREY = `${ESC}90m`;
63
- var ANSI_RESET = `${ESC}0m`;
64
- function makeWrap(isTty2) {
65
- return (s, ...codes) => isTty2 ? codes.join("") + s + ANSI_RESET : s;
66
- }
67
- function makePalette(isTty2) {
68
- const wrap = makeWrap(isTty2);
69
- return {
70
- bold: (s) => wrap(s, ANSI_BOLD),
71
- underline: (s) => wrap(s, ANSI_UNDERLINE),
72
- cyan: (s) => wrap(s, ANSI_CYAN),
73
- dim: (s) => wrap(s, ANSI_GREY),
74
- sectionLine: (label) => wrap(`\u25B8 ${label}`, ANSI_BOLD, ANSI_UNDERLINE)
75
- };
76
- }
77
- function colorsFor(stream) {
78
- return makePalette(stream.isTTY ?? false);
79
- }
80
- var stderrPalette = makePalette(process.stderr.isTTY ?? false);
81
- var bold = stderrPalette.bold;
82
- var underline = stderrPalette.underline;
83
- var cyan = stderrPalette.cyan;
84
- var dim = stderrPalette.dim;
85
- var sectionLine = stderrPalette.sectionLine;
86
-
87
- // src/devcontainer/wsl-backend-bootstrap.ts
88
- function bootstrapWslBackend(opts = {}) {
89
- const platform = opts.platform ?? process.platform;
90
- if (platform !== "win32") return;
91
- const listDistros = opts.wslDistros ?? defaultWslDistros;
92
- const raw = listDistros();
93
- if (raw !== null && hasWsl2Distro(raw)) return;
94
- const probe = opts.probe ?? defaultProbe2;
95
- if (probe("docker", ["--version"]) !== 0) return;
96
- const warn = opts.warn ?? ((m) => process.stderr.write(`${m}
97
- `));
98
- warn(formatWslBackendHint());
99
- }
100
- function hasWsl2Distro(raw) {
101
- const text = raw.split(String.fromCharCode(0)).join("");
102
- for (const line of text.split(/\r?\n/)) {
103
- const trimmed = line.trim();
104
- if (!trimmed) continue;
105
- if (/\bNAME\b/i.test(trimmed) && /\bVERSION\b/i.test(trimmed)) continue;
106
- const tokens = trimmed.replace(/^\*\s*/, "").split(/\s+/);
107
- if (tokens[tokens.length - 1] === "2") return true;
108
- }
109
- return false;
110
- }
111
- function formatWslBackendHint() {
112
- return [
113
- `Docker's daemon isn't reachable, and no WSL 2 distro is registered.`,
114
- `Docker Desktop runs on the WSL 2 backend, so without a distro it`,
115
- `can't start -- often shown as the misleading "Virtualization support`,
116
- `not detected" (even with virtualization enabled in BIOS).`,
117
- ``,
118
- `Fix it in an elevated PowerShell:`,
119
- ``,
120
- cyan(` wsl --set-default-version 2`),
121
- cyan(` wsl --update`),
122
- cyan(` wsl --install -d Ubuntu`),
123
- ``,
124
- `Then reboot and start Docker Desktop.`
125
- ].join("\n");
126
- }
127
- function defaultProbe2(cmd, args) {
128
- const result = spawnSync2(cmd, [...args], { stdio: "ignore", timeout: 1e4 });
129
- return result.status ?? 1;
130
- }
131
- function defaultWslDistros() {
132
- const result = spawnSync2("wsl", ["-l", "-v"], {
133
- encoding: "utf8",
134
- stdio: ["ignore", "pipe", "ignore"],
135
- env: { ...process.env, WSL_UTF8: "1" },
136
- timeout: 5e3
137
- });
138
- if (result.status !== 0 || typeof result.stdout !== "string") return null;
139
- return result.stdout;
140
- }
141
-
142
54
  // src/help.ts
143
- var ANSI_BOLD2 = "\x1B[1m";
144
- var ANSI_UNDERLINE2 = "\x1B[4m";
145
- var ANSI_CYAN2 = "\x1B[36m";
146
- var ANSI_GREY2 = "\x1B[90m";
147
- var ANSI_RESET2 = "\x1B[0m";
55
+ var ANSI_BOLD = "\x1B[1m";
56
+ var ANSI_UNDERLINE = "\x1B[4m";
57
+ var ANSI_CYAN = "\x1B[36m";
58
+ var ANSI_GREY = "\x1B[90m";
59
+ var ANSI_RESET = "\x1B[0m";
148
60
  function isTty() {
149
61
  return process.stdout.isTTY ?? false;
150
62
  }
151
63
  function color(text, ...codes) {
152
64
  if (!isTty()) return text;
153
- return codes.join("") + text + ANSI_RESET2;
65
+ return codes.join("") + text + ANSI_RESET;
154
66
  }
155
- var bold2 = (s) => color(s, ANSI_BOLD2);
156
- var underline2 = (s) => color(s, ANSI_UNDERLINE2);
157
- var cyan2 = (s) => color(s, ANSI_CYAN2);
158
- var grey = (s) => color(s, ANSI_GREY2);
67
+ var bold = (s) => color(s, ANSI_BOLD);
68
+ var underline = (s) => color(s, ANSI_UNDERLINE);
69
+ var cyan = (s) => color(s, ANSI_CYAN);
70
+ var grey = (s) => color(s, ANSI_GREY);
159
71
  var GROUPS = [
160
72
  { key: "lifecycle", label: "Container lifecycle" },
161
73
  { key: "run", label: "Run + inspect" },
@@ -248,7 +160,7 @@ function collectSubCommands(cmd) {
248
160
  function renderCommandsBlock(entries) {
249
161
  if (entries.length === 0) return [];
250
162
  const lines = [];
251
- lines.push(underline2(bold2("COMMANDS")));
163
+ lines.push(underline(bold("COMMANDS")));
252
164
  const byGroup = /* @__PURE__ */ new Map();
253
165
  for (const entry2 of entries) {
254
166
  const arr = byGroup.get(entry2.group) ?? [];
@@ -258,10 +170,10 @@ function renderCommandsBlock(entries) {
258
170
  const renderSection = (label, items) => {
259
171
  if (items.length === 0) return;
260
172
  lines.push("");
261
- lines.push(underline2(grey(label)));
173
+ lines.push(underline(grey(label)));
262
174
  lines.push("");
263
175
  const rows = items.map((e) => [
264
- cyan2(e.name),
176
+ cyan(e.name),
265
177
  e.description
266
178
  ]);
267
179
  lines.push(alignTable(rows, ""));
@@ -298,27 +210,27 @@ function renderUsageBlock(cmd, commandPath) {
298
210
  lines.push(grey(wrapText(header, terminalWidth(), "")));
299
211
  lines.push("");
300
212
  lines.push(
301
- `${underline2(bold2("USAGE"))} ${cyan2([fullName, ...usageTokens].join(" "))}`
213
+ `${underline(bold("USAGE"))} ${cyan([fullName, ...usageTokens].join(" "))}`
302
214
  );
303
215
  lines.push("");
304
216
  if (positionals.length > 0) {
305
- lines.push(underline2(bold2("ARGUMENTS")));
217
+ lines.push(underline(bold("ARGUMENTS")));
306
218
  lines.push("");
307
219
  const rows = positionals.map((p) => {
308
220
  const isRequired = p.required !== false && p.default === void 0;
309
- return [cyan2(p.name.toUpperCase()), renderArgDescription(p, isRequired)];
221
+ return [cyan(p.name.toUpperCase()), renderArgDescription(p, isRequired)];
310
222
  });
311
223
  lines.push(alignTable(rows, " "));
312
224
  lines.push("");
313
225
  }
314
226
  if (flags.length > 0) {
315
- lines.push(underline2(bold2("OPTIONS")));
227
+ lines.push(underline(bold("OPTIONS")));
316
228
  lines.push("");
317
229
  const rows = flags.map((f) => {
318
230
  const isRequired = f.required === true && f.default === void 0;
319
231
  const aliases = (Array.isArray(f.alias) ? f.alias : f.alias ? [f.alias] : []).map((a) => `-${a}`);
320
232
  const label = [...aliases, `--${f.name}`].join(", ") + renderValueHint(f);
321
- return [cyan2(label), renderArgDescription(f, isRequired)];
233
+ return [cyan(label), renderArgDescription(f, isRequired)];
322
234
  });
323
235
  lines.push(alignTable(rows, " "));
324
236
  lines.push("");
@@ -328,7 +240,7 @@ function renderUsageBlock(cmd, commandPath) {
328
240
  lines.push(line);
329
241
  }
330
242
  lines.push(
331
- `Use ${cyan2(`${fullName} <command> --help`)} for more information about a command.`
243
+ `Use ${cyan(`${fullName} <command> --help`)} for more information about a command.`
332
244
  );
333
245
  lines.push("");
334
246
  }
@@ -339,25 +251,25 @@ function detectHelpRequest(argv, main2) {
339
251
  const separatorIdx = argv.indexOf("--");
340
252
  if (helpIdx === -1) return null;
341
253
  if (separatorIdx !== -1 && separatorIdx < helpIdx) return null;
342
- const path18 = [];
254
+ const path21 = [];
343
255
  const tokens = argv.slice(
344
256
  0,
345
257
  separatorIdx === -1 ? argv.length : separatorIdx
346
258
  );
347
259
  let cursor = main2;
348
260
  const mainName = (main2.meta ?? {}).name ?? "monoceros";
349
- path18.push(mainName);
261
+ path21.push(mainName);
350
262
  for (const tok of tokens) {
351
263
  if (tok.startsWith("-")) continue;
352
264
  const subs = cursor.subCommands ?? {};
353
265
  if (tok in subs) {
354
266
  cursor = subs[tok];
355
- path18.push(tok);
267
+ path21.push(tok);
356
268
  continue;
357
269
  }
358
270
  break;
359
271
  }
360
- return { path: path18, cmd: cursor };
272
+ return { path: path21, cmd: cursor };
361
273
  }
362
274
  async function maybeRenderHelp(argv, main2) {
363
275
  const hit = detectHelpRequest(argv, main2);
@@ -393,13 +305,13 @@ import { defineCommand as defineCommand30 } from "citty";
393
305
 
394
306
  // src/commands/add-apt-packages.ts
395
307
  import { defineCommand } from "citty";
396
- import { consola as consola3 } from "consola";
308
+ import { consola as consola2 } from "consola";
397
309
 
398
310
  // src/modify/index.ts
399
311
  import { promises as fs8 } from "fs";
400
- import { consola as consola2 } from "consola";
312
+ import { consola } from "consola";
401
313
  import { createPatch } from "diff";
402
- import path10 from "path";
314
+ import path9 from "path";
403
315
 
404
316
  // src/config/io.ts
405
317
  import { promises as fs } from "fs";
@@ -492,6 +404,68 @@ var RoutingSchema = z.object({
492
404
  ports: z.array(PortEntrySchema).default([]),
493
405
  vscodeAutoForward: z.boolean().optional()
494
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
+ });
495
469
  var ExternalServicesSchema = z.object({
496
470
  postgres: z.string().regex(
497
471
  POSTGRES_URL_RE,
@@ -518,7 +492,7 @@ var SolutionConfigSchema = z.object({
518
492
  "Invalid install URL. Must start with 'https://' and contain only URL-safe characters (no shell metacharacters)."
519
493
  )
520
494
  ).default([]),
521
- services: z.array(z.string().min(1)).default([]),
495
+ services: z.array(ServiceObjectSchema).default([]),
522
496
  repos: z.array(RepoEntrySchema).default([]),
523
497
  routing: RoutingSchema.optional(),
524
498
  externalServices: ExternalServicesSchema.default({}),
@@ -637,6 +611,9 @@ function containerConfigsDir(home = monocerosHome()) {
637
611
  function containerConfigPath(name, home = monocerosHome()) {
638
612
  return path.join(containerConfigsDir(home), `${name}.yml`);
639
613
  }
614
+ function containerEnvPath(name, home = monocerosHome()) {
615
+ return path.join(containerConfigsDir(home), `${name}.env`);
616
+ }
640
617
  function containersDir(home = monocerosHome()) {
641
618
  return path.join(home, "container");
642
619
  }
@@ -657,813 +634,667 @@ function prettyPath(p) {
657
634
  return p;
658
635
  }
659
636
 
660
- // src/devcontainer/credentials.ts
661
- import { spawn } from "child_process";
662
- import { promises as fs2 } from "fs";
637
+ // src/config/env-file.ts
638
+ import { existsSync as existsSync2, readFileSync, promises as fsp } from "fs";
663
639
  import path2 from "path";
664
- var realGitCredentialFill = (input) => {
665
- return new Promise((resolve, reject) => {
666
- const child = spawn("git", ["credential", "fill"], {
667
- stdio: ["pipe", "pipe", "inherit"],
668
- env: {
669
- ...process.env,
670
- GIT_TERMINAL_PROMPT: "0"
671
- }
672
- });
673
- let stdout = "";
674
- child.stdout.on("data", (chunk) => {
675
- stdout += chunk.toString();
676
- });
677
- child.on("error", reject);
678
- child.on("exit", (code) => resolve({ stdout, exitCode: code ?? 0 }));
679
- child.stdin.write(input);
680
- child.stdin.end();
681
- });
682
- };
683
- var realGitCredentialApprove = (input) => {
684
- return new Promise((resolve, reject) => {
685
- const child = spawn("git", ["credential", "approve"], {
686
- stdio: ["pipe", "ignore", "inherit"],
687
- env: {
688
- ...process.env,
689
- GIT_TERMINAL_PROMPT: "0"
690
- }
691
- });
692
- child.on("error", reject);
693
- child.on("exit", () => resolve());
694
- child.stdin.write(input);
695
- child.stdin.end();
696
- });
697
- };
698
- function resolveProvider(host, explicit) {
699
- const canonical = KNOWN_PROVIDER_HOSTS[host.toLowerCase()];
700
- if (canonical) return canonical;
701
- return explicit ?? "unknown";
702
- }
703
- function uniqueHttpsHosts(repos) {
704
- const byHost = /* @__PURE__ */ new Map();
705
- for (const repo of repos) {
706
- if (!repo.url.startsWith("https://")) continue;
707
- let host;
708
- try {
709
- host = new URL(repo.url).hostname;
710
- } catch {
711
- continue;
712
- }
713
- if (byHost.has(host)) continue;
714
- byHost.set(host, { host, provider: resolveProvider(host, repo.provider) });
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;
715
654
  }
716
- return [...byHost.values()];
655
+ return out;
717
656
  }
718
- var BREW_INSTALL_COMMAND = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"';
719
- function installCommandForOS(opts) {
720
- const withBrewBootstrap = (cmd) => [
721
- "",
722
- cyan(BREW_INSTALL_COMMAND),
723
- cyan(cmd),
724
- "",
725
- dim("(Skip the first line if you already have Homebrew.)")
726
- ].join("\n");
727
- switch (process.platform) {
728
- case "darwin":
729
- return withBrewBootstrap(opts.brew);
730
- case "win32":
731
- return ["", cyan(opts.winget)].join("\n");
732
- default:
733
- if (opts.linuxBrew) return withBrewBootstrap(opts.linuxBrew);
734
- return `See ${opts.linuxDocsUrl} for package instructions.`;
735
- }
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
+ `);
736
674
  }
737
- function providerSetupHint(host, provider) {
738
- if (provider === "github") {
739
- const isSaas = host.toLowerCase() === "github.com";
740
- const hostArg = isSaas ? "" : ` --hostname ${host}`;
741
- const install = installCommandForOS({
742
- brew: "brew install gh",
743
- winget: "winget install --id GitHub.cli",
744
- linuxBrew: "brew install gh",
745
- linuxDocsUrl: "https://github.com/cli/cli#installation"
746
- });
747
- return {
748
- title: `${host} \u2014 GitHub`,
749
- body: [
750
- "Install the GitHub CLI:",
751
- install,
752
- "",
753
- "Then run once:",
754
- cyan(`gh auth login${hostArg}`),
755
- cyan(`gh auth setup-git${hostArg}`),
756
- "",
757
- "`gh auth login` walks through OAuth in your browser.",
758
- "`gh auth setup-git` wires gh into git as a credential helper."
759
- ].join("\n")
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;
760
694
  };
761
- }
762
- if (provider === "gitlab") {
763
- const isSaas = host.toLowerCase() === "gitlab.com";
764
- const hostArg = isSaas ? "" : ` --hostname ${host}`;
765
- const install = installCommandForOS({
766
- brew: "brew install glab",
767
- winget: "winget install --id GLab.GLab",
768
- linuxBrew: "brew install glab",
769
- linuxDocsUrl: "https://gitlab.com/gitlab-org/cli#installation"
770
- });
771
- return {
772
- title: `${host} \u2014 GitLab`,
773
- body: [
774
- "Install the GitLab CLI (glab):",
775
- install,
776
- "",
777
- "Then run once:",
778
- cyan(`glab auth login${hostArg}`),
779
- "",
780
- "Choose `HTTPS` when asked for git-protocol, then accept",
781
- '"Authenticate Git with your GitLab credentials" \u2014 glab',
782
- "configures itself as the git credential helper."
783
- ].join("\n")
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}]`))
784
702
  };
785
- }
786
- if (provider === "bitbucket") {
787
- const isCloud = host.toLowerCase() === "bitbucket.org";
788
- if (isCloud) {
789
- return {
790
- title: `${host} \u2014 Bitbucket Cloud`,
791
- body: [
792
- "Bitbucket has no first-party CLI for git-credentials, so this",
793
- "is a manual one-time setup. Generate an Atlassian API token at",
794
- "https://id.atlassian.com/manage-profile/security/api-tokens",
795
- "",
796
- "Then store it via your OS credential helper:",
797
- cyan(
798
- `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-atlassian-email>\\npassword=<token>\\n'`
799
- )
800
- ].join("\n")
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") } : {}
801
714
  };
802
715
  }
803
- return {
804
- title: `${host} \u2014 Bitbucket Data Center`,
805
- body: [
806
- "Bitbucket has no first-party CLI for git-credentials, so this",
807
- "is a manual one-time setup. Generate a personal HTTP access",
808
- `token in your Bitbucket UI: profile picture (top right on ${host})`,
809
- "\u2192 Manage account \u2192 HTTP access tokens \u2192 Create token. Give it",
810
- "at least repo-read + repo-write scopes for the repos you need.",
811
- "",
812
- "Then store it via your OS credential helper:",
813
- cyan(
814
- `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-bitbucket-username>\\npassword=<token>\\n'`
815
- )
816
- ].join("\n")
817
- };
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;
818
737
  }
819
- return {
820
- title: `${host} \u2014 Gitea`,
821
- body: [
822
- "Gitea has no first-party CLI helper for git-credentials (the",
823
- "`tea` CLI logs into its own config, not into your git credential",
824
- "helper), so this is a manual one-time setup. Generate an access",
825
- `token in your Gitea UI: profile picture (top right on ${host}) \u2192`,
826
- 'Settings \u2192 Applications \u2192 "Generate New Token". Give it at',
827
- "least the `read:repository` scope (add `write:repository` if you",
828
- "need push from the container).",
829
- "",
830
- "Then store it via your OS credential helper:",
831
- cyan(
832
- `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-gitea-username>\\npassword=<token>\\n'`
833
- )
834
- ].join("\n")
835
- };
738
+ return { features: out, missing };
836
739
  }
837
- function parseCredentialFillOutput(output) {
838
- const result = {};
839
- for (const line of output.split("\n")) {
840
- const eqIdx = line.indexOf("=");
841
- if (eqIdx <= 0) continue;
842
- const key = line.slice(0, eqIdx);
843
- const value = line.slice(eqIdx + 1);
844
- if (key === "username") result.username = value;
845
- if (key === "password") result.password = value;
740
+ function buildEnvStub(name) {
741
+ return `# Secrets and values for \${VAR} references in ${name}.yml.
742
+ `;
743
+ }
744
+ async function ensureEnvVars(envPath, name, vars) {
745
+ const exists = existsSync2(envPath);
746
+ let content = exists ? readFileSync(envPath, "utf8") : buildEnvStub(name);
747
+ const present = new Set(Object.keys(parseEnvFile(content)));
748
+ const added = [...new Set(vars)].filter((v) => !present.has(v));
749
+ if (!exists || added.length > 0) {
750
+ if (content.length > 0 && !content.endsWith("\n")) content += "\n";
751
+ for (const v of added) content += `${v}=
752
+ `;
753
+ await fsp.mkdir(path2.dirname(envPath), { recursive: true });
754
+ await fsp.writeFile(envPath, content);
846
755
  }
847
- return result;
756
+ return { created: !exists, added };
848
757
  }
849
- function formatCredentialLine(host, username, password) {
850
- const encUser = encodeURIComponent(username);
851
- const encPass = encodeURIComponent(password);
852
- return `https://${encUser}:${encPass}@${host}`;
758
+ function formatMissingVarsError(missing, envPathPretty) {
759
+ const lines = missing.map((m) => ` - \${${m.name}} (${m.location})`);
760
+ const uniqueNames = [...new Set(missing.map((m) => m.name))];
761
+ return `Unresolved \${VAR} references in the container yml:
762
+ ${lines.join("\n")}
763
+
764
+ Define them in ${envPathPretty}, e.g.
765
+ ` + uniqueNames.map((n) => ` ${n}=<value>`).join("\n");
853
766
  }
854
- async function collectGitCredentials(devContainerRoot, hosts, options = {}) {
855
- const credsDir = path2.join(devContainerRoot, ".monoceros");
856
- const credentialsPath = path2.join(credsDir, "git-credentials");
857
- const spawnFn = options.spawn ?? realGitCredentialFill;
858
- const approveFn = options.approve ?? realGitCredentialApprove;
859
- const logger = options.logger ?? { info: () => {
860
- }, warn: () => {
861
- } };
862
- const lines = [];
863
- const perHost = [];
864
- for (const { host, provider } of hosts) {
865
- if (provider === "unknown") {
866
- perHost.push({
867
- host,
868
- provider: "github",
869
- // placeholder — never rendered because pre-flight already bailed
870
- status: "no-credentials",
871
- detail: "provider not declared (internal: should not reach here)"
872
- });
873
- continue;
874
- }
875
- logger.info(`Fetching credentials for ${host} from host git\u2026`);
876
- const input = `protocol=https
877
- host=${host}
878
767
 
879
- `;
880
- let result;
881
- try {
882
- result = await spawnFn(input);
883
- } catch (err) {
884
- const detail = err instanceof Error ? err.message : String(err);
885
- perHost.push({ host, provider, status: "spawn-error", detail });
886
- continue;
887
- }
888
- if (result.exitCode !== 0) {
889
- perHost.push({
890
- host,
891
- provider,
892
- status: "non-zero-exit",
893
- detail: `exit code ${result.exitCode}`
894
- });
895
- continue;
896
- }
897
- const { username, password } = parseCredentialFillOutput(result.stdout);
898
- if (!username || !password) {
899
- perHost.push({
900
- host,
901
- provider,
902
- status: "no-credentials",
903
- detail: "host credential helper returned no username/password"
904
- });
905
- continue;
906
- }
907
- lines.push(formatCredentialLine(host, username, password));
908
- perHost.push({ host, provider, status: "ok", detail: "" });
909
- const approveInput = `protocol=https
910
- host=${host}
911
- username=${username}
912
- password=${password}
913
-
914
- `;
915
- try {
916
- await approveFn(approveInput);
917
- } catch {
918
- }
919
- }
920
- await fs2.mkdir(credsDir, { recursive: true });
921
- await fs2.writeFile(
922
- credentialsPath,
923
- lines.join("\n") + (lines.length > 0 ? "\n" : ""),
924
- {
925
- mode: 384
768
+ // src/init/feature-doc.ts
769
+ function buildFeatureHeaderLines(summary, width) {
770
+ const paragraphs = buildHeaderParagraphs(summary);
771
+ const wrapped = [];
772
+ for (const para of paragraphs) {
773
+ for (const line of wrapToComment(para, width)) {
774
+ wrapped.push(line);
926
775
  }
927
- );
928
- return {
929
- hostsWritten: lines.length,
930
- hostsSkipped: perHost.filter((p) => p.status !== "ok").length,
931
- perHost,
932
- credentialsPath
933
- };
934
- }
935
- function formatMissingCredentialsError(missing) {
936
- if (missing.length === 1) {
937
- const m = missing[0];
938
- const hint = providerSetupHint(m.host, m.provider);
939
- return [
940
- `Missing Git credentials: ${hint.title}`,
941
- "",
942
- hint.body,
943
- "",
944
- `Then re-run ${cyan("monoceros apply")}.`
945
- ].join("\n");
946
776
  }
947
- const lines = [
948
- `Missing Git credentials for ${missing.length} hosts:`,
949
- ""
950
- ];
951
- for (const m of missing) {
952
- const hint = providerSetupHint(m.host, m.provider);
953
- lines.push(hint.title);
954
- lines.push("");
955
- lines.push(hint.body);
956
- lines.push("");
957
- }
958
- lines.push(`Then re-run ${cyan("monoceros apply")}.`);
959
- return lines.join("\n");
960
- }
961
- function formatUnknownProviderError(hosts) {
962
- const sorted = [...new Set(hosts)].sort();
963
- const lines = [
964
- sorted.length === 1 ? `Unknown Git provider for host ${sorted[0]}.` : `Unknown Git provider for ${sorted.length} hosts: ${sorted.join(", ")}.`,
965
- "",
966
- "Monoceros auto-detects only github.com / gitlab.com / bitbucket.org.",
967
- "For any other host (self-hosted GitLab, Gitea, Bitbucket Server, \u2026)",
968
- "declare the provider explicitly in the yml. Edit the repo entry:",
969
- "",
970
- cyan(" repos:"),
971
- cyan(` - url: https://${sorted[0]}/\u2026`),
972
- cyan(" provider: gitlab # or: github, bitbucket, gitea"),
973
- "",
974
- `Or re-add with ${cyan("monoceros add-repo <name> <url> --provider=<github|gitlab|bitbucket|gitea>")}.`
975
- ];
976
- return lines.join("\n");
977
- }
978
-
979
- // src/devcontainer/locate-running.ts
980
- import { spawn as spawn5 } from "child_process";
981
-
982
- // src/devcontainer/compose.ts
983
- import { spawn as spawn4 } from "child_process";
984
- import { existsSync as existsSync2 } from "fs";
985
- import path5 from "path";
986
- import { consola } from "consola";
987
-
988
- // src/proxy/index.ts
989
- import { spawn as spawn2 } from "child_process";
990
- import { promises as fs3 } from "fs";
991
- import path3 from "path";
992
- var PROXY_CONTAINER_NAME = "monoceros-proxy";
993
- var PROXY_NETWORK_NAME = "monoceros-proxy";
994
- var TRAEFIK_IMAGE = "traefik:v3.3";
995
- var defaultDockerExec = (args) => {
996
- return new Promise((resolve, reject) => {
997
- const child = spawn2("docker", args, {
998
- stdio: ["ignore", "pipe", "pipe"]
999
- });
1000
- let stdout = "";
1001
- let stderr = "";
1002
- child.stdout.on("data", (chunk) => {
1003
- stdout += chunk.toString();
1004
- });
1005
- child.stderr.on("data", (chunk) => {
1006
- stderr += chunk.toString();
1007
- });
1008
- child.on("error", reject);
1009
- child.on(
1010
- "exit",
1011
- (code) => resolve({ stdout, stderr, exitCode: code ?? 0 })
1012
- );
1013
- });
1014
- };
1015
- var realDocker = defaultDockerExec;
1016
- function proxyDynamicDir(home) {
1017
- return path3.join(home ?? monocerosHome(), "traefik", "dynamic");
777
+ return wrapped;
1018
778
  }
1019
- async function kickProxyReload(opts = {}) {
1020
- if (process.platform !== "win32") return;
1021
- const docker = opts.docker ?? realDocker;
1022
- const inspect = await docker([
1023
- "inspect",
1024
- "--format",
1025
- "{{.State.Running}}",
1026
- PROXY_CONTAINER_NAME
1027
- ]);
1028
- if (inspect.exitCode !== 0) return;
1029
- if (inspect.stdout.trim() !== "true") return;
1030
- await docker(["restart", PROXY_CONTAINER_NAME]);
779
+ function buildFeatureHeaderCommentBefore(summary, width) {
780
+ const lines = buildFeatureHeaderLines(summary, width);
781
+ return lines.map((l) => ` ${l}`).join("\n");
1031
782
  }
1032
- async function ensureProxy(opts = {}) {
1033
- const docker = opts.docker ?? realDocker;
1034
- const dyn = proxyDynamicDir(opts.monocerosHome);
1035
- await fs3.mkdir(dyn, { recursive: true });
1036
- const netInspect = await docker(["network", "inspect", PROXY_NETWORK_NAME]);
1037
- if (netInspect.exitCode !== 0) {
1038
- const create = await docker(["network", "create", PROXY_NETWORK_NAME]);
1039
- if (create.exitCode !== 0) {
1040
- throw new Error(
1041
- `Could not create docker network ${PROXY_NETWORK_NAME}: ${create.stderr.trim() || `exit ${create.exitCode}`}`
1042
- );
1043
- }
1044
- }
1045
- const state = await docker([
1046
- "inspect",
1047
- "--format",
1048
- "{{.State.Running}}",
1049
- PROXY_CONTAINER_NAME
1050
- ]);
1051
- if (state.exitCode === 0) {
1052
- if (state.stdout.trim() === "true") return;
1053
- const start = await docker(["start", PROXY_CONTAINER_NAME]);
1054
- if (start.exitCode !== 0) {
1055
- throw new Error(
1056
- `Could not start existing ${PROXY_CONTAINER_NAME} container: ${start.stderr.trim() || `exit ${start.exitCode}`}`
1057
- );
1058
- }
1059
- return;
1060
- }
1061
- const hostPort = opts.hostPort ?? 80;
1062
- const run = await docker([
1063
- "run",
1064
- "-d",
1065
- "--name",
1066
- PROXY_CONTAINER_NAME,
1067
- "--network",
1068
- PROXY_NETWORK_NAME,
1069
- "-p",
1070
- `${hostPort}:80`,
1071
- "-v",
1072
- `${dyn}:/etc/traefik/dynamic:ro`,
1073
- "--label",
1074
- "monoceros.role=proxy",
1075
- TRAEFIK_IMAGE,
1076
- "--entrypoints.web.address=:80",
1077
- "--providers.file.directory=/etc/traefik/dynamic",
1078
- "--providers.file.watch=true",
1079
- "--providers.docker=false",
1080
- "--api.dashboard=false",
1081
- "--log.level=INFO"
1082
- ]);
1083
- if (run.exitCode !== 0) {
1084
- throw new Error(
1085
- `Could not start ${PROXY_CONTAINER_NAME}: ${run.stderr.trim() || `exit ${run.exitCode}`}`
1086
- );
783
+ function buildHeaderParagraphs(summary) {
784
+ if (!summary) return [];
785
+ const out = [];
786
+ const tagline = summary.name?.trim();
787
+ const description = summary.description?.trim();
788
+ if (tagline && description) {
789
+ out.push(`${tagline} \u2014 ${description}`);
790
+ } else if (tagline) {
791
+ out.push(tagline);
792
+ } else if (description) {
793
+ out.push(description);
1087
794
  }
1088
- opts.logger?.info(
1089
- `Started ${PROXY_CONTAINER_NAME} (Traefik on :${hostPort}).`
1090
- );
1091
- }
1092
- async function maybeStopProxy(opts = {}) {
1093
- const docker = opts.docker ?? realDocker;
1094
- const logger = opts.logger;
1095
- const inspect = await docker([
1096
- "network",
1097
- "inspect",
1098
- PROXY_NETWORK_NAME,
1099
- "--format",
1100
- "{{range $k, $v := .Containers}}{{$v.Name}}\n{{end}}"
1101
- ]);
1102
- if (inspect.exitCode !== 0) {
1103
- return;
795
+ for (const note of summary.usageNotes) {
796
+ const trimmed = note.trim();
797
+ if (trimmed.length > 0) out.push(trimmed);
1104
798
  }
1105
- const others = inspect.stdout.split("\n").map((n) => n.trim()).filter((n) => n.length > 0 && n !== PROXY_CONTAINER_NAME);
1106
- if (others.length > 0) return;
1107
- await docker(["rm", "-f", PROXY_CONTAINER_NAME]);
1108
- const netRm = await docker(["network", "rm", PROXY_NETWORK_NAME]);
1109
- if (netRm.exitCode !== 0) {
1110
- logger?.warn?.(
1111
- `Could not remove docker network ${PROXY_NETWORK_NAME}: ${netRm.stderr.trim() || `exit ${netRm.exitCode}`}`
1112
- );
1113
- return;
799
+ if (summary.optionHints.length > 0) {
800
+ const parts = summary.optionHints.map((key) => {
801
+ const desc = summary.optionDescriptions[key];
802
+ const short = desc ? shortenOptionDescription(desc) : void 0;
803
+ return short ? `${key} (${short})` : key;
804
+ });
805
+ out.push(`Options: ${parts.join(", ")}.`);
1114
806
  }
1115
- logger?.info(
1116
- `Stopped ${PROXY_CONTAINER_NAME} (no dev-containers with ports left).`
1117
- );
1118
- }
1119
-
1120
- // src/util/mask-secrets.ts
1121
- import { Transform } from "stream";
1122
- var PATTERNS = [
1123
- // Atlassian Cloud API token. Starts with literal `ATATT3xFf` plus
1124
- // a long URL-safe-base64 tail. Tightened to that prefix to avoid
1125
- // matching unrelated all-caps words.
1126
- { name: "atlassian-api", re: /ATATT3xFf[A-Za-z0-9+/=_-]{20,}/g },
1127
- // Bitbucket Cloud app password.
1128
- { name: "bitbucket-app", re: /ATBB[A-Za-z0-9+/=_-]{20,}/g },
1129
- // GitHub PAT (classic), OAuth, user, server, refresh — all share
1130
- // the `gh<lower-letter>_<base62>` shape per GitHub's token format.
1131
- { name: "github-token", re: /gh[a-z]_[A-Za-z0-9]{20,}/g },
1132
- // GitHub fine-grained PAT.
1133
- { name: "github-pat", re: /github_pat_[A-Za-z0-9_]{20,}/g },
1134
- // Anthropic API key.
1135
- { name: "anthropic-api", re: /sk-ant-[A-Za-z0-9_-]{20,}/g }
1136
- ];
1137
- function maskSecrets(text) {
1138
- let result = text;
1139
- for (const { re } of PATTERNS) {
1140
- result = result.replace(re, maskOne);
807
+ if (summary.documentationURL) {
808
+ out.push(`See ${summary.documentationURL} for further information.`);
1141
809
  }
1142
- return result;
810
+ return out;
1143
811
  }
1144
- function maskOne(token) {
1145
- if (token.length <= 12) return token;
1146
- return `${token.slice(0, 5)}\u2026${token.slice(-6)}`;
812
+ function shortenOptionDescription(desc) {
813
+ const firstSentence = desc.split(/(?<=[.!?])\s+/)[0]?.trim() ?? desc.trim();
814
+ return firstSentence.replace(/[.!?]+$/, "").trim();
1147
815
  }
1148
- function createSecretMaskStream() {
1149
- let buffer = "";
1150
- return new Transform({
1151
- decodeStrings: true,
1152
- transform(chunk, _enc, cb) {
1153
- const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
1154
- buffer += text;
1155
- const lastNewline = buffer.lastIndexOf("\n");
1156
- if (lastNewline === -1) {
1157
- cb(null);
1158
- return;
1159
- }
1160
- const flushable = buffer.slice(0, lastNewline + 1);
1161
- buffer = buffer.slice(lastNewline + 1);
1162
- cb(null, maskSecrets(flushable));
1163
- },
1164
- flush(cb) {
1165
- if (buffer.length > 0) {
1166
- const tail = maskSecrets(buffer);
1167
- buffer = "";
1168
- cb(null, tail);
1169
- return;
1170
- }
1171
- cb(null);
816
+ function wrapToComment(text, width) {
817
+ const words = text.split(/\s+/).filter((w) => w.length > 0);
818
+ if (words.length === 0) return [""];
819
+ const usable = Math.max(width, 20);
820
+ const lines = [];
821
+ let current = "";
822
+ for (const w of words) {
823
+ if (current.length === 0) {
824
+ current = w;
825
+ continue;
826
+ }
827
+ if (current.length + 1 + w.length <= usable) {
828
+ current += " " + w;
829
+ } else {
830
+ lines.push(current);
831
+ current = w;
1172
832
  }
833
+ }
834
+ if (current.length > 0) lines.push(current);
835
+ return lines;
836
+ }
837
+ var FEATURE_HEADER_WIDTH = 76 - 2;
838
+ function featureOptionVarName(ref, optionKey) {
839
+ const leaf = ref.split("/").pop() ?? ref;
840
+ const id = leaf.split("@")[0].split(":")[0];
841
+ const idSnake = id.replace(/[^A-Za-z0-9]+/g, "_").toUpperCase();
842
+ const optSnake = optionKey.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").toUpperCase();
843
+ return `${idSnake}_${optSnake}`;
844
+ }
845
+ function featureOptionHints(summary, ref, activeKeys = []) {
846
+ return (summary?.optionHints ?? []).filter((key) => !activeKeys.includes(key)).map((key) => {
847
+ const envVar = featureOptionVarName(ref, key);
848
+ return { key, envVar, placeholder: `\${${envVar}}` };
1173
849
  });
1174
850
  }
1175
851
 
1176
- // src/devcontainer/cli.ts
1177
- import { spawn as spawn3 } from "child_process";
1178
- import { readFileSync } from "fs";
1179
- import { createRequire } from "module";
1180
- import path4 from "path";
852
+ // src/init/manifest.ts
853
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
854
+ import path3 from "path";
1181
855
 
1182
- // src/devcontainer/runtime-pull-hint.ts
1183
- import { Transform as Transform2 } from "stream";
1184
- var RUNTIME_PULL_MARKER = "No manifest found for ghcr.io/getmonoceros/monoceros-runtime";
1185
- var RUNTIME_PULL_HINT = 'Downloading the Monoceros runtime image now -- expected on first apply, takes ~1-2 min (Docker pulls the multi-arch base with no progress output). The "No manifest found" line above is harmless. Please wait...';
1186
- function createRuntimePullHintStream(state) {
1187
- let buffer = "";
1188
- const appendHintIfMarker = (block) => {
1189
- if (state.hinted || !block.includes(RUNTIME_PULL_MARKER)) return block;
1190
- state.hinted = true;
1191
- return `${block}${dim(`(i) ${RUNTIME_PULL_HINT}`)}
1192
- `;
1193
- };
1194
- return new Transform2({
1195
- decodeStrings: true,
1196
- transform(chunk, _enc, cb) {
1197
- const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
1198
- buffer += text;
1199
- const lastNewline = buffer.lastIndexOf("\n");
1200
- if (lastNewline === -1) {
1201
- cb(null);
1202
- return;
1203
- }
1204
- const flushable = buffer.slice(0, lastNewline + 1);
1205
- buffer = buffer.slice(lastNewline + 1);
1206
- cb(null, appendHintIfMarker(flushable));
1207
- },
1208
- flush(cb) {
1209
- if (buffer.length === 0) {
1210
- cb(null);
1211
- return;
1212
- }
1213
- const tail = buffer;
1214
- buffer = "";
1215
- cb(null, appendHintIfMarker(tail));
1216
- }
1217
- });
856
+ // src/util/ref.ts
857
+ var FEATURE_NAME_CHARSET = "[a-z0-9._-]+";
858
+ var FEATURE_TAG_CHARSET = "[a-z0-9._-]+";
859
+ var MONOCEROS_FEATURE_RE = new RegExp(
860
+ `^ghcr\\.io/getmonoceros/monoceros-features/(${FEATURE_NAME_CHARSET}):${FEATURE_TAG_CHARSET}$`
861
+ );
862
+ var DEPRECATED_MONOCEROS_FEATURE_RE = new RegExp(
863
+ `^ghcr\\.io/monoceros/features/(${FEATURE_NAME_CHARSET}):(${FEATURE_TAG_CHARSET})$`
864
+ );
865
+ function matchMonocerosFeature(ref) {
866
+ const match = MONOCEROS_FEATURE_RE.exec(ref);
867
+ if (!match) return null;
868
+ return { name: match[1] };
869
+ }
870
+ function migrateDeprecatedFeatureRef(ref) {
871
+ const match = DEPRECATED_MONOCEROS_FEATURE_RE.exec(ref);
872
+ if (!match) return null;
873
+ const name = match[1];
874
+ const tag = match[2];
875
+ return `ghcr.io/getmonoceros/monoceros-features/${name}:${tag}`;
1218
876
  }
1219
877
 
1220
- // src/devcontainer/cli.ts
1221
- var require_ = createRequire(import.meta.url);
1222
- var cachedBinaryPath = null;
1223
- function devcontainerCliPath() {
1224
- if (cachedBinaryPath) return cachedBinaryPath;
1225
- const pkgJsonPath = require_.resolve("@devcontainers/cli/package.json");
1226
- const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
1227
- const binEntry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.devcontainer ?? "";
1228
- if (!binEntry) {
1229
- throw new Error("Could not resolve @devcontainers/cli bin entry.");
878
+ // src/init/manifest.ts
879
+ function resolveManifestPath(name, checkoutRoot) {
880
+ if (checkoutRoot) {
881
+ const checkoutPath = path3.join(
882
+ checkoutRoot,
883
+ "images",
884
+ "features",
885
+ name,
886
+ "devcontainer-feature.json"
887
+ );
888
+ if (existsSync3(checkoutPath)) return checkoutPath;
1230
889
  }
1231
- cachedBinaryPath = path4.resolve(path4.dirname(pkgJsonPath), binEntry);
1232
- return cachedBinaryPath;
890
+ const bundlePath = path3.join(
891
+ bundledFeaturesDir(),
892
+ name,
893
+ "devcontainer-feature.json"
894
+ );
895
+ if (existsSync3(bundlePath)) return bundlePath;
896
+ return null;
1233
897
  }
1234
- var spawnDevcontainer = (args, cwd, options = {}) => {
1235
- const binPath = devcontainerCliPath();
1236
- return new Promise((resolve, reject) => {
1237
- if (options.interactive) {
1238
- const child2 = spawn3(process.execPath, [binPath, ...args], {
1239
- cwd,
1240
- stdio: "inherit"
1241
- });
1242
- child2.on("error", reject);
1243
- child2.on("exit", (code) => resolve(code ?? 0));
1244
- return;
1245
- }
1246
- const child = spawn3(process.execPath, [binPath, ...args], {
1247
- cwd,
1248
- stdio: ["ignore", "pipe", "pipe"]
1249
- });
1250
- if (options.quiet) {
1251
- const stdoutChunks = [];
1252
- const stderrChunks = [];
1253
- child.stdout?.on("data", (chunk) => stdoutChunks.push(chunk));
1254
- child.stderr?.on("data", (chunk) => stderrChunks.push(chunk));
1255
- child.on("error", reject);
1256
- child.on("exit", (code) => {
1257
- const exitCode = code ?? 0;
1258
- if (exitCode !== 0) {
1259
- process.stderr.write(
1260
- maskSecrets(Buffer.concat(stderrChunks).toString("utf8"))
1261
- );
1262
- process.stderr.write(
1263
- maskSecrets(Buffer.concat(stdoutChunks).toString("utf8"))
1264
- );
898
+ function loadFeatureManifestSummary(ref, checkoutRoot = workbenchCheckoutRoot()) {
899
+ const match = matchMonocerosFeature(ref);
900
+ if (!match) return void 0;
901
+ const manifestPath = resolveManifestPath(match.name, checkoutRoot);
902
+ if (!manifestPath) return void 0;
903
+ try {
904
+ const text = readFileSync2(manifestPath, "utf8");
905
+ const parsed = JSON.parse(text);
906
+ const rawHints = parsed["x-monoceros"]?.optionHints;
907
+ const optionHints = Array.isArray(rawHints) ? rawHints.filter(
908
+ (x) => typeof x === "string" && x.length > 0
909
+ ) : [];
910
+ const rawNotes = parsed["x-monoceros"]?.usageNotes;
911
+ const usageNotes = Array.isArray(rawNotes) ? rawNotes.filter(
912
+ (x) => typeof x === "string" && x.length > 0
913
+ ) : [];
914
+ const optionDescriptions = {};
915
+ const optionTypes = {};
916
+ const optionNames = [];
917
+ if (parsed.options) {
918
+ for (const [key, opt] of Object.entries(parsed.options)) {
919
+ if (!opt || typeof opt !== "object") continue;
920
+ optionNames.push(key);
921
+ if (typeof opt.description === "string" && opt.description.length > 0) {
922
+ optionDescriptions[key] = opt.description;
1265
923
  }
1266
- resolve(exitCode);
1267
- });
1268
- return;
924
+ if (opt.type === "boolean") {
925
+ optionTypes[key] = "boolean";
926
+ } else if (opt.type === "string") {
927
+ optionTypes[key] = "string";
928
+ }
929
+ }
1269
930
  }
1270
- const pullHint = { hinted: false };
1271
- child.stdout?.pipe(createSecretMaskStream()).pipe(createRuntimePullHintStream(pullHint)).pipe(process.stdout);
1272
- child.stderr?.pipe(createSecretMaskStream()).pipe(createRuntimePullHintStream(pullHint)).pipe(process.stderr);
1273
- child.on("error", reject);
1274
- child.on("exit", (code) => resolve(code ?? 0));
1275
- });
1276
- };
931
+ const name = typeof parsed.name === "string" ? parsed.name : "";
932
+ const description = typeof parsed.description === "string" ? parsed.description : "";
933
+ const rawUrl = typeof parsed.documentationURL === "string" ? parsed.documentationURL.trim() : "";
934
+ const documentationURL = rawUrl.length > 0 && rawUrl.toLowerCase() !== "tbd" ? rawUrl : void 0;
935
+ return {
936
+ name,
937
+ description,
938
+ documentationURL,
939
+ optionHints,
940
+ optionDescriptions,
941
+ optionNames,
942
+ optionTypes,
943
+ usageNotes
944
+ };
945
+ } catch {
946
+ return void 0;
947
+ }
948
+ }
1277
949
 
1278
- // src/devcontainer/compose.ts
1279
- var spawnDockerCompose = (args, cwd) => {
950
+ // src/devcontainer/credentials.ts
951
+ import { spawn } from "child_process";
952
+ import { promises as fs2 } from "fs";
953
+ import path4 from "path";
954
+
955
+ // src/util/format.ts
956
+ var ESC = "\x1B[";
957
+ var ANSI_BOLD2 = `${ESC}1m`;
958
+ var ANSI_UNDERLINE2 = `${ESC}4m`;
959
+ var ANSI_CYAN2 = `${ESC}36m`;
960
+ var ANSI_GREY2 = `${ESC}90m`;
961
+ var ANSI_RESET2 = `${ESC}0m`;
962
+ function makeWrap(isTty2) {
963
+ return (s, ...codes) => isTty2 ? codes.join("") + s + ANSI_RESET2 : s;
964
+ }
965
+ function makePalette(isTty2) {
966
+ const wrap = makeWrap(isTty2);
967
+ return {
968
+ bold: (s) => wrap(s, ANSI_BOLD2),
969
+ underline: (s) => wrap(s, ANSI_UNDERLINE2),
970
+ cyan: (s) => wrap(s, ANSI_CYAN2),
971
+ dim: (s) => wrap(s, ANSI_GREY2),
972
+ sectionLine: (label) => wrap(`\u25B8 ${label}`, ANSI_BOLD2, ANSI_UNDERLINE2)
973
+ };
974
+ }
975
+ function colorsFor(stream) {
976
+ return makePalette(stream.isTTY ?? false);
977
+ }
978
+ var stderrPalette = makePalette(process.stderr.isTTY ?? false);
979
+ var bold2 = stderrPalette.bold;
980
+ var underline2 = stderrPalette.underline;
981
+ var cyan2 = stderrPalette.cyan;
982
+ var dim = stderrPalette.dim;
983
+ var sectionLine = stderrPalette.sectionLine;
984
+
985
+ // src/devcontainer/credentials.ts
986
+ var realGitCredentialFill = (input) => {
1280
987
  return new Promise((resolve, reject) => {
1281
- const child = spawn4("docker", ["compose", ...args], {
1282
- cwd,
1283
- stdio: ["inherit", "pipe", "pipe"]
988
+ const child = spawn("git", ["credential", "fill"], {
989
+ stdio: ["pipe", "pipe", "inherit"],
990
+ env: {
991
+ ...process.env,
992
+ GIT_TERMINAL_PROMPT: "0"
993
+ }
994
+ });
995
+ let stdout = "";
996
+ child.stdout.on("data", (chunk) => {
997
+ stdout += chunk.toString();
1284
998
  });
1285
- child.stdout?.pipe(createSecretMaskStream()).pipe(process.stdout);
1286
- child.stderr?.pipe(createSecretMaskStream()).pipe(process.stderr);
1287
999
  child.on("error", reject);
1288
- child.on("exit", (code) => resolve(code ?? 0));
1000
+ child.on("exit", (code) => resolve({ stdout, exitCode: code ?? 0 }));
1001
+ child.stdin.write(input);
1002
+ child.stdin.end();
1289
1003
  });
1290
1004
  };
1291
- var spawnDocker = (args) => {
1005
+ var realGitCredentialApprove = (input) => {
1292
1006
  return new Promise((resolve, reject) => {
1293
- const child = spawn4("docker", args, {
1294
- stdio: ["ignore", "pipe", "pipe"]
1295
- });
1296
- let stdout = "";
1297
- let stderr = "";
1298
- child.stdout?.on("data", (chunk) => {
1299
- stdout += chunk.toString("utf8");
1300
- });
1301
- child.stderr?.on("data", (chunk) => {
1302
- stderr += chunk.toString("utf8");
1007
+ const child = spawn("git", ["credential", "approve"], {
1008
+ stdio: ["pipe", "ignore", "inherit"],
1009
+ env: {
1010
+ ...process.env,
1011
+ GIT_TERMINAL_PROMPT: "0"
1012
+ }
1303
1013
  });
1304
1014
  child.on("error", reject);
1305
- child.on(
1306
- "exit",
1307
- (code) => resolve({ exitCode: code ?? 0, stdout, stderr })
1308
- );
1015
+ child.on("exit", () => resolve());
1016
+ child.stdin.write(input);
1017
+ child.stdin.end();
1309
1018
  });
1310
1019
  };
1311
- async function findContainerIds(filters, exec = spawnDocker) {
1312
- const ids = /* @__PURE__ */ new Set();
1313
- for (const filter of filters) {
1314
- const result = await exec(["ps", "-aq", "--filter", filter]);
1315
- if (result.exitCode !== 0) continue;
1316
- for (const line of result.stdout.split(/\r?\n/)) {
1317
- const id = line.trim();
1318
- if (id) ids.add(id);
1020
+ function resolveProvider(host, explicit) {
1021
+ const canonical = KNOWN_PROVIDER_HOSTS[host.toLowerCase()];
1022
+ if (canonical) return canonical;
1023
+ return explicit ?? "unknown";
1024
+ }
1025
+ function uniqueHttpsHosts(repos) {
1026
+ const byHost = /* @__PURE__ */ new Map();
1027
+ for (const repo of repos) {
1028
+ if (!repo.url.startsWith("https://")) continue;
1029
+ let host;
1030
+ try {
1031
+ host = new URL(repo.url).hostname;
1032
+ } catch {
1033
+ continue;
1319
1034
  }
1035
+ if (byHost.has(host)) continue;
1036
+ byHost.set(host, { host, provider: resolveProvider(host, repo.provider) });
1320
1037
  }
1321
- return [...ids];
1038
+ return [...byHost.values()];
1322
1039
  }
1323
- async function cleanupDockerObjects(opts) {
1324
- const exec = opts.exec ?? spawnDocker;
1325
- const tag = opts.logTag ?? "cleanup";
1326
- opts.logger.info(`[${tag}] tearing down docker project ${opts.projectName}\u2026`);
1327
- const ids = await findContainerIds(opts.filters, exec);
1328
- let rmExit = 0;
1329
- if (ids.length > 0) {
1330
- opts.logger.info(`[${tag}] removing containers: ${ids.join(" ")}`);
1331
- const rmResult = await exec(["rm", "-f", ...ids]);
1332
- rmExit = rmResult.exitCode;
1333
- if (rmExit !== 0 && rmResult.stderr.trim()) {
1334
- opts.logger.info(`[${tag}] ${rmResult.stderr.trim()}`);
1040
+ var BREW_INSTALL_COMMAND = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"';
1041
+ function installCommandForOS(opts) {
1042
+ const withBrewBootstrap = (cmd) => [
1043
+ "",
1044
+ cyan2(BREW_INSTALL_COMMAND),
1045
+ cyan2(cmd),
1046
+ "",
1047
+ dim("(Skip the first line if you already have Homebrew.)")
1048
+ ].join("\n");
1049
+ if (process.platform === "darwin") return withBrewBootstrap(opts.brew);
1050
+ if (opts.linuxBrew) return withBrewBootstrap(opts.linuxBrew);
1051
+ return `See ${opts.linuxDocsUrl} for package instructions.`;
1052
+ }
1053
+ function providerSetupHint(host, provider) {
1054
+ if (provider === "github") {
1055
+ const isSaas = host.toLowerCase() === "github.com";
1056
+ const hostArg = isSaas ? "" : ` --hostname ${host}`;
1057
+ const install = installCommandForOS({
1058
+ brew: "brew install gh",
1059
+ linuxBrew: "brew install gh",
1060
+ linuxDocsUrl: "https://github.com/cli/cli#installation"
1061
+ });
1062
+ return {
1063
+ title: `${host} \u2014 GitHub`,
1064
+ body: [
1065
+ "Install the GitHub CLI:",
1066
+ install,
1067
+ "",
1068
+ "Then run once:",
1069
+ cyan2(`gh auth login${hostArg}`),
1070
+ cyan2(`gh auth setup-git${hostArg}`),
1071
+ "",
1072
+ "`gh auth login` walks through OAuth in your browser.",
1073
+ "`gh auth setup-git` wires gh into git as a credential helper."
1074
+ ].join("\n")
1075
+ };
1076
+ }
1077
+ if (provider === "gitlab") {
1078
+ const isSaas = host.toLowerCase() === "gitlab.com";
1079
+ const hostArg = isSaas ? "" : ` --hostname ${host}`;
1080
+ const install = installCommandForOS({
1081
+ brew: "brew install glab",
1082
+ linuxBrew: "brew install glab",
1083
+ linuxDocsUrl: "https://gitlab.com/gitlab-org/cli#installation"
1084
+ });
1085
+ return {
1086
+ title: `${host} \u2014 GitLab`,
1087
+ body: [
1088
+ "Install the GitLab CLI (glab):",
1089
+ install,
1090
+ "",
1091
+ "Then run once:",
1092
+ cyan2(`glab auth login${hostArg}`),
1093
+ "",
1094
+ "Choose `HTTPS` when asked for git-protocol, then accept",
1095
+ '"Authenticate Git with your GitLab credentials" \u2014 glab',
1096
+ "configures itself as the git credential helper."
1097
+ ].join("\n")
1098
+ };
1099
+ }
1100
+ if (provider === "bitbucket") {
1101
+ const isCloud = host.toLowerCase() === "bitbucket.org";
1102
+ if (isCloud) {
1103
+ return {
1104
+ title: `${host} \u2014 Bitbucket Cloud`,
1105
+ body: [
1106
+ "Bitbucket has no first-party CLI for git-credentials, so this",
1107
+ "is a manual one-time setup. Generate an Atlassian API token at",
1108
+ "https://id.atlassian.com/manage-profile/security/api-tokens",
1109
+ "",
1110
+ "Then store it via your OS credential helper:",
1111
+ cyan2(
1112
+ `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-atlassian-email>\\npassword=<token>\\n'`
1113
+ )
1114
+ ].join("\n")
1115
+ };
1335
1116
  }
1336
- } else {
1337
- opts.logger.info(`[${tag}] no containers found`);
1117
+ return {
1118
+ title: `${host} \u2014 Bitbucket Data Center`,
1119
+ body: [
1120
+ "Bitbucket has no first-party CLI for git-credentials, so this",
1121
+ "is a manual one-time setup. Generate a personal HTTP access",
1122
+ `token in your Bitbucket UI: profile picture (top right on ${host})`,
1123
+ "\u2192 Manage account \u2192 HTTP access tokens \u2192 Create token. Give it",
1124
+ "at least repo-read + repo-write scopes for the repos you need.",
1125
+ "",
1126
+ "Then store it via your OS credential helper:",
1127
+ cyan2(
1128
+ `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-bitbucket-username>\\npassword=<token>\\n'`
1129
+ )
1130
+ ].join("\n")
1131
+ };
1338
1132
  }
1339
- if (opts.network) {
1340
- const netResult = await exec(["network", "rm", opts.network]);
1341
- if (netResult.exitCode === 0) {
1342
- opts.logger.info(`[${tag}] network ${opts.network} removed`);
1133
+ return {
1134
+ title: `${host} \u2014 Gitea`,
1135
+ body: [
1136
+ "Gitea has no first-party CLI helper for git-credentials (the",
1137
+ "`tea` CLI logs into its own config, not into your git credential",
1138
+ "helper), so this is a manual one-time setup. Generate an access",
1139
+ `token in your Gitea UI: profile picture (top right on ${host}) \u2192`,
1140
+ 'Settings \u2192 Applications \u2192 "Generate New Token". Give it at',
1141
+ "least the `read:repository` scope (add `write:repository` if you",
1142
+ "need push from the container).",
1143
+ "",
1144
+ "Then store it via your OS credential helper:",
1145
+ cyan2(
1146
+ `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-gitea-username>\\npassword=<token>\\n'`
1147
+ )
1148
+ ].join("\n")
1149
+ };
1150
+ }
1151
+ function parseCredentialFillOutput(output) {
1152
+ const result = {};
1153
+ for (const line of output.split("\n")) {
1154
+ const eqIdx = line.indexOf("=");
1155
+ if (eqIdx <= 0) continue;
1156
+ const key = line.slice(0, eqIdx);
1157
+ const value = line.slice(eqIdx + 1);
1158
+ if (key === "username") result.username = value;
1159
+ if (key === "password") result.password = value;
1160
+ }
1161
+ return result;
1162
+ }
1163
+ function formatCredentialLine(host, username, password) {
1164
+ const encUser = encodeURIComponent(username);
1165
+ const encPass = encodeURIComponent(password);
1166
+ return `https://${encUser}:${encPass}@${host}`;
1167
+ }
1168
+ async function collectGitCredentials(devContainerRoot, hosts, options = {}) {
1169
+ const credsDir = path4.join(devContainerRoot, ".monoceros");
1170
+ const credentialsPath = path4.join(credsDir, "git-credentials");
1171
+ const spawnFn = options.spawn ?? realGitCredentialFill;
1172
+ const approveFn = options.approve ?? realGitCredentialApprove;
1173
+ const logger = options.logger ?? { info: () => {
1174
+ }, warn: () => {
1175
+ } };
1176
+ const lines = [];
1177
+ const perHost = [];
1178
+ for (const { host, provider } of hosts) {
1179
+ if (provider === "unknown") {
1180
+ perHost.push({
1181
+ host,
1182
+ provider: "github",
1183
+ // placeholder — never rendered because pre-flight already bailed
1184
+ status: "no-credentials",
1185
+ detail: "provider not declared (internal: should not reach here)"
1186
+ });
1187
+ continue;
1188
+ }
1189
+ logger.info(`Fetching credentials for ${host} from host git\u2026`);
1190
+ const input = `protocol=https
1191
+ host=${host}
1192
+
1193
+ `;
1194
+ let result;
1195
+ try {
1196
+ result = await spawnFn(input);
1197
+ } catch (err) {
1198
+ const detail = err instanceof Error ? err.message : String(err);
1199
+ perHost.push({ host, provider, status: "spawn-error", detail });
1200
+ continue;
1201
+ }
1202
+ if (result.exitCode !== 0) {
1203
+ perHost.push({
1204
+ host,
1205
+ provider,
1206
+ status: "non-zero-exit",
1207
+ detail: `exit code ${result.exitCode}`
1208
+ });
1209
+ continue;
1210
+ }
1211
+ const { username, password } = parseCredentialFillOutput(result.stdout);
1212
+ if (!username || !password) {
1213
+ perHost.push({
1214
+ host,
1215
+ provider,
1216
+ status: "no-credentials",
1217
+ detail: "host credential helper returned no username/password"
1218
+ });
1219
+ continue;
1220
+ }
1221
+ lines.push(formatCredentialLine(host, username, password));
1222
+ perHost.push({ host, provider, status: "ok", detail: "" });
1223
+ const approveInput = `protocol=https
1224
+ host=${host}
1225
+ username=${username}
1226
+ password=${password}
1227
+
1228
+ `;
1229
+ try {
1230
+ await approveFn(approveInput);
1231
+ } catch {
1343
1232
  }
1344
1233
  }
1345
- opts.logger.info(`[${tag}] docker cleanup done`);
1346
- return { exitCode: rmExit, removedIds: ids };
1347
- }
1348
- function dockerLocalFolderLabel(p) {
1349
- if (process.platform !== "win32") return p;
1350
- return p.replace(
1351
- /^([A-Z]):/,
1352
- (_, drive) => `${drive.toLowerCase()}:`
1234
+ await fs2.mkdir(credsDir, { recursive: true });
1235
+ await fs2.writeFile(
1236
+ credentialsPath,
1237
+ lines.join("\n") + (lines.length > 0 ? "\n" : ""),
1238
+ {
1239
+ mode: 384
1240
+ }
1353
1241
  );
1242
+ return {
1243
+ hostsWritten: lines.length,
1244
+ hostsSkipped: perHost.filter((p) => p.status !== "ok").length,
1245
+ perHost,
1246
+ credentialsPath
1247
+ };
1354
1248
  }
1355
- function composeProjectName(root) {
1356
- return `${path5.basename(root)}_devcontainer`;
1357
- }
1358
- function resolveCompose(root) {
1359
- if (!existsSync2(path5.join(root, ".devcontainer"))) {
1360
- throw new Error(
1361
- `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
1362
- );
1363
- }
1364
- const composeFile = path5.join(root, ".devcontainer", "compose.yaml");
1365
- if (!existsSync2(composeFile)) {
1366
- throw new Error(
1367
- `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.`
1368
- );
1249
+ function formatMissingCredentialsError(missing) {
1250
+ if (missing.length === 1) {
1251
+ const m = missing[0];
1252
+ const hint = providerSetupHint(m.host, m.provider);
1253
+ return [
1254
+ `Missing Git credentials: ${hint.title}`,
1255
+ "",
1256
+ hint.body,
1257
+ "",
1258
+ `Then re-run ${cyan2("monoceros apply")}.`
1259
+ ].join("\n");
1369
1260
  }
1370
- return { composeFile, projectName: composeProjectName(root) };
1371
- }
1372
- async function runComposeAction(buildSubArgs, opts) {
1373
- const { composeFile, projectName } = resolveCompose(opts.root);
1374
- const spawnFn = opts.spawn ?? spawnDockerCompose;
1375
- const subArgs = buildSubArgs(opts.service);
1376
- return spawnFn(["-f", composeFile, "-p", projectName, ...subArgs], opts.root);
1377
- }
1378
- async function runStart(opts) {
1379
- resolveCompose(opts.root);
1380
- const logger = opts.logger ?? { info: (msg) => consola.info(msg) };
1381
- const spawnFn = opts.spawn ?? spawnDevcontainer;
1382
- logger.info(`Bringing devcontainer up at ${opts.root}\u2026`);
1383
- return spawnFn(
1384
- ["up", "--workspace-folder", opts.root, "--mount-workspace-git-root=false"],
1385
- opts.root
1386
- );
1387
- }
1388
- async function runContainerCycle(root, opts) {
1389
- const { hasCompose, logger } = opts;
1390
- if (hasCompose) {
1391
- const projectName = composeProjectName(root);
1392
- logger.info(
1393
- `Force-removing existing ${projectName} containers (volumes preserved)\u2026`
1394
- );
1395
- const exec = opts.dockerExec ?? spawnDocker;
1396
- const filters = [
1397
- `label=com.docker.compose.project=${projectName}`,
1398
- `name=^${projectName}-`
1399
- ];
1400
- const { exitCode: rmExit } = await cleanupDockerObjects({
1401
- projectName,
1402
- filters,
1403
- network: `${projectName}_default`,
1404
- logger,
1405
- exec
1406
- });
1407
- if (rmExit !== 0) return rmExit;
1408
- const remaining = await findContainerIds(filters, exec);
1409
- if (remaining.length > 0) {
1410
- const warn = logger.warn ?? logger.info;
1411
- warn(
1412
- `ERROR: containers under project ${projectName} reappeared after removal.
1413
- This typically means VS Code's Remote Containers extension is connected
1414
- to this devcontainer and auto-recreated it. Close the dev container
1415
- session in VS Code (Cmd+Shift+P \u2192 'Dev Containers: Close Remote Connection')
1416
- and retry \`monoceros apply\`.`
1417
- );
1418
- return 1;
1419
- }
1420
- return runStart({
1421
- root,
1422
- ...opts.devcontainerSpawn ? { spawn: opts.devcontainerSpawn } : {},
1423
- logger
1424
- });
1261
+ const lines = [
1262
+ `Missing Git credentials for ${missing.length} hosts:`,
1263
+ ""
1264
+ ];
1265
+ for (const m of missing) {
1266
+ const hint = providerSetupHint(m.host, m.provider);
1267
+ lines.push(hint.title);
1268
+ lines.push("");
1269
+ lines.push(hint.body);
1270
+ lines.push("");
1425
1271
  }
1426
- logger.info(`Recreating image-mode devcontainer at ${root}\u2026`);
1427
- const spawnFn = opts.devcontainerSpawn ?? spawnDevcontainer;
1428
- return spawnFn(
1429
- [
1430
- "up",
1431
- "--workspace-folder",
1432
- root,
1433
- "--mount-workspace-git-root=false",
1434
- "--remove-existing-container"
1435
- ],
1436
- root
1437
- );
1438
- }
1439
- function runStop(opts) {
1440
- return runComposeAction(
1441
- (service) => ["stop", ...service ? [service] : []],
1442
- opts
1443
- );
1444
- }
1445
- function runStatus(opts) {
1446
- return runComposeAction(
1447
- (service) => ["ps", ...service ? [service] : []],
1448
- opts
1449
- );
1272
+ lines.push(`Then re-run ${cyan2("monoceros apply")}.`);
1273
+ return lines.join("\n");
1450
1274
  }
1451
- function runLogs(opts) {
1452
- const follow = opts.follow ?? true;
1453
- return runComposeAction(
1454
- (service) => [
1455
- "logs",
1456
- ...follow ? ["-f"] : [],
1457
- ...service ? [service] : []
1458
- ],
1459
- opts
1460
- );
1275
+ function formatUnknownProviderError(hosts) {
1276
+ const sorted = [...new Set(hosts)].sort();
1277
+ const lines = [
1278
+ sorted.length === 1 ? `Unknown Git provider for host ${sorted[0]}.` : `Unknown Git provider for ${sorted.length} hosts: ${sorted.join(", ")}.`,
1279
+ "",
1280
+ "Monoceros auto-detects only github.com / gitlab.com / bitbucket.org.",
1281
+ "For any other host (self-hosted GitLab, Gitea, Bitbucket Server, \u2026)",
1282
+ "declare the provider explicitly in the yml. Edit the repo entry:",
1283
+ "",
1284
+ cyan2(" repos:"),
1285
+ cyan2(` - url: https://${sorted[0]}/\u2026`),
1286
+ cyan2(" provider: gitlab # or: github, bitbucket, gitea"),
1287
+ "",
1288
+ `Or re-add with ${cyan2("monoceros add-repo <name> <url> --provider=<github|gitlab|bitbucket|gitea>")}.`
1289
+ ];
1290
+ return lines.join("\n");
1461
1291
  }
1462
1292
 
1463
1293
  // src/devcontainer/locate-running.ts
1294
+ import { spawn as spawn2 } from "child_process";
1464
1295
  var realDockerLookup = (args) => {
1465
1296
  return new Promise((resolve, reject) => {
1466
- const child = spawn5("docker", args, {
1297
+ const child = spawn2("docker", args, {
1467
1298
  stdio: ["ignore", "pipe", "pipe"]
1468
1299
  });
1469
1300
  let stdout = "";
@@ -1487,7 +1318,7 @@ async function findRunningContainerByLocalFolder(containerPath, opts = {}) {
1487
1318
  "ps",
1488
1319
  "-q",
1489
1320
  "--filter",
1490
- `label=devcontainer.local_folder=${dockerLocalFolderLabel(containerPath)}`,
1321
+ `label=devcontainer.local_folder=${containerPath}`,
1491
1322
  "--filter",
1492
1323
  "status=running"
1493
1324
  ]);
@@ -1497,7 +1328,7 @@ async function findRunningContainerByLocalFolder(containerPath, opts = {}) {
1497
1328
  }
1498
1329
  var realContainerExec = (containerId, argv) => {
1499
1330
  return new Promise((resolve, reject) => {
1500
- const child = spawn5("docker", ["exec", containerId, ...argv], {
1331
+ const child = spawn2("docker", ["exec", containerId, ...argv], {
1501
1332
  // Inherit stdio so live git output reaches the user.
1502
1333
  stdio: ["ignore", "inherit", "inherit"]
1503
1334
  });
@@ -1507,7 +1338,7 @@ var realContainerExec = (containerId, argv) => {
1507
1338
  };
1508
1339
 
1509
1340
  // src/config/global.ts
1510
- import { promises as fs4 } from "fs";
1341
+ import { promises as fs3 } from "fs";
1511
1342
  import { z as z2 } from "zod";
1512
1343
  import { isMap, Pair, parseDocument as parseDocument2, Scalar, YAMLMap } from "yaml";
1513
1344
  var SCHEMA_VERSION = 1;
@@ -1550,7 +1381,7 @@ async function readMonocerosConfig(opts = {}) {
1550
1381
  const filePath = monocerosConfigPath(home);
1551
1382
  let text;
1552
1383
  try {
1553
- text = await fs4.readFile(filePath, "utf8");
1384
+ text = await fs3.readFile(filePath, "utf8");
1554
1385
  } catch {
1555
1386
  return void 0;
1556
1387
  }
@@ -1587,7 +1418,7 @@ async function writeGlobalDefaultGitUser(user, opts = {}) {
1587
1418
  const filePath = monocerosConfigPath(home);
1588
1419
  let text;
1589
1420
  try {
1590
- text = await fs4.readFile(filePath, "utf8");
1421
+ text = await fs3.readFile(filePath, "utf8");
1591
1422
  } catch {
1592
1423
  text = void 0;
1593
1424
  }
@@ -1604,8 +1435,8 @@ async function writeGlobalDefaultGitUser(user, opts = {}) {
1604
1435
  ` email: ${user.email}`,
1605
1436
  ""
1606
1437
  ].join("\n");
1607
- await fs4.mkdir(home, { recursive: true });
1608
- await fs4.writeFile(filePath, fresh, "utf8");
1438
+ await fs3.mkdir(home, { recursive: true });
1439
+ await fs3.writeFile(filePath, fresh, "utf8");
1609
1440
  return { filePath, created: true, alreadySet: false };
1610
1441
  }
1611
1442
  const doc = parseDocument2(text, { prettyErrors: true });
@@ -1626,7 +1457,7 @@ async function writeGlobalDefaultGitUser(user, opts = {}) {
1626
1457
  userMap.set("name", user.name);
1627
1458
  userMap.set("email", user.email);
1628
1459
  const newText = String(doc);
1629
- await fs4.writeFile(filePath, newText, "utf8");
1460
+ await fs3.writeFile(filePath, newText, "utf8");
1630
1461
  return { filePath, created: false, alreadySet: false };
1631
1462
  }
1632
1463
  function ensureMap(doc, key) {
@@ -1733,8 +1564,8 @@ ${existing}` : leakedComment;
1733
1564
  }
1734
1565
 
1735
1566
  // src/init/components.ts
1736
- import { existsSync as existsSync3, promises as fs5 } from "fs";
1737
- import path6 from "path";
1567
+ import { existsSync as existsSync4, promises as fs4 } from "fs";
1568
+ import path5 from "path";
1738
1569
  import { z as z3 } from "zod";
1739
1570
  import { parse as parseYaml } from "yaml";
1740
1571
  var CategorySchema = z3.enum(["language", "service", "feature"]);
@@ -1781,7 +1612,7 @@ var ComponentFileSchema = z3.object({
1781
1612
  }
1782
1613
  });
1783
1614
  async function loadComponentCatalog(rootDir = componentsDir()) {
1784
- if (!existsSync3(rootDir)) {
1615
+ if (!existsSync4(rootDir)) {
1785
1616
  return /* @__PURE__ */ new Map();
1786
1617
  }
1787
1618
  const out = /* @__PURE__ */ new Map();
@@ -1789,17 +1620,17 @@ async function loadComponentCatalog(rootDir = componentsDir()) {
1789
1620
  return out;
1790
1621
  }
1791
1622
  async function walk(baseDir, currentDir, out) {
1792
- const entries = await fs5.readdir(currentDir, { withFileTypes: true });
1623
+ const entries = await fs4.readdir(currentDir, { withFileTypes: true });
1793
1624
  for (const entry2 of entries) {
1794
- const full = path6.join(currentDir, entry2.name);
1625
+ const full = path5.join(currentDir, entry2.name);
1795
1626
  if (entry2.isDirectory()) {
1796
1627
  await walk(baseDir, full, out);
1797
1628
  continue;
1798
1629
  }
1799
1630
  if (!entry2.isFile() || !entry2.name.endsWith(".yml")) continue;
1800
- const relative = path6.relative(baseDir, full);
1801
- const name = relative.replace(/\.yml$/, "").split(path6.sep).join("/");
1802
- const text = await fs5.readFile(full, "utf8");
1631
+ const relative = path5.relative(baseDir, full);
1632
+ const name = relative.replace(/\.yml$/, "").split(path5.sep).join("/");
1633
+ const text = await fs4.readFile(full, "utf8");
1803
1634
  let raw;
1804
1635
  try {
1805
1636
  raw = parseYaml(text);
@@ -1820,42 +1651,6 @@ ${issues}`);
1820
1651
  out.set(name, { name, sourcePath: full, file: parsed.data });
1821
1652
  }
1822
1653
  }
1823
- function mergeComponents(resolved) {
1824
- const languages = [];
1825
- const services = [];
1826
- const featureByRef = /* @__PURE__ */ new Map();
1827
- for (const entry2 of resolved) {
1828
- const c = isResolvedComponent(entry2) ? entry2.component : entry2;
1829
- const version = isResolvedComponent(entry2) ? entry2.version : void 0;
1830
- const ct = c.file.contributes;
1831
- for (const lang of ct.languages ?? []) {
1832
- const value = version !== void 0 ? `${lang}:${version}` : lang;
1833
- if (!languages.includes(value)) languages.push(value);
1834
- }
1835
- for (const svc of ct.services ?? []) {
1836
- if (!services.includes(svc)) services.push(svc);
1837
- }
1838
- for (const f of ct.features ?? []) {
1839
- const existing = featureByRef.get(f.ref);
1840
- if (!existing) {
1841
- featureByRef.set(f.ref, {
1842
- ref: f.ref,
1843
- options: { ...f.options ?? {} }
1844
- });
1845
- continue;
1846
- }
1847
- existing.options = mergeFeatureOptions(existing.options, f.options ?? {});
1848
- }
1849
- }
1850
- return {
1851
- languages,
1852
- services,
1853
- features: [...featureByRef.values()]
1854
- };
1855
- }
1856
- function isResolvedComponent(x) {
1857
- return "component" in x;
1858
- }
1859
1654
  function mergeFeatureOptions(a, b) {
1860
1655
  const result = { ...a };
1861
1656
  for (const [key, valueB] of Object.entries(b)) {
@@ -1868,33 +1663,124 @@ function mergeFeatureOptions(a, b) {
1868
1663
  }
1869
1664
  return result;
1870
1665
  }
1871
- function resolveComponents(catalog, names) {
1872
- const unknown = [];
1873
- const out = [];
1874
- for (const raw of names) {
1875
- const colon = raw.indexOf(":");
1876
- const name = colon === -1 ? raw : raw.slice(0, colon);
1877
- const version = colon === -1 ? void 0 : raw.slice(colon + 1);
1878
- const c = catalog.get(name);
1879
- if (!c) {
1880
- unknown.push(raw);
1881
- continue;
1666
+
1667
+ // src/proxy/index.ts
1668
+ import { spawn as spawn3 } from "child_process";
1669
+ import { promises as fs5 } from "fs";
1670
+ import path6 from "path";
1671
+ var PROXY_CONTAINER_NAME = "monoceros-proxy";
1672
+ var PROXY_NETWORK_NAME = "monoceros-proxy";
1673
+ var TRAEFIK_IMAGE = "traefik:v3.3";
1674
+ var defaultDockerExec = (args) => {
1675
+ return new Promise((resolve, reject) => {
1676
+ const child = spawn3("docker", args, {
1677
+ stdio: ["ignore", "pipe", "pipe"]
1678
+ });
1679
+ let stdout = "";
1680
+ let stderr = "";
1681
+ child.stdout.on("data", (chunk) => {
1682
+ stdout += chunk.toString();
1683
+ });
1684
+ child.stderr.on("data", (chunk) => {
1685
+ stderr += chunk.toString();
1686
+ });
1687
+ child.on("error", reject);
1688
+ child.on(
1689
+ "exit",
1690
+ (code) => resolve({ stdout, stderr, exitCode: code ?? 0 })
1691
+ );
1692
+ });
1693
+ };
1694
+ var realDocker = defaultDockerExec;
1695
+ function proxyDynamicDir(home) {
1696
+ return path6.join(home ?? monocerosHome(), "traefik", "dynamic");
1697
+ }
1698
+ async function ensureProxy(opts = {}) {
1699
+ const docker = opts.docker ?? realDocker;
1700
+ const dyn = proxyDynamicDir(opts.monocerosHome);
1701
+ await fs5.mkdir(dyn, { recursive: true });
1702
+ const netInspect = await docker(["network", "inspect", PROXY_NETWORK_NAME]);
1703
+ if (netInspect.exitCode !== 0) {
1704
+ const create = await docker(["network", "create", PROXY_NETWORK_NAME]);
1705
+ if (create.exitCode !== 0) {
1706
+ throw new Error(
1707
+ `Could not create docker network ${PROXY_NETWORK_NAME}: ${create.stderr.trim() || `exit ${create.exitCode}`}`
1708
+ );
1882
1709
  }
1883
- if (version !== void 0 && c.file.category !== "language") {
1710
+ }
1711
+ const state = await docker([
1712
+ "inspect",
1713
+ "--format",
1714
+ "{{.State.Running}}",
1715
+ PROXY_CONTAINER_NAME
1716
+ ]);
1717
+ if (state.exitCode === 0) {
1718
+ if (state.stdout.trim() === "true") return;
1719
+ const start = await docker(["start", PROXY_CONTAINER_NAME]);
1720
+ if (start.exitCode !== 0) {
1884
1721
  throw new Error(
1885
- `Component '${name}' is a ${c.file.category}, not a language \u2014 a ':${version}' suffix has no meaning here.`
1722
+ `Could not start existing ${PROXY_CONTAINER_NAME} container: ${start.stderr.trim() || `exit ${start.exitCode}`}`
1886
1723
  );
1887
1724
  }
1888
- out.push({ component: c, ...version !== void 0 ? { version } : {} });
1725
+ return;
1726
+ }
1727
+ const hostPort = opts.hostPort ?? 80;
1728
+ const run = await docker([
1729
+ "run",
1730
+ "-d",
1731
+ "--name",
1732
+ PROXY_CONTAINER_NAME,
1733
+ "--network",
1734
+ PROXY_NETWORK_NAME,
1735
+ "-p",
1736
+ `${hostPort}:80`,
1737
+ "-v",
1738
+ `${dyn}:/etc/traefik/dynamic:ro`,
1739
+ "--label",
1740
+ "monoceros.role=proxy",
1741
+ TRAEFIK_IMAGE,
1742
+ "--entrypoints.web.address=:80",
1743
+ "--providers.file.directory=/etc/traefik/dynamic",
1744
+ "--providers.file.watch=true",
1745
+ "--providers.docker=false",
1746
+ "--api.dashboard=false",
1747
+ "--log.level=INFO"
1748
+ ]);
1749
+ if (run.exitCode !== 0) {
1750
+ throw new Error(
1751
+ `Could not start ${PROXY_CONTAINER_NAME}: ${run.stderr.trim() || `exit ${run.exitCode}`}`
1752
+ );
1753
+ }
1754
+ opts.logger?.info(
1755
+ `Started ${PROXY_CONTAINER_NAME} (Traefik on :${hostPort}).`
1756
+ );
1757
+ }
1758
+ async function maybeStopProxy(opts = {}) {
1759
+ const docker = opts.docker ?? realDocker;
1760
+ const logger = opts.logger;
1761
+ const inspect = await docker([
1762
+ "network",
1763
+ "inspect",
1764
+ PROXY_NETWORK_NAME,
1765
+ "--format",
1766
+ "{{range $k, $v := .Containers}}{{$v.Name}}\n{{end}}"
1767
+ ]);
1768
+ if (inspect.exitCode !== 0) {
1769
+ return;
1889
1770
  }
1890
- if (unknown.length > 0) {
1891
- const available = [...catalog.keys()].sort();
1892
- throw new Error(
1893
- `Unknown component${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.
1894
- Available: ${available.join(", ")}.`
1771
+ const others = inspect.stdout.split("\n").map((n) => n.trim()).filter((n) => n.length > 0 && n !== PROXY_CONTAINER_NAME);
1772
+ if (others.length > 0) return;
1773
+ await docker(["rm", "-f", PROXY_CONTAINER_NAME]);
1774
+ const netRm = await docker(["network", "rm", PROXY_NETWORK_NAME]);
1775
+ if (netRm.exitCode !== 0) {
1776
+ logger?.warn?.(
1777
+ `Could not remove docker network ${PROXY_NETWORK_NAME}: ${netRm.stderr.trim() || `exit ${netRm.exitCode}`}`
1895
1778
  );
1779
+ return;
1896
1780
  }
1897
- return out;
1781
+ logger?.info(
1782
+ `Stopped ${PROXY_CONTAINER_NAME} (no dev-containers with ports left).`
1783
+ );
1898
1784
  }
1899
1785
 
1900
1786
  // src/proxy/dynamic.ts
@@ -2116,34 +2002,97 @@ function knownLanguages() {
2116
2002
  function knownServices() {
2117
2003
  return Object.keys(SERVICE_CATALOG).sort();
2118
2004
  }
2005
+ function resolveService(entry2) {
2006
+ return {
2007
+ name: entry2.name,
2008
+ image: entry2.image,
2009
+ ...entry2.port !== void 0 ? { port: entry2.port } : {},
2010
+ env: entry2.env ? { ...entry2.env } : {},
2011
+ volumes: entry2.volumes ? [...entry2.volumes] : [],
2012
+ ...entry2.healthcheck ? { healthcheck: entry2.healthcheck } : {},
2013
+ ...entry2.restart ? { restart: entry2.restart } : {},
2014
+ ...entry2.command ? { command: entry2.command } : {}
2015
+ };
2016
+ }
2017
+ function isCuratedService(name) {
2018
+ return Object.prototype.hasOwnProperty.call(SERVICE_CATALOG, name);
2019
+ }
2020
+ function expandCuratedService(name) {
2021
+ const def = SERVICE_CATALOG[name];
2022
+ if (!def) {
2023
+ throw new Error(
2024
+ `Unknown service '${name}'. Known catalog services: ${knownServices().join(", ")}.`
2025
+ );
2026
+ }
2027
+ return {
2028
+ name: def.id,
2029
+ image: def.image,
2030
+ port: def.defaultPort,
2031
+ ...def.env ? { env: { ...def.env } } : {},
2032
+ ...def.dataMount ? { volumes: [`data:${def.dataMount}`] } : {}
2033
+ };
2034
+ }
2035
+ function deriveServiceName(image) {
2036
+ const lastSegment = image.split("/").pop() ?? image;
2037
+ const noTag = lastSegment.split("@")[0].split(":")[0];
2038
+ return noTag.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
2039
+ }
2119
2040
 
2120
- // src/create/scaffold.ts
2121
- import { existsSync as existsSync4, readFileSync as readFileSync2, promises as fs7 } from "fs";
2122
- import path8 from "path";
2123
-
2124
- // src/util/ref.ts
2125
- var FEATURE_NAME_CHARSET = "[a-z0-9._-]+";
2126
- var FEATURE_TAG_CHARSET = "[a-z0-9._-]+";
2127
- var MONOCEROS_FEATURE_RE = new RegExp(
2128
- `^ghcr\\.io/getmonoceros/monoceros-features/(${FEATURE_NAME_CHARSET}):${FEATURE_TAG_CHARSET}$`
2129
- );
2130
- var DEPRECATED_MONOCEROS_FEATURE_RE = new RegExp(
2131
- `^ghcr\\.io/monoceros/features/(${FEATURE_NAME_CHARSET}):(${FEATURE_TAG_CHARSET})$`
2132
- );
2133
- function matchMonocerosFeature(ref) {
2134
- const match = MONOCEROS_FEATURE_RE.exec(ref);
2135
- if (!match) return null;
2136
- return { name: match[1] };
2041
+ // src/init/service-doc.ts
2042
+ function renderServiceObjectBody(svc) {
2043
+ const lines = [`name: ${svc.name}`, `image: ${svc.image}`];
2044
+ if (svc.port !== void 0) lines.push(`port: ${svc.port}`);
2045
+ if (svc.env && Object.keys(svc.env).length > 0) {
2046
+ lines.push("env:");
2047
+ for (const [k, v] of Object.entries(svc.env)) {
2048
+ lines.push(` ${k}: ${v}`);
2049
+ }
2050
+ }
2051
+ if (svc.volumes && svc.volumes.length > 0) {
2052
+ lines.push("volumes:");
2053
+ for (const vol of svc.volumes) lines.push(` - ${vol}`);
2054
+ }
2055
+ if (svc.restart) lines.push(`restart: ${svc.restart}`);
2056
+ if (svc.command !== void 0) lines.push(`command: ${svc.command}`);
2057
+ if (svc.healthcheck) {
2058
+ lines.push("healthcheck:");
2059
+ const test = svc.healthcheck.test;
2060
+ lines.push(
2061
+ Array.isArray(test) ? ` test: [${test.map((t) => JSON.stringify(t)).join(", ")}]` : ` test: ${test}`
2062
+ );
2063
+ if (svc.healthcheck.interval)
2064
+ lines.push(` interval: ${svc.healthcheck.interval}`);
2065
+ if (svc.healthcheck.timeout)
2066
+ lines.push(` timeout: ${svc.healthcheck.timeout}`);
2067
+ if (svc.healthcheck.retries !== void 0)
2068
+ lines.push(` retries: ${svc.healthcheck.retries}`);
2069
+ if (svc.healthcheck.startPeriod)
2070
+ lines.push(` startPeriod: ${svc.healthcheck.startPeriod}`);
2071
+ }
2072
+ return lines;
2137
2073
  }
2138
- function migrateDeprecatedFeatureRef(ref) {
2139
- const match = DEPRECATED_MONOCEROS_FEATURE_RE.exec(ref);
2140
- if (!match) return null;
2141
- const name = match[1];
2142
- const tag = match[2];
2143
- return `ghcr.io/getmonoceros/monoceros-features/${name}:${tag}`;
2074
+ function renderCustomService(name, image) {
2075
+ const bodyLines = [`name: ${name}`, `image: ${image}`];
2076
+ const comment = [
2077
+ " port: 8080 # in-container port \u2192 `monoceros tunnel`",
2078
+ " env: # values resolved from <name>.env",
2079
+ " KEY: ${SOME_VAR}",
2080
+ " volumes:",
2081
+ ` - data:/data # persistent host bind-mount under data/${name}`,
2082
+ " - rel/host/path:/in/container:ro",
2083
+ " healthcheck:",
2084
+ " test: curl -f http://localhost:8080/health",
2085
+ " restart: unless-stopped"
2086
+ ].join("\n");
2087
+ return { bodyLines, comment };
2088
+ }
2089
+ function customServiceHint(name) {
2090
+ return `'${name}' is a custom image \u2014 Monoceros doesn't know its env, ports or volumes. Review the commented block under services[].${name} in the yml and fill in what the image needs.`;
2144
2091
  }
2145
2092
 
2146
2093
  // src/create/scaffold.ts
2094
+ import { existsSync as existsSync5, readFileSync as readFileSync3, promises as fs7 } from "fs";
2095
+ import path8 from "path";
2147
2096
  var APT_PACKAGE_NAME_RE2 = /^[a-z0-9][a-z0-9.+-]*$/;
2148
2097
  var FEATURE_REF_RE2 = /^[a-z0-9.-]+(\/[a-z0-9._-]+)+:[a-z0-9._-]+$/;
2149
2098
  var INSTALL_URL_RE2 = /^https:\/\/[A-Za-z0-9.\-_~/:?#[\]@!&'()*+,;=%]+$/;
@@ -2173,12 +2122,24 @@ function validateOptions(opts) {
2173
2122
  );
2174
2123
  }
2175
2124
  }
2125
+ const seenServiceNames = /* @__PURE__ */ new Set();
2176
2126
  for (const svc of opts.services) {
2177
- if (!SERVICE_CATALOG[svc]) {
2127
+ if (!svc.image) {
2128
+ throw new Error(
2129
+ `Service ${JSON.stringify(svc.name)} has no image. Every service needs an 'image:'.`
2130
+ );
2131
+ }
2132
+ if (svc.name === "workspace") {
2178
2133
  throw new Error(
2179
- `Unknown service: ${svc}. Known: ${knownServices().join(", ")}.`
2134
+ `Invalid service name 'workspace': it collides with the reserved devcontainer workspace service. Pick another name.`
2180
2135
  );
2181
2136
  }
2137
+ if (seenServiceNames.has(svc.name)) {
2138
+ throw new Error(
2139
+ `Duplicate service name: ${JSON.stringify(svc.name)}. Each services[] entry must have a unique name.`
2140
+ );
2141
+ }
2142
+ seenServiceNames.add(svc.name);
2182
2143
  }
2183
2144
  for (const pkg of opts.aptPackages ?? []) {
2184
2145
  if (!APT_PACKAGE_NAME_RE2.test(pkg)) {
@@ -2228,10 +2189,14 @@ function validateOptions(opts) {
2228
2189
  }
2229
2190
  function normalizeOptions(opts) {
2230
2191
  const languages = [...new Set(opts.languages)].sort();
2231
- let services = [...new Set(opts.services)].sort();
2232
- if (opts.postgresUrl) {
2233
- services = services.filter((s) => s !== "postgres");
2192
+ const serviceByName = /* @__PURE__ */ new Map();
2193
+ for (const svc of opts.services) {
2194
+ if (opts.postgresUrl && svc.name === "postgres") continue;
2195
+ serviceByName.set(svc.name, svc);
2234
2196
  }
2197
+ const services = [...serviceByName.values()].sort(
2198
+ (a, b) => a.name.localeCompare(b.name)
2199
+ );
2235
2200
  const aptPackages = [...new Set(opts.aptPackages ?? [])].sort();
2236
2201
  const features = opts.features ? Object.fromEntries(
2237
2202
  Object.entries(opts.features).sort(([a], [b]) => a.localeCompare(b))
@@ -2291,7 +2256,7 @@ function resolveFeatures(opts) {
2291
2256
  const name = match.name;
2292
2257
  const checkout = workbenchCheckoutRoot();
2293
2258
  const localSourceDir = checkout ? path8.join(checkout, "images", "features", name) : null;
2294
- if (localSourceDir && existsSync4(localSourceDir)) {
2259
+ if (localSourceDir && existsSync5(localSourceDir)) {
2295
2260
  const { paths, files } = readPersistentHomeEntries(localSourceDir);
2296
2261
  resolved.push({
2297
2262
  devcontainerKey: `./features/${name}`,
@@ -2317,7 +2282,7 @@ function resolveFeatures(opts) {
2317
2282
  function readPersistentHomeEntries(localSourceDir) {
2318
2283
  const manifestPath = path8.join(localSourceDir, "devcontainer-feature.json");
2319
2284
  try {
2320
- const text = readFileSync2(manifestPath, "utf8");
2285
+ const text = readFileSync3(manifestPath, "utf8");
2321
2286
  const parsed = JSON.parse(text);
2322
2287
  return {
2323
2288
  paths: filterSubpaths(parsed["x-monoceros"]?.persistentHomePaths),
@@ -2392,7 +2357,7 @@ function buildDevcontainerJson(opts, dockerMode = "rootful") {
2392
2357
  name: opts.name,
2393
2358
  dockerComposeFile: "compose.yaml",
2394
2359
  service: "workspace",
2395
- ...opts.services.length > 0 ? { runServices: opts.services } : {},
2360
+ ...opts.services.length > 0 ? { runServices: opts.services.map((s) => s.name) } : {},
2396
2361
  workspaceFolder: `/workspaces/${opts.name}`,
2397
2362
  remoteUser: "node",
2398
2363
  forwardPorts: ports,
@@ -2425,6 +2390,18 @@ function buildDevcontainerJson(opts, dockerMode = "rootful") {
2425
2390
  ...customizationsField ?? {}
2426
2391
  };
2427
2392
  }
2393
+ function composeScalar(value) {
2394
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t");
2395
+ return `"${escaped}"`;
2396
+ }
2397
+ function composeVolumeSource(spec, serviceName) {
2398
+ const parts = spec.split(":");
2399
+ const src = parts[0];
2400
+ const rest = parts.slice(1).join(":");
2401
+ if (src === "data") return `../data/${serviceName}:${rest}`;
2402
+ const relative = src.startsWith("./") ? src.slice(2) : src;
2403
+ return `../${relative}:${rest}`;
2404
+ }
2428
2405
  function buildComposeYaml(opts, dockerMode = "rootful") {
2429
2406
  void dockerMode;
2430
2407
  const hasPorts = (opts.ports?.length ?? 0) > 0;
@@ -2453,20 +2430,42 @@ function buildComposeYaml(opts, dockerMode = "rootful") {
2453
2430
  lines.push(` - ../home/${sub}:/home/node/${sub}`);
2454
2431
  }
2455
2432
  }
2456
- for (const svcId of opts.services) {
2457
- const def = SERVICE_CATALOG[svcId];
2458
- if (!def) continue;
2459
- lines.push(` ${def.id}:`);
2460
- lines.push(` image: ${def.image}`);
2461
- if (def.env) {
2433
+ for (const svc of opts.services) {
2434
+ lines.push(` ${svc.name}:`);
2435
+ lines.push(` image: ${svc.image}`);
2436
+ if (svc.restart) {
2437
+ lines.push(` restart: ${svc.restart}`);
2438
+ }
2439
+ if (svc.command !== void 0) {
2440
+ lines.push(` command: ${composeScalar(svc.command)}`);
2441
+ }
2442
+ const envKeys = Object.keys(svc.env);
2443
+ if (envKeys.length > 0) {
2462
2444
  lines.push(" environment:");
2463
- for (const [k, v] of Object.entries(def.env)) {
2464
- lines.push(` ${k}: ${v}`);
2445
+ for (const k of envKeys) {
2446
+ lines.push(` ${k}: ${composeScalar(svc.env[k])}`);
2465
2447
  }
2466
2448
  }
2467
- if (def.dataMount) {
2449
+ if (svc.volumes.length > 0) {
2468
2450
  lines.push(" volumes:");
2469
- lines.push(` - ../data/${def.id}:${def.dataMount}`);
2451
+ for (const vol of svc.volumes) {
2452
+ lines.push(` - ${composeVolumeSource(vol, svc.name)}`);
2453
+ }
2454
+ }
2455
+ if (svc.healthcheck) {
2456
+ const hc = svc.healthcheck;
2457
+ lines.push(" healthcheck:");
2458
+ if (Array.isArray(hc.test)) {
2459
+ lines.push(` test: [${hc.test.map(composeScalar).join(", ")}]`);
2460
+ } else {
2461
+ lines.push(` test: ${composeScalar(hc.test)}`);
2462
+ }
2463
+ if (hc.interval) lines.push(` interval: ${hc.interval}`);
2464
+ if (hc.timeout) lines.push(` timeout: ${hc.timeout}`);
2465
+ if (hc.retries !== void 0) lines.push(` retries: ${hc.retries}`);
2466
+ if (hc.startPeriod) {
2467
+ lines.push(` start_period: ${hc.startPeriod}`);
2468
+ }
2470
2469
  }
2471
2470
  }
2472
2471
  if (hasPorts) {
@@ -2602,243 +2601,97 @@ function buildPostCreateScript(opts) {
2602
2601
  async function writePostCreateScript(devcontainerDir, opts) {
2603
2602
  const dest = path8.join(devcontainerDir, "post-create.sh");
2604
2603
  await fs7.writeFile(dest, buildPostCreateScript(opts));
2605
- await fs7.chmod(dest, 493);
2606
- }
2607
- async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2608
- const dockerMode = scaffoldOpts.dockerMode ?? "rootful";
2609
- const devcontainerDir = path8.join(targetDir, ".devcontainer");
2610
- const monocerosDir = path8.join(targetDir, ".monoceros");
2611
- const projectsDir = path8.join(targetDir, "projects");
2612
- const homeDir = path8.join(targetDir, "home");
2613
- const dataDir = path8.join(targetDir, "data");
2614
- await fs7.mkdir(devcontainerDir, { recursive: true });
2615
- await fs7.mkdir(monocerosDir, { recursive: true });
2616
- await fs7.mkdir(projectsDir, { recursive: true });
2617
- await fs7.mkdir(homeDir, { recursive: true });
2618
- if (needsCompose(opts)) {
2619
- await fs7.mkdir(dataDir, { recursive: true });
2620
- for (const svcId of opts.services) {
2621
- const def = SERVICE_CATALOG[svcId];
2622
- if (def?.dataMount) {
2623
- await fs7.mkdir(path8.join(dataDir, def.id), { recursive: true });
2624
- }
2625
- }
2626
- }
2627
- const containerGitignore = path8.join(targetDir, ".gitignore");
2628
- await fs7.writeFile(containerGitignore, "/home/\n/.monoceros/\n/data/\n");
2629
- const gitkeep = path8.join(projectsDir, ".gitkeep");
2630
- if (!existsSync4(gitkeep)) {
2631
- await fs7.writeFile(gitkeep, "");
2632
- }
2633
- await fs7.writeFile(
2634
- path8.join(monocerosDir, ".gitignore"),
2635
- "git-credentials*\ngitconfig\n"
2636
- );
2637
- const devcontainerJson = buildDevcontainerJson(opts, dockerMode);
2638
- await fs7.writeFile(
2639
- path8.join(devcontainerDir, "devcontainer.json"),
2640
- JSON.stringify(devcontainerJson, null, 2) + "\n"
2641
- );
2642
- const featuresDir = path8.join(devcontainerDir, "features");
2643
- if (existsSync4(featuresDir)) {
2644
- await fs7.rm(featuresDir, { recursive: true, force: true });
2645
- }
2646
- const resolvedFeatures = resolveFeatures(opts);
2647
- for (const f of resolvedFeatures) {
2648
- if (!f.localSourceDir || !f.localName) continue;
2649
- const dest = path8.join(featuresDir, f.localName);
2650
- await fs7.mkdir(dest, { recursive: true });
2651
- await fs7.cp(f.localSourceDir, dest, { recursive: true });
2652
- }
2653
- for (const f of resolvedFeatures) {
2654
- for (const sub of f.persistentHomePaths) {
2655
- await fs7.mkdir(path8.join(homeDir, sub), { recursive: true });
2656
- }
2657
- for (const entry2 of f.persistentHomeFiles) {
2658
- const filePath = path8.join(homeDir, entry2.path);
2659
- await fs7.mkdir(path8.dirname(filePath), { recursive: true });
2660
- if (!existsSync4(filePath)) {
2661
- await fs7.writeFile(filePath, entry2.initialContent);
2662
- }
2663
- }
2664
- }
2665
- await writePostCreateScript(devcontainerDir, opts);
2666
- const composePath = path8.join(devcontainerDir, "compose.yaml");
2667
- if (needsCompose(opts)) {
2668
- await fs7.writeFile(composePath, buildComposeYaml(opts, dockerMode));
2669
- } else if (existsSync4(composePath)) {
2670
- await fs7.rm(composePath);
2671
- }
2672
- const workspacePath = path8.join(targetDir, `${opts.name}.code-workspace`);
2673
- let existingWorkspace;
2674
- try {
2675
- const raw = await fs7.readFile(workspacePath, "utf8");
2676
- existingWorkspace = JSON.parse(raw);
2677
- } catch {
2678
- existingWorkspace = void 0;
2679
- }
2680
- const generated = buildCodeWorkspaceJson(opts);
2681
- const merged = mergeCodeWorkspace(existingWorkspace, generated);
2682
- await fs7.writeFile(workspacePath, JSON.stringify(merged, null, 2) + "\n");
2683
- }
2684
-
2685
- // src/modify/yml.ts
2686
- import {
2687
- isMap as isMap2,
2688
- isScalar,
2689
- isSeq,
2690
- Pair as Pair2,
2691
- Scalar as Scalar2,
2692
- YAMLMap as YAMLMap2,
2693
- YAMLSeq
2694
- } from "yaml";
2695
-
2696
- // src/init/manifest.ts
2697
- import { existsSync as existsSync5, readFileSync as readFileSync3 } from "fs";
2698
- import path9 from "path";
2699
- function resolveManifestPath(name, checkoutRoot) {
2700
- if (checkoutRoot) {
2701
- const checkoutPath = path9.join(
2702
- checkoutRoot,
2703
- "images",
2704
- "features",
2705
- name,
2706
- "devcontainer-feature.json"
2707
- );
2708
- if (existsSync5(checkoutPath)) return checkoutPath;
2709
- }
2710
- const bundlePath = path9.join(
2711
- bundledFeaturesDir(),
2712
- name,
2713
- "devcontainer-feature.json"
2714
- );
2715
- if (existsSync5(bundlePath)) return bundlePath;
2716
- return null;
2717
- }
2718
- function loadFeatureManifestSummary(ref, checkoutRoot = workbenchCheckoutRoot()) {
2719
- const match = matchMonocerosFeature(ref);
2720
- if (!match) return void 0;
2721
- const manifestPath = resolveManifestPath(match.name, checkoutRoot);
2722
- if (!manifestPath) return void 0;
2723
- try {
2724
- const text = readFileSync3(manifestPath, "utf8");
2725
- const parsed = JSON.parse(text);
2726
- const rawHints = parsed["x-monoceros"]?.optionHints;
2727
- const optionHints = Array.isArray(rawHints) ? rawHints.filter(
2728
- (x) => typeof x === "string" && x.length > 0
2729
- ) : [];
2730
- const rawNotes = parsed["x-monoceros"]?.usageNotes;
2731
- const usageNotes = Array.isArray(rawNotes) ? rawNotes.filter(
2732
- (x) => typeof x === "string" && x.length > 0
2733
- ) : [];
2734
- const optionDescriptions = {};
2735
- const optionTypes = {};
2736
- const optionNames = [];
2737
- if (parsed.options) {
2738
- for (const [key, opt] of Object.entries(parsed.options)) {
2739
- if (!opt || typeof opt !== "object") continue;
2740
- optionNames.push(key);
2741
- if (typeof opt.description === "string" && opt.description.length > 0) {
2742
- optionDescriptions[key] = opt.description;
2743
- }
2744
- if (opt.type === "boolean") {
2745
- optionTypes[key] = "boolean";
2746
- } else if (opt.type === "string") {
2747
- optionTypes[key] = "string";
2748
- }
2749
- }
2750
- }
2751
- const name = typeof parsed.name === "string" ? parsed.name : "";
2752
- const description = typeof parsed.description === "string" ? parsed.description : "";
2753
- const rawUrl = typeof parsed.documentationURL === "string" ? parsed.documentationURL.trim() : "";
2754
- const documentationURL = rawUrl.length > 0 && rawUrl.toLowerCase() !== "tbd" ? rawUrl : void 0;
2755
- return {
2756
- name,
2757
- description,
2758
- documentationURL,
2759
- optionHints,
2760
- optionDescriptions,
2761
- optionNames,
2762
- optionTypes,
2763
- usageNotes
2764
- };
2765
- } catch {
2766
- return void 0;
2767
- }
2768
- }
2769
-
2770
- // src/init/feature-doc.ts
2771
- function buildFeatureHeaderLines(summary, width) {
2772
- const paragraphs = buildHeaderParagraphs(summary);
2773
- const wrapped = [];
2774
- for (const para of paragraphs) {
2775
- for (const line of wrapToComment(para, width)) {
2776
- wrapped.push(line);
2777
- }
2778
- }
2779
- return wrapped;
2780
- }
2781
- function buildFeatureHeaderCommentBefore(summary, width) {
2782
- const lines = buildFeatureHeaderLines(summary, width);
2783
- return lines.map((l) => ` ${l}`).join("\n");
2604
+ await fs7.chmod(dest, 493);
2784
2605
  }
2785
- function buildHeaderParagraphs(summary) {
2786
- if (!summary) return [];
2787
- const out = [];
2788
- const tagline = summary.name?.trim();
2789
- const description = summary.description?.trim();
2790
- if (tagline && description) {
2791
- out.push(`${tagline} \u2014 ${description}`);
2792
- } else if (tagline) {
2793
- out.push(tagline);
2794
- } else if (description) {
2795
- out.push(description);
2606
+ async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2607
+ const dockerMode = scaffoldOpts.dockerMode ?? "rootful";
2608
+ const devcontainerDir = path8.join(targetDir, ".devcontainer");
2609
+ const monocerosDir = path8.join(targetDir, ".monoceros");
2610
+ const projectsDir = path8.join(targetDir, "projects");
2611
+ const homeDir = path8.join(targetDir, "home");
2612
+ const dataDir = path8.join(targetDir, "data");
2613
+ await fs7.mkdir(devcontainerDir, { recursive: true });
2614
+ await fs7.mkdir(monocerosDir, { recursive: true });
2615
+ await fs7.mkdir(projectsDir, { recursive: true });
2616
+ await fs7.mkdir(homeDir, { recursive: true });
2617
+ if (needsCompose(opts)) {
2618
+ await fs7.mkdir(dataDir, { recursive: true });
2619
+ for (const svc of opts.services) {
2620
+ const hasDataVolume = svc.volumes.some((v) => v.split(":")[0] === "data");
2621
+ if (hasDataVolume) {
2622
+ await fs7.mkdir(path8.join(dataDir, svc.name), { recursive: true });
2623
+ }
2624
+ }
2796
2625
  }
2797
- for (const note of summary.usageNotes) {
2798
- const trimmed = note.trim();
2799
- if (trimmed.length > 0) out.push(trimmed);
2626
+ const containerGitignore = path8.join(targetDir, ".gitignore");
2627
+ await fs7.writeFile(containerGitignore, "/home/\n/.monoceros/\n/data/\n");
2628
+ const gitkeep = path8.join(projectsDir, ".gitkeep");
2629
+ if (!existsSync5(gitkeep)) {
2630
+ await fs7.writeFile(gitkeep, "");
2800
2631
  }
2801
- if (summary.optionHints.length > 0) {
2802
- const parts = summary.optionHints.map((key) => {
2803
- const desc = summary.optionDescriptions[key];
2804
- const short = desc ? shortenOptionDescription(desc) : void 0;
2805
- return short ? `${key} (${short})` : key;
2806
- });
2807
- out.push(`Options: ${parts.join(", ")}.`);
2632
+ await fs7.writeFile(
2633
+ path8.join(monocerosDir, ".gitignore"),
2634
+ "git-credentials*\ngitconfig\n"
2635
+ );
2636
+ const devcontainerJson = buildDevcontainerJson(opts, dockerMode);
2637
+ await fs7.writeFile(
2638
+ path8.join(devcontainerDir, "devcontainer.json"),
2639
+ JSON.stringify(devcontainerJson, null, 2) + "\n"
2640
+ );
2641
+ const featuresDir = path8.join(devcontainerDir, "features");
2642
+ if (existsSync5(featuresDir)) {
2643
+ await fs7.rm(featuresDir, { recursive: true, force: true });
2808
2644
  }
2809
- if (summary.documentationURL) {
2810
- out.push(`See ${summary.documentationURL} for further information.`);
2645
+ const resolvedFeatures = resolveFeatures(opts);
2646
+ for (const f of resolvedFeatures) {
2647
+ if (!f.localSourceDir || !f.localName) continue;
2648
+ const dest = path8.join(featuresDir, f.localName);
2649
+ await fs7.mkdir(dest, { recursive: true });
2650
+ await fs7.cp(f.localSourceDir, dest, { recursive: true });
2811
2651
  }
2812
- return out;
2813
- }
2814
- function shortenOptionDescription(desc) {
2815
- const firstSentence = desc.split(/(?<=[.!?])\s+/)[0]?.trim() ?? desc.trim();
2816
- return firstSentence.replace(/[.!?]+$/, "").trim();
2817
- }
2818
- function wrapToComment(text, width) {
2819
- const words = text.split(/\s+/).filter((w) => w.length > 0);
2820
- if (words.length === 0) return [""];
2821
- const usable = Math.max(width, 20);
2822
- const lines = [];
2823
- let current = "";
2824
- for (const w of words) {
2825
- if (current.length === 0) {
2826
- current = w;
2827
- continue;
2652
+ for (const f of resolvedFeatures) {
2653
+ for (const sub of f.persistentHomePaths) {
2654
+ await fs7.mkdir(path8.join(homeDir, sub), { recursive: true });
2828
2655
  }
2829
- if (current.length + 1 + w.length <= usable) {
2830
- current += " " + w;
2831
- } else {
2832
- lines.push(current);
2833
- current = w;
2656
+ for (const entry2 of f.persistentHomeFiles) {
2657
+ const filePath = path8.join(homeDir, entry2.path);
2658
+ await fs7.mkdir(path8.dirname(filePath), { recursive: true });
2659
+ if (!existsSync5(filePath)) {
2660
+ await fs7.writeFile(filePath, entry2.initialContent);
2661
+ }
2834
2662
  }
2835
2663
  }
2836
- if (current.length > 0) lines.push(current);
2837
- return lines;
2664
+ await writePostCreateScript(devcontainerDir, opts);
2665
+ const composePath = path8.join(devcontainerDir, "compose.yaml");
2666
+ if (needsCompose(opts)) {
2667
+ await fs7.writeFile(composePath, buildComposeYaml(opts, dockerMode));
2668
+ } else if (existsSync5(composePath)) {
2669
+ await fs7.rm(composePath);
2670
+ }
2671
+ const workspacePath = path8.join(targetDir, `${opts.name}.code-workspace`);
2672
+ let existingWorkspace;
2673
+ try {
2674
+ const raw = await fs7.readFile(workspacePath, "utf8");
2675
+ existingWorkspace = JSON.parse(raw);
2676
+ } catch {
2677
+ existingWorkspace = void 0;
2678
+ }
2679
+ const generated = buildCodeWorkspaceJson(opts);
2680
+ const merged = mergeCodeWorkspace(existingWorkspace, generated);
2681
+ await fs7.writeFile(workspacePath, JSON.stringify(merged, null, 2) + "\n");
2838
2682
  }
2839
- var FEATURE_HEADER_WIDTH = 76 - 2;
2840
2683
 
2841
2684
  // src/modify/yml.ts
2685
+ import {
2686
+ isMap as isMap2,
2687
+ isScalar,
2688
+ isSeq,
2689
+ Pair as Pair2,
2690
+ parseDocument as parseDocument3,
2691
+ Scalar as Scalar2,
2692
+ YAMLMap as YAMLMap2,
2693
+ YAMLSeq
2694
+ } from "yaml";
2842
2695
  function ensureSeq(doc, key) {
2843
2696
  const existing = doc.get(key, true);
2844
2697
  if (existing && isSeq(existing)) return existing;
@@ -2861,11 +2714,24 @@ function addLanguageToDoc(doc, lang) {
2861
2714
  seq.add(lang);
2862
2715
  return true;
2863
2716
  }
2864
- function addServiceToDoc(doc, service) {
2717
+ function findServiceItem(seq, name) {
2718
+ for (const item of seq.items) {
2719
+ if (isMap2(item) && item.get("name") === name) return item;
2720
+ }
2721
+ return void 0;
2722
+ }
2723
+ function addServiceEntryToDoc(doc, name, image, bodyLines, scaffoldComment) {
2865
2724
  const seq = ensureSeq(doc, "services");
2866
- if (seq.items.some((i) => scalarValue(i) === service)) return false;
2867
- seq.add(service);
2868
- return true;
2725
+ const existing = findServiceItem(seq, name);
2726
+ if (existing) {
2727
+ const existingImage = existing.get("image");
2728
+ if (existingImage === image) return { outcome: "exists" };
2729
+ return { outcome: "conflict", existingImage: String(existingImage) };
2730
+ }
2731
+ const node = parseDocument3(bodyLines.join("\n")).contents;
2732
+ if (scaffoldComment) node.comment = scaffoldComment;
2733
+ seq.add(node);
2734
+ return { outcome: "added" };
2869
2735
  }
2870
2736
  function addAptPackagesToDoc(doc, packages) {
2871
2737
  const seq = ensureSeq(doc, "aptPackages");
@@ -3085,6 +2951,12 @@ function addFeatureToDoc(doc, ref, options = {}, displayName) {
3085
2951
  entry2.commentBefore = headerBefore;
3086
2952
  entry2.spaceBefore = true;
3087
2953
  }
2954
+ const hints = featureOptionHints(summary, ref, Object.keys(options));
2955
+ if (hints.length > 0) {
2956
+ const commentLines = [" options:"];
2957
+ for (const h of hints) commentLines.push(` ${h.key}: ${h.placeholder}`);
2958
+ entry2.comment = commentLines.join("\n");
2959
+ }
3088
2960
  seq.add(entry2);
3089
2961
  return true;
3090
2962
  }
@@ -3161,7 +3033,15 @@ function removeLanguageFromDoc(doc, lang) {
3161
3033
  return removeScalarFromSeq(doc, "languages", lang);
3162
3034
  }
3163
3035
  function removeServiceFromDoc(doc, service) {
3164
- return removeScalarFromSeq(doc, "services", service);
3036
+ const node = doc.get("services", true);
3037
+ if (!node || !isSeq(node)) return false;
3038
+ const idx = node.items.findIndex(
3039
+ (i) => isMap2(i) && i.get("name") === service
3040
+ );
3041
+ if (idx === -1) return false;
3042
+ node.items.splice(idx, 1);
3043
+ pruneEmptySeq(doc, "services");
3044
+ return true;
3165
3045
  }
3166
3046
  function removeAptPackageFromDoc(doc, pkg) {
3167
3047
  return removeScalarFromSeq(doc, "aptPackages", pkg);
@@ -3201,8 +3081,8 @@ function removeRepoFromDoc(doc, urlOrPath) {
3201
3081
  if (!isMap2(item)) return false;
3202
3082
  const url = item.get("url");
3203
3083
  if (url === urlOrPath) return true;
3204
- const path18 = item.get("path");
3205
- const effectivePath = typeof path18 === "string" ? path18 : typeof url === "string" ? deriveRepoName(url) : void 0;
3084
+ const path21 = item.get("path");
3085
+ const effectivePath = typeof path21 === "string" ? path21 : typeof url === "string" ? deriveRepoName(url) : void 0;
3206
3086
  return effectivePath === urlOrPath;
3207
3087
  });
3208
3088
  if (idx < 0) return false;
@@ -3229,13 +3109,38 @@ function runAddLanguage(input) {
3229
3109
  }
3230
3110
  return mutate(input, (doc) => addLanguageToDoc(doc, input.language));
3231
3111
  }
3232
- function runAddService(input) {
3233
- if (!SERVICE_CATALOG[input.service]) {
3112
+ async function runAddService(input) {
3113
+ const arg = input.service;
3114
+ const curated = isCuratedService(arg);
3115
+ if (input.as !== void 0 && !/^[a-z0-9][a-z0-9_-]*$/.test(input.as)) {
3234
3116
  throw new Error(
3235
- `Unknown service: ${input.service}. Known: ${knownServices().join(", ")}.`
3117
+ `Invalid --as name ${JSON.stringify(input.as)}. Use lowercase letters, digits, '_' or '-' (must start with a letter or digit).`
3118
+ );
3119
+ }
3120
+ const name = input.as ?? (curated ? arg : deriveServiceName(arg));
3121
+ const image = curated ? expandCuratedService(arg).image : arg;
3122
+ const custom = curated ? null : renderCustomService(name, arg);
3123
+ const bodyLines = curated ? renderServiceObjectBody({ ...expandCuratedService(arg), name }) : custom.bodyLines;
3124
+ const scaffoldComment = curated ? void 0 : custom.comment;
3125
+ const result = await mutate(input, (doc) => {
3126
+ const r = addServiceEntryToDoc(
3127
+ doc,
3128
+ name,
3129
+ image,
3130
+ bodyLines,
3131
+ scaffoldComment
3236
3132
  );
3133
+ if (r.outcome === "conflict") {
3134
+ throw new Error(
3135
+ `A service named '${name}' already exists with a different image (${r.existingImage}). Add it under a different name with \`--as <name>\`, or remove the existing one first (\`monoceros remove-service ${input.name} ${name}\`).`
3136
+ );
3137
+ }
3138
+ return r.outcome === "added";
3139
+ });
3140
+ if (result.status === "updated" && !curated) {
3141
+ (input.logger ?? defaultLogger()).info(customServiceHint(name));
3237
3142
  }
3238
- return mutate(input, (doc) => addServiceToDoc(doc, input.service));
3143
+ return result;
3239
3144
  }
3240
3145
  function runAddAptPackages(input) {
3241
3146
  if (input.packages.length === 0) {
@@ -3252,7 +3157,7 @@ async function runAddRepo(input) {
3252
3157
  "Missing repo URL. Usage: monoceros add-repo <containername> <url>."
3253
3158
  );
3254
3159
  }
3255
- const path18 = (input.path ?? deriveRepoName(url)).trim();
3160
+ const path21 = (input.path ?? deriveRepoName(url)).trim();
3256
3161
  const hasName = typeof input.gitName === "string" && input.gitName.trim().length > 0;
3257
3162
  const hasEmail = typeof input.gitEmail === "string" && input.gitEmail.trim().length > 0;
3258
3163
  if (hasName !== hasEmail) {
@@ -3281,7 +3186,7 @@ async function runAddRepo(input) {
3281
3186
  const providerToWrite = !canonical && explicitProvider ? explicitProvider : void 0;
3282
3187
  const entry2 = {
3283
3188
  url,
3284
- path: path18,
3189
+ path: path21,
3285
3190
  ...hasName && hasEmail ? {
3286
3191
  gitUser: {
3287
3192
  name: input.gitName.trim(),
@@ -3397,7 +3302,7 @@ async function tryCloneInRunningContainer(input, entry2) {
3397
3302
  logger.info(
3398
3303
  `Cloned ${entry2.url} into /workspaces/${containerName2}/${targetRel} inside the running container.`
3399
3304
  );
3400
- void path10;
3305
+ void path9;
3401
3306
  }
3402
3307
  function shquote(value) {
3403
3308
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -3474,10 +3379,33 @@ async function runAddFeature(input) {
3474
3379
  ...resolved.defaultOptions,
3475
3380
  ...input.options ?? {}
3476
3381
  };
3477
- return mutate(
3382
+ const result = await mutate(
3478
3383
  input,
3479
3384
  (doc) => addFeatureToDoc(doc, resolved.ref, merged, raw)
3480
3385
  );
3386
+ if (result.status === "updated") {
3387
+ const summary = loadFeatureManifestSummary(resolved.ref);
3388
+ const vars = featureOptionHints(
3389
+ summary,
3390
+ resolved.ref,
3391
+ Object.keys(merged)
3392
+ ).map((h) => h.envVar);
3393
+ if (vars.length > 0) {
3394
+ const home = input.monocerosHome ?? monocerosHome();
3395
+ await ensureEnvGitignored(containerConfigsDir(home));
3396
+ const seeded = await ensureEnvVars(
3397
+ containerEnvPath(input.name, home),
3398
+ input.name,
3399
+ vars
3400
+ );
3401
+ if (seeded.added.length > 0) {
3402
+ (input.logger ?? defaultLogger()).info(
3403
+ `Seeded ${seeded.added.join(", ")} into ${input.name}.env \u2014 fill in the values.`
3404
+ );
3405
+ }
3406
+ }
3407
+ }
3408
+ return result;
3481
3409
  }
3482
3410
  async function resolveFeatureRefOrShortname(input) {
3483
3411
  if (REGEX.featureRef.test(input)) {
@@ -3616,13 +3544,13 @@ async function mutate(opts, apply) {
3616
3544
  }
3617
3545
  function defaultLogger() {
3618
3546
  return {
3619
- info: (m) => consola2.info(m),
3620
- success: (m) => consola2.success(m),
3621
- warn: (m) => consola2.warn(m)
3547
+ info: (m) => consola.info(m),
3548
+ success: (m) => consola.success(m),
3549
+ warn: (m) => consola.warn(m)
3622
3550
  };
3623
3551
  }
3624
3552
  var defaultConfirm = async (message) => {
3625
- const result = await consola2.prompt(message, {
3553
+ const result = await consola.prompt(message, {
3626
3554
  type: "confirm",
3627
3555
  initial: false
3628
3556
  });
@@ -3662,9 +3590,6 @@ async function syncPortsToProxy(input) {
3662
3590
  ...input.proxyDocker ? { docker: input.proxyDocker } : {},
3663
3591
  logger: { info: (m) => logger.info(m), warn: (m) => logger.warn(m) }
3664
3592
  });
3665
- await kickProxyReload({
3666
- ...input.proxyDocker ? { docker: input.proxyDocker } : {}
3667
- });
3668
3593
  const urls = proxyUrlsFor(input.name, allPorts, hostPort);
3669
3594
  const lines = urls.map((u) => {
3670
3595
  const tag = u.isDefault ? " (default)" : "";
@@ -3674,9 +3599,6 @@ async function syncPortsToProxy(input) {
3674
3599
  ${lines.join("\n")}`);
3675
3600
  } else {
3676
3601
  await removeDynamicConfig(input.name, { monocerosHome: home });
3677
- await kickProxyReload({
3678
- ...input.proxyDocker ? { docker: input.proxyDocker } : {}
3679
- });
3680
3602
  await maybeStopProxy({
3681
3603
  monocerosHome: home,
3682
3604
  ...input.proxyDocker ? { docker: input.proxyDocker } : {},
@@ -3713,7 +3635,7 @@ var addAptPackagesCommand = defineCommand({
3713
3635
  async run({ args }) {
3714
3636
  const packages = [...getInnerArgs()];
3715
3637
  if (packages.length === 0) {
3716
- consola3.error(
3638
+ consola2.error(
3717
3639
  "No package names given. Usage: `monoceros add-apt-packages <containername> [--yes] -- <pkg> [<pkg> \u2026]`."
3718
3640
  );
3719
3641
  process.exit(1);
@@ -3726,7 +3648,7 @@ var addAptPackagesCommand = defineCommand({
3726
3648
  });
3727
3649
  process.exit(result.status === "aborted" ? 1 : 0);
3728
3650
  } catch (err) {
3729
- consola3.error(err instanceof Error ? err.message : String(err));
3651
+ consola2.error(err instanceof Error ? err.message : String(err));
3730
3652
  process.exit(1);
3731
3653
  }
3732
3654
  }
@@ -3734,7 +3656,7 @@ var addAptPackagesCommand = defineCommand({
3734
3656
 
3735
3657
  // src/commands/add-feature.ts
3736
3658
  import { defineCommand as defineCommand2 } from "citty";
3737
- import { consola as consola4 } from "consola";
3659
+ import { consola as consola3 } from "consola";
3738
3660
  var addFeatureCommand = defineCommand2({
3739
3661
  meta: {
3740
3662
  name: "add-feature",
@@ -3764,7 +3686,7 @@ var addFeatureCommand = defineCommand2({
3764
3686
  try {
3765
3687
  options = parseOptionsAfterDashes(getInnerArgs());
3766
3688
  } catch (err) {
3767
- consola4.error(err instanceof Error ? err.message : String(err));
3689
+ consola3.error(err instanceof Error ? err.message : String(err));
3768
3690
  process.exit(1);
3769
3691
  }
3770
3692
  try {
@@ -3776,7 +3698,7 @@ var addFeatureCommand = defineCommand2({
3776
3698
  });
3777
3699
  process.exit(result.status === "aborted" ? 1 : 0);
3778
3700
  } catch (err) {
3779
- consola4.error(err instanceof Error ? err.message : String(err));
3701
+ consola3.error(err instanceof Error ? err.message : String(err));
3780
3702
  process.exit(1);
3781
3703
  }
3782
3704
  }
@@ -3808,7 +3730,7 @@ function coerce(value) {
3808
3730
 
3809
3731
  // src/commands/add-from-url.ts
3810
3732
  import { defineCommand as defineCommand3 } from "citty";
3811
- import { consola as consola5 } from "consola";
3733
+ import { consola as consola4 } from "consola";
3812
3734
  var addFromUrlCommand = defineCommand3({
3813
3735
  meta: {
3814
3736
  name: "add-from-url",
@@ -3845,7 +3767,7 @@ var addFromUrlCommand = defineCommand3({
3845
3767
  });
3846
3768
  process.exit(result.status === "aborted" ? 1 : 0);
3847
3769
  } catch (err) {
3848
- consola5.error(err instanceof Error ? err.message : String(err));
3770
+ consola4.error(err instanceof Error ? err.message : String(err));
3849
3771
  process.exit(1);
3850
3772
  }
3851
3773
  }
@@ -3880,7 +3802,7 @@ function printSecurityWarning(url) {
3880
3802
 
3881
3803
  // src/commands/add-repo.ts
3882
3804
  import { defineCommand as defineCommand4 } from "citty";
3883
- import { consola as consola6 } from "consola";
3805
+ import { consola as consola5 } from "consola";
3884
3806
  var addRepoCommand = defineCommand4({
3885
3807
  meta: {
3886
3808
  name: "add-repo",
@@ -3934,7 +3856,7 @@ var addRepoCommand = defineCommand4({
3934
3856
  });
3935
3857
  process.exit(result.status === "aborted" ? 1 : 0);
3936
3858
  } catch (err) {
3937
- consola6.error(err instanceof Error ? err.message : String(err));
3859
+ consola5.error(err instanceof Error ? err.message : String(err));
3938
3860
  process.exit(1);
3939
3861
  }
3940
3862
  }
@@ -3942,7 +3864,7 @@ var addRepoCommand = defineCommand4({
3942
3864
 
3943
3865
  // src/commands/add-language.ts
3944
3866
  import { defineCommand as defineCommand5 } from "citty";
3945
- import { consola as consola7 } from "consola";
3867
+ import { consola as consola6 } from "consola";
3946
3868
  var addLanguageCommand = defineCommand5({
3947
3869
  meta: {
3948
3870
  name: "add-language",
@@ -3955,11 +3877,111 @@ var addLanguageCommand = defineCommand5({
3955
3877
  description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3956
3878
  required: true
3957
3879
  },
3958
- language: {
3880
+ language: {
3881
+ type: "positional",
3882
+ description: "Language identifier from the feature whitelist (e.g. python, java, rust).",
3883
+ required: true
3884
+ },
3885
+ yes: {
3886
+ type: "boolean",
3887
+ description: "Skip the interactive confirmation and apply the diff.",
3888
+ alias: ["y"],
3889
+ default: false
3890
+ }
3891
+ },
3892
+ async run({ args }) {
3893
+ try {
3894
+ const result = await runAddLanguage({
3895
+ name: args.name,
3896
+ language: args.language,
3897
+ yes: args.yes
3898
+ });
3899
+ process.exit(result.status === "aborted" ? 1 : 0);
3900
+ } catch (err) {
3901
+ consola6.error(err instanceof Error ? err.message : String(err));
3902
+ process.exit(1);
3903
+ }
3904
+ }
3905
+ });
3906
+
3907
+ // src/commands/add-port.ts
3908
+ import { defineCommand as defineCommand6 } from "citty";
3909
+ import { consola as consola7 } from "consola";
3910
+ var addPortCommand = defineCommand6({
3911
+ meta: {
3912
+ name: "add-port",
3913
+ group: "edit",
3914
+ description: "Add one or more ports to the container config so they become reachable from the host via Traefik (`<container>.localhost` / `<container>-<port>.localhost`). Pass port numbers after `--` (e.g. `monoceros add-port sandbox -- 3000 5173 6006`). Idempotent. Persisted in the yml so later `monoceros apply` runs restore the routes. Pass `--default` together with a single port to make it the bare `<container>.localhost` route \u2014 the port is inserted at position 0 (or moved there if it already exists)."
3915
+ },
3916
+ args: {
3917
+ name: {
3918
+ type: "positional",
3919
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3920
+ required: true
3921
+ },
3922
+ yes: {
3923
+ type: "boolean",
3924
+ description: "Skip the interactive confirmation and apply the diff.",
3925
+ alias: ["y"],
3926
+ default: false
3927
+ },
3928
+ default: {
3929
+ type: "boolean",
3930
+ description: "Make the (single) port the new default route at `<container>.localhost`. Inserts the port at position 0 of `routing.ports`, or moves it there if it already exists. Errors when more than one port is passed.",
3931
+ default: false
3932
+ }
3933
+ },
3934
+ async run({ args }) {
3935
+ const tokens = [...getInnerArgs()];
3936
+ if (tokens.length === 0) {
3937
+ consola7.error(
3938
+ "No ports given. Usage: `monoceros add-port <containername> [--yes] [--default] -- <port> [<port> \u2026]`."
3939
+ );
3940
+ process.exit(1);
3941
+ }
3942
+ try {
3943
+ const result = await runAddPort({
3944
+ name: args.name,
3945
+ ports: tokens.map(coerceToken),
3946
+ yes: args.yes,
3947
+ asDefault: args.default
3948
+ });
3949
+ process.exit(result.status === "aborted" ? 1 : 0);
3950
+ } catch (err) {
3951
+ consola7.error(err instanceof Error ? err.message : String(err));
3952
+ process.exit(1);
3953
+ }
3954
+ }
3955
+ });
3956
+ function coerceToken(raw) {
3957
+ const n = Number(raw);
3958
+ return Number.isFinite(n) ? n : raw;
3959
+ }
3960
+
3961
+ // src/commands/add-service.ts
3962
+ import { defineCommand as defineCommand7 } from "citty";
3963
+ import { consola as consola8 } from "consola";
3964
+ var addServiceCommand = defineCommand7({
3965
+ meta: {
3966
+ name: "add-service",
3967
+ group: "edit",
3968
+ description: "Add a backing service to the container config. A curated name (postgres, mysql, redis) expands to a full editable block; any other image (e.g. rustfs/rustfs:latest) drops in name + image plus a commented scaffold. Idempotent, prints a diff before writing."
3969
+ },
3970
+ args: {
3971
+ name: {
3972
+ type: "positional",
3973
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3974
+ required: true
3975
+ },
3976
+ service: {
3959
3977
  type: "positional",
3960
- description: "Language identifier from the feature whitelist (e.g. python, java, rust).",
3978
+ description: "Curated name (postgres, mysql, redis) or any image ref (e.g. rustfs/rustfs:latest).",
3961
3979
  required: true
3962
3980
  },
3981
+ as: {
3982
+ type: "string",
3983
+ description: "Override the service name (the compose service / DNS name / data dir). Lets you add the same image more than once \u2014 e.g. two postgres servers as postgres-app and postgres-analytics."
3984
+ },
3963
3985
  yes: {
3964
3986
  type: "boolean",
3965
3987
  description: "Skip the interactive confirmation and apply the diff.",
@@ -3969,213 +3991,464 @@ var addLanguageCommand = defineCommand5({
3969
3991
  },
3970
3992
  async run({ args }) {
3971
3993
  try {
3972
- const result = await runAddLanguage({
3994
+ const result = await runAddService({
3973
3995
  name: args.name,
3974
- language: args.language,
3996
+ service: args.service,
3997
+ ...args.as ? { as: args.as } : {},
3975
3998
  yes: args.yes
3976
3999
  });
3977
4000
  process.exit(result.status === "aborted" ? 1 : 0);
3978
4001
  } catch (err) {
3979
- consola7.error(err instanceof Error ? err.message : String(err));
4002
+ consola8.error(err instanceof Error ? err.message : String(err));
3980
4003
  process.exit(1);
3981
4004
  }
3982
4005
  }
3983
4006
  });
3984
4007
 
3985
- // src/commands/add-port.ts
3986
- import { defineCommand as defineCommand6 } from "citty";
3987
- import { consola as consola8 } from "consola";
3988
- var addPortCommand = defineCommand6({
3989
- meta: {
3990
- name: "add-port",
3991
- group: "edit",
3992
- description: "Add one or more ports to the container config so they become reachable from the host via Traefik (`<container>.localhost` / `<container>-<port>.localhost`). Pass port numbers after `--` (e.g. `monoceros add-port sandbox -- 3000 5173 6006`). Idempotent. Persisted in the yml so later `monoceros apply` runs restore the routes. Pass `--default` together with a single port to make it the bare `<container>.localhost` route \u2014 the port is inserted at position 0 (or moved there if it already exists)."
3993
- },
3994
- args: {
3995
- name: {
3996
- type: "positional",
3997
- description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3998
- required: true
4008
+ // src/commands/apply.ts
4009
+ import { defineCommand as defineCommand8 } from "citty";
4010
+
4011
+ // src/apply/index.ts
4012
+ import { existsSync as existsSync8, promises as fs12 } from "fs";
4013
+ import { consola as consola11 } from "consola";
4014
+
4015
+ // src/config/state.ts
4016
+ import { promises as fs9 } from "fs";
4017
+ import path10 from "path";
4018
+ function buildStateFile(opts) {
4019
+ return {
4020
+ schemaVersion: CONFIG_SCHEMA_VERSION,
4021
+ origin: opts.origin,
4022
+ monocerosCliVersion: opts.cliVersion,
4023
+ materializedAt: (opts.now ?? /* @__PURE__ */ new Date()).toISOString()
4024
+ };
4025
+ }
4026
+ function stateFilePath(targetDir) {
4027
+ return path10.join(targetDir, ".monoceros", "state.json");
4028
+ }
4029
+ async function readStateFile(targetDir) {
4030
+ try {
4031
+ const content = await fs9.readFile(stateFilePath(targetDir), "utf8");
4032
+ return JSON.parse(content);
4033
+ } catch {
4034
+ return void 0;
4035
+ }
4036
+ }
4037
+ async function writeStateFile(targetDir, state) {
4038
+ const monocerosDir = path10.join(targetDir, ".monoceros");
4039
+ await fs9.mkdir(monocerosDir, { recursive: true });
4040
+ await fs9.writeFile(
4041
+ stateFilePath(targetDir),
4042
+ JSON.stringify(state, null, 2) + "\n"
4043
+ );
4044
+ }
4045
+
4046
+ // src/config/transform.ts
4047
+ function solutionConfigToCreateOptions(config, featureDefaults = {}) {
4048
+ const featureRecord = {};
4049
+ for (const entry2 of config.features) {
4050
+ const defaults = featureDefaults[entry2.ref] ?? {};
4051
+ const containerOpts = Object.fromEntries(
4052
+ Object.entries(entry2.options ?? {}).filter(([, v]) => v !== "")
4053
+ );
4054
+ featureRecord[entry2.ref] = { ...defaults, ...containerOpts };
4055
+ }
4056
+ const result = {
4057
+ name: config.name,
4058
+ languages: [...config.languages],
4059
+ // Normalize every services[] entry (curated string or explicit
4060
+ // object) to the canonical ResolvedService shape. `${VAR}` values
4061
+ // survive untouched here — apply interpolates them against
4062
+ // <name>.env afterwards.
4063
+ services: config.services.map(resolveService)
4064
+ };
4065
+ if (config.externalServices.postgres !== void 0) {
4066
+ result.postgresUrl = config.externalServices.postgres;
4067
+ }
4068
+ if (config.aptPackages.length > 0) {
4069
+ result.aptPackages = [...config.aptPackages];
4070
+ }
4071
+ if (Object.keys(featureRecord).length > 0) {
4072
+ result.features = featureRecord;
4073
+ }
4074
+ if (config.installUrls.length > 0) {
4075
+ result.installUrls = [...config.installUrls];
4076
+ }
4077
+ if (config.repos.length > 0) {
4078
+ result.repos = config.repos.map((r) => ({
4079
+ url: r.url,
4080
+ // `path` is optional in the yml; CreateOptions requires it.
4081
+ // When the yml omits `path`, fall back to the URL-derived
4082
+ // single-segment default (`https://.../foo.git` → `foo`),
4083
+ // which lands the clone at `projects/foo/`.
4084
+ path: r.path ?? deriveRepoName(r.url),
4085
+ // gitUser is forwarded only when BOTH name + email are set.
4086
+ // The relaxed GitUserSchema accepts nullable / empty strings
4087
+ // (so a yml placeholder `name:` parses without error), so we
4088
+ // re-check here before downstream code, which expects both
4089
+ // values to be non-empty.
4090
+ ...r.git?.user?.name && r.git.user.email ? { gitUser: { name: r.git.user.name, email: r.git.user.email } } : {},
4091
+ ...r.provider ? { provider: r.provider } : {}
4092
+ }));
4093
+ }
4094
+ const routingPorts = config.routing?.ports ?? [];
4095
+ if (routingPorts.length > 0) {
4096
+ const seen = /* @__PURE__ */ new Set();
4097
+ const ports = [];
4098
+ for (const entry2 of routingPorts) {
4099
+ const n = portNumber(entry2);
4100
+ if (seen.has(n)) continue;
4101
+ seen.add(n);
4102
+ ports.push(n);
4103
+ }
4104
+ result.ports = ports;
4105
+ }
4106
+ if (config.routing?.vscodeAutoForward !== void 0) {
4107
+ result.vscodeAutoForward = config.routing.vscodeAutoForward;
4108
+ }
4109
+ return result;
4110
+ }
4111
+
4112
+ // src/devcontainer/compose.ts
4113
+ import { spawn as spawn5 } from "child_process";
4114
+ import { existsSync as existsSync6 } from "fs";
4115
+ import path12 from "path";
4116
+ import { consola as consola9 } from "consola";
4117
+
4118
+ // src/util/mask-secrets.ts
4119
+ import { Transform } from "stream";
4120
+ var PATTERNS = [
4121
+ // Atlassian Cloud API token. Starts with literal `ATATT3xFf` plus
4122
+ // a long URL-safe-base64 tail. Tightened to that prefix to avoid
4123
+ // matching unrelated all-caps words.
4124
+ { name: "atlassian-api", re: /ATATT3xFf[A-Za-z0-9+/=_-]{20,}/g },
4125
+ // Bitbucket Cloud app password.
4126
+ { name: "bitbucket-app", re: /ATBB[A-Za-z0-9+/=_-]{20,}/g },
4127
+ // GitHub PAT (classic), OAuth, user, server, refresh — all share
4128
+ // the `gh<lower-letter>_<base62>` shape per GitHub's token format.
4129
+ { name: "github-token", re: /gh[a-z]_[A-Za-z0-9]{20,}/g },
4130
+ // GitHub fine-grained PAT.
4131
+ { name: "github-pat", re: /github_pat_[A-Za-z0-9_]{20,}/g },
4132
+ // Anthropic API key.
4133
+ { name: "anthropic-api", re: /sk-ant-[A-Za-z0-9_-]{20,}/g }
4134
+ ];
4135
+ function maskSecrets(text) {
4136
+ let result = text;
4137
+ for (const { re } of PATTERNS) {
4138
+ result = result.replace(re, maskOne);
4139
+ }
4140
+ return result;
4141
+ }
4142
+ function maskOne(token) {
4143
+ if (token.length <= 12) return token;
4144
+ return `${token.slice(0, 5)}\u2026${token.slice(-6)}`;
4145
+ }
4146
+ function createSecretMaskStream() {
4147
+ let buffer = "";
4148
+ return new Transform({
4149
+ decodeStrings: true,
4150
+ transform(chunk, _enc, cb) {
4151
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
4152
+ buffer += text;
4153
+ const lastNewline = buffer.lastIndexOf("\n");
4154
+ if (lastNewline === -1) {
4155
+ cb(null);
4156
+ return;
4157
+ }
4158
+ const flushable = buffer.slice(0, lastNewline + 1);
4159
+ buffer = buffer.slice(lastNewline + 1);
4160
+ cb(null, maskSecrets(flushable));
3999
4161
  },
4000
- yes: {
4001
- type: "boolean",
4002
- description: "Skip the interactive confirmation and apply the diff.",
4003
- alias: ["y"],
4004
- default: false
4162
+ flush(cb) {
4163
+ if (buffer.length > 0) {
4164
+ const tail = maskSecrets(buffer);
4165
+ buffer = "";
4166
+ cb(null, tail);
4167
+ return;
4168
+ }
4169
+ cb(null);
4170
+ }
4171
+ });
4172
+ }
4173
+
4174
+ // src/devcontainer/cli.ts
4175
+ import { spawn as spawn4 } from "child_process";
4176
+ import { readFileSync as readFileSync4 } from "fs";
4177
+ import { createRequire } from "module";
4178
+ import path11 from "path";
4179
+
4180
+ // src/devcontainer/runtime-pull-hint.ts
4181
+ import { Transform as Transform2 } from "stream";
4182
+ var RUNTIME_PULL_MARKER = "No manifest found for ghcr.io/getmonoceros/monoceros-runtime";
4183
+ var RUNTIME_PULL_HINT = 'Downloading the Monoceros runtime image now -- expected on first apply, takes ~1-2 min (Docker pulls the multi-arch base with no progress output). The "No manifest found" line above is harmless. Please wait...';
4184
+ function createRuntimePullHintStream(state) {
4185
+ let buffer = "";
4186
+ const appendHintIfMarker = (block) => {
4187
+ if (state.hinted || !block.includes(RUNTIME_PULL_MARKER)) return block;
4188
+ state.hinted = true;
4189
+ return `${block}${dim(`(i) ${RUNTIME_PULL_HINT}`)}
4190
+ `;
4191
+ };
4192
+ return new Transform2({
4193
+ decodeStrings: true,
4194
+ transform(chunk, _enc, cb) {
4195
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
4196
+ buffer += text;
4197
+ const lastNewline = buffer.lastIndexOf("\n");
4198
+ if (lastNewline === -1) {
4199
+ cb(null);
4200
+ return;
4201
+ }
4202
+ const flushable = buffer.slice(0, lastNewline + 1);
4203
+ buffer = buffer.slice(lastNewline + 1);
4204
+ cb(null, appendHintIfMarker(flushable));
4005
4205
  },
4006
- default: {
4007
- type: "boolean",
4008
- description: "Make the (single) port the new default route at `<container>.localhost`. Inserts the port at position 0 of `routing.ports`, or moves it there if it already exists. Errors when more than one port is passed.",
4009
- default: false
4206
+ flush(cb) {
4207
+ if (buffer.length === 0) {
4208
+ cb(null);
4209
+ return;
4210
+ }
4211
+ const tail = buffer;
4212
+ buffer = "";
4213
+ cb(null, appendHintIfMarker(tail));
4010
4214
  }
4011
- },
4012
- async run({ args }) {
4013
- const tokens = [...getInnerArgs()];
4014
- if (tokens.length === 0) {
4015
- consola8.error(
4016
- "No ports given. Usage: `monoceros add-port <containername> [--yes] [--default] -- <port> [<port> \u2026]`."
4017
- );
4018
- process.exit(1);
4215
+ });
4216
+ }
4217
+
4218
+ // src/devcontainer/cli.ts
4219
+ var require_ = createRequire(import.meta.url);
4220
+ var cachedBinaryPath = null;
4221
+ function devcontainerCliPath() {
4222
+ if (cachedBinaryPath) return cachedBinaryPath;
4223
+ const pkgJsonPath = require_.resolve("@devcontainers/cli/package.json");
4224
+ const pkg = JSON.parse(readFileSync4(pkgJsonPath, "utf8"));
4225
+ const binEntry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.devcontainer ?? "";
4226
+ if (!binEntry) {
4227
+ throw new Error("Could not resolve @devcontainers/cli bin entry.");
4228
+ }
4229
+ cachedBinaryPath = path11.resolve(path11.dirname(pkgJsonPath), binEntry);
4230
+ return cachedBinaryPath;
4231
+ }
4232
+ var spawnDevcontainer = (args, cwd, options = {}) => {
4233
+ const binPath = devcontainerCliPath();
4234
+ return new Promise((resolve, reject) => {
4235
+ if (options.interactive) {
4236
+ const child2 = spawn4(process.execPath, [binPath, ...args], {
4237
+ cwd,
4238
+ stdio: "inherit"
4239
+ });
4240
+ child2.on("error", reject);
4241
+ child2.on("exit", (code) => resolve(code ?? 0));
4242
+ return;
4019
4243
  }
4020
- try {
4021
- const result = await runAddPort({
4022
- name: args.name,
4023
- ports: tokens.map(coerceToken),
4024
- yes: args.yes,
4025
- asDefault: args.default
4244
+ const child = spawn4(process.execPath, [binPath, ...args], {
4245
+ cwd,
4246
+ stdio: ["ignore", "pipe", "pipe"]
4247
+ });
4248
+ if (options.quiet) {
4249
+ const stdoutChunks = [];
4250
+ const stderrChunks = [];
4251
+ child.stdout?.on("data", (chunk) => stdoutChunks.push(chunk));
4252
+ child.stderr?.on("data", (chunk) => stderrChunks.push(chunk));
4253
+ child.on("error", reject);
4254
+ child.on("exit", (code) => {
4255
+ const exitCode = code ?? 0;
4256
+ if (exitCode !== 0) {
4257
+ process.stderr.write(
4258
+ maskSecrets(Buffer.concat(stderrChunks).toString("utf8"))
4259
+ );
4260
+ process.stderr.write(
4261
+ maskSecrets(Buffer.concat(stdoutChunks).toString("utf8"))
4262
+ );
4263
+ }
4264
+ resolve(exitCode);
4026
4265
  });
4027
- process.exit(result.status === "aborted" ? 1 : 0);
4028
- } catch (err) {
4029
- consola8.error(err instanceof Error ? err.message : String(err));
4030
- process.exit(1);
4266
+ return;
4267
+ }
4268
+ const pullHint = { hinted: false };
4269
+ child.stdout?.pipe(createSecretMaskStream()).pipe(createRuntimePullHintStream(pullHint)).pipe(process.stdout);
4270
+ child.stderr?.pipe(createSecretMaskStream()).pipe(createRuntimePullHintStream(pullHint)).pipe(process.stderr);
4271
+ child.on("error", reject);
4272
+ child.on("exit", (code) => resolve(code ?? 0));
4273
+ });
4274
+ };
4275
+
4276
+ // src/devcontainer/compose.ts
4277
+ var spawnDockerCompose = (args, cwd) => {
4278
+ return new Promise((resolve, reject) => {
4279
+ const child = spawn5("docker", ["compose", ...args], {
4280
+ cwd,
4281
+ stdio: ["inherit", "pipe", "pipe"]
4282
+ });
4283
+ child.stdout?.pipe(createSecretMaskStream()).pipe(process.stdout);
4284
+ child.stderr?.pipe(createSecretMaskStream()).pipe(process.stderr);
4285
+ child.on("error", reject);
4286
+ child.on("exit", (code) => resolve(code ?? 0));
4287
+ });
4288
+ };
4289
+ var spawnDocker = (args) => {
4290
+ return new Promise((resolve, reject) => {
4291
+ const child = spawn5("docker", args, {
4292
+ stdio: ["ignore", "pipe", "pipe"]
4293
+ });
4294
+ let stdout = "";
4295
+ let stderr = "";
4296
+ child.stdout?.on("data", (chunk) => {
4297
+ stdout += chunk.toString("utf8");
4298
+ });
4299
+ child.stderr?.on("data", (chunk) => {
4300
+ stderr += chunk.toString("utf8");
4301
+ });
4302
+ child.on("error", reject);
4303
+ child.on(
4304
+ "exit",
4305
+ (code) => resolve({ exitCode: code ?? 0, stdout, stderr })
4306
+ );
4307
+ });
4308
+ };
4309
+ async function findContainerIds(filters, exec = spawnDocker) {
4310
+ const ids = /* @__PURE__ */ new Set();
4311
+ for (const filter of filters) {
4312
+ const result = await exec(["ps", "-aq", "--filter", filter]);
4313
+ if (result.exitCode !== 0) continue;
4314
+ for (const line of result.stdout.split(/\r?\n/)) {
4315
+ const id = line.trim();
4316
+ if (id) ids.add(id);
4031
4317
  }
4032
4318
  }
4033
- });
4034
- function coerceToken(raw) {
4035
- const n = Number(raw);
4036
- return Number.isFinite(n) ? n : raw;
4319
+ return [...ids];
4037
4320
  }
4038
-
4039
- // src/commands/add-service.ts
4040
- import { defineCommand as defineCommand7 } from "citty";
4041
- import { consola as consola9 } from "consola";
4042
- var addServiceCommand = defineCommand7({
4043
- meta: {
4044
- name: "add-service",
4045
- group: "edit",
4046
- description: "Add a compose service (postgres, mysql, redis, \u2026) to the container config. Idempotent, prints a diff before writing."
4047
- },
4048
- args: {
4049
- name: {
4050
- type: "positional",
4051
- description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
4052
- required: true
4053
- },
4054
- service: {
4055
- type: "positional",
4056
- description: "Service identifier (postgres, mysql, redis).",
4057
- required: true
4058
- },
4059
- yes: {
4060
- type: "boolean",
4061
- description: "Skip the interactive confirmation and apply the diff.",
4062
- alias: ["y"],
4063
- default: false
4321
+ async function cleanupDockerObjects(opts) {
4322
+ const exec = opts.exec ?? spawnDocker;
4323
+ const tag = opts.logTag ?? "cleanup";
4324
+ opts.logger.info(`[${tag}] tearing down docker project ${opts.projectName}\u2026`);
4325
+ const ids = await findContainerIds(opts.filters, exec);
4326
+ let rmExit = 0;
4327
+ if (ids.length > 0) {
4328
+ opts.logger.info(`[${tag}] removing containers: ${ids.join(" ")}`);
4329
+ const rmResult = await exec(["rm", "-f", ...ids]);
4330
+ rmExit = rmResult.exitCode;
4331
+ if (rmExit !== 0 && rmResult.stderr.trim()) {
4332
+ opts.logger.info(`[${tag}] ${rmResult.stderr.trim()}`);
4064
4333
  }
4065
- },
4066
- async run({ args }) {
4067
- try {
4068
- const result = await runAddService({
4069
- name: args.name,
4070
- service: args.service,
4071
- yes: args.yes
4072
- });
4073
- process.exit(result.status === "aborted" ? 1 : 0);
4074
- } catch (err) {
4075
- consola9.error(err instanceof Error ? err.message : String(err));
4076
- process.exit(1);
4334
+ } else {
4335
+ opts.logger.info(`[${tag}] no containers found`);
4336
+ }
4337
+ if (opts.network) {
4338
+ const netResult = await exec(["network", "rm", opts.network]);
4339
+ if (netResult.exitCode === 0) {
4340
+ opts.logger.info(`[${tag}] network ${opts.network} removed`);
4077
4341
  }
4078
4342
  }
4079
- });
4080
-
4081
- // src/commands/apply.ts
4082
- import { defineCommand as defineCommand8 } from "citty";
4083
-
4084
- // src/apply/index.ts
4085
- import { existsSync as existsSync6, promises as fs11 } from "fs";
4086
- import { consola as consola11 } from "consola";
4087
-
4088
- // src/config/state.ts
4089
- import { promises as fs9 } from "fs";
4090
- import path11 from "path";
4091
- function buildStateFile(opts) {
4092
- return {
4093
- schemaVersion: CONFIG_SCHEMA_VERSION,
4094
- origin: opts.origin,
4095
- monocerosCliVersion: opts.cliVersion,
4096
- materializedAt: (opts.now ?? /* @__PURE__ */ new Date()).toISOString()
4097
- };
4343
+ opts.logger.info(`[${tag}] docker cleanup done`);
4344
+ return { exitCode: rmExit, removedIds: ids };
4098
4345
  }
4099
- function stateFilePath(targetDir) {
4100
- return path11.join(targetDir, ".monoceros", "state.json");
4346
+ function composeProjectName(root) {
4347
+ return `${path12.basename(root)}_devcontainer`;
4101
4348
  }
4102
- async function readStateFile(targetDir) {
4103
- try {
4104
- const content = await fs9.readFile(stateFilePath(targetDir), "utf8");
4105
- return JSON.parse(content);
4106
- } catch {
4107
- return void 0;
4349
+ function resolveCompose(root) {
4350
+ if (!existsSync6(path12.join(root, ".devcontainer"))) {
4351
+ throw new Error(
4352
+ `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
4353
+ );
4354
+ }
4355
+ const composeFile = path12.join(root, ".devcontainer", "compose.yaml");
4356
+ if (!existsSync6(composeFile)) {
4357
+ throw new Error(
4358
+ `No compose.yaml at ${composeFile}. \`start\` / \`stop\` / \`status\` / \`logs\` require services configured via \`monoceros add-service <name> <svc>\`. Use \`monoceros shell <name>\` to enter the container directly.`
4359
+ );
4108
4360
  }
4361
+ return { composeFile, projectName: composeProjectName(root) };
4109
4362
  }
4110
- async function writeStateFile(targetDir, state) {
4111
- const monocerosDir = path11.join(targetDir, ".monoceros");
4112
- await fs9.mkdir(monocerosDir, { recursive: true });
4113
- await fs9.writeFile(
4114
- stateFilePath(targetDir),
4115
- JSON.stringify(state, null, 2) + "\n"
4363
+ async function runComposeAction(buildSubArgs, opts) {
4364
+ const { composeFile, projectName } = resolveCompose(opts.root);
4365
+ const spawnFn = opts.spawn ?? spawnDockerCompose;
4366
+ const subArgs = buildSubArgs(opts.service);
4367
+ return spawnFn(["-f", composeFile, "-p", projectName, ...subArgs], opts.root);
4368
+ }
4369
+ async function runStart(opts) {
4370
+ resolveCompose(opts.root);
4371
+ const logger = opts.logger ?? { info: (msg) => consola9.info(msg) };
4372
+ const spawnFn = opts.spawn ?? spawnDevcontainer;
4373
+ logger.info(`Bringing devcontainer up at ${opts.root}\u2026`);
4374
+ return spawnFn(
4375
+ ["up", "--workspace-folder", opts.root, "--mount-workspace-git-root=false"],
4376
+ opts.root
4116
4377
  );
4117
4378
  }
4118
-
4119
- // src/config/transform.ts
4120
- function solutionConfigToCreateOptions(config, featureDefaults = {}) {
4121
- const featureRecord = {};
4122
- for (const entry2 of config.features) {
4123
- const defaults = featureDefaults[entry2.ref] ?? {};
4124
- const containerOpts = Object.fromEntries(
4125
- Object.entries(entry2.options ?? {}).filter(([, v]) => v !== "")
4379
+ async function runContainerCycle(root, opts) {
4380
+ const { hasCompose, logger } = opts;
4381
+ if (hasCompose) {
4382
+ const projectName = composeProjectName(root);
4383
+ logger.info(
4384
+ `Force-removing existing ${projectName} containers (volumes preserved)\u2026`
4126
4385
  );
4127
- featureRecord[entry2.ref] = { ...defaults, ...containerOpts };
4128
- }
4129
- const result = {
4130
- name: config.name,
4131
- languages: [...config.languages],
4132
- services: [...config.services]
4133
- };
4134
- if (config.externalServices.postgres !== void 0) {
4135
- result.postgresUrl = config.externalServices.postgres;
4136
- }
4137
- if (config.aptPackages.length > 0) {
4138
- result.aptPackages = [...config.aptPackages];
4139
- }
4140
- if (Object.keys(featureRecord).length > 0) {
4141
- result.features = featureRecord;
4142
- }
4143
- if (config.installUrls.length > 0) {
4144
- result.installUrls = [...config.installUrls];
4145
- }
4146
- if (config.repos.length > 0) {
4147
- result.repos = config.repos.map((r) => ({
4148
- url: r.url,
4149
- // `path` is optional in the yml; CreateOptions requires it.
4150
- // When the yml omits `path`, fall back to the URL-derived
4151
- // single-segment default (`https://.../foo.git` → `foo`),
4152
- // which lands the clone at `projects/foo/`.
4153
- path: r.path ?? deriveRepoName(r.url),
4154
- // gitUser is forwarded only when BOTH name + email are set.
4155
- // The relaxed GitUserSchema accepts nullable / empty strings
4156
- // (so a yml placeholder `name:` parses without error), so we
4157
- // re-check here before downstream code, which expects both
4158
- // values to be non-empty.
4159
- ...r.git?.user?.name && r.git.user.email ? { gitUser: { name: r.git.user.name, email: r.git.user.email } } : {},
4160
- ...r.provider ? { provider: r.provider } : {}
4161
- }));
4162
- }
4163
- const routingPorts = config.routing?.ports ?? [];
4164
- if (routingPorts.length > 0) {
4165
- const seen = /* @__PURE__ */ new Set();
4166
- const ports = [];
4167
- for (const entry2 of routingPorts) {
4168
- const n = portNumber(entry2);
4169
- if (seen.has(n)) continue;
4170
- seen.add(n);
4171
- ports.push(n);
4386
+ const exec = opts.dockerExec ?? spawnDocker;
4387
+ const filters = [
4388
+ `label=com.docker.compose.project=${projectName}`,
4389
+ `name=^${projectName}-`
4390
+ ];
4391
+ const { exitCode: rmExit } = await cleanupDockerObjects({
4392
+ projectName,
4393
+ filters,
4394
+ network: `${projectName}_default`,
4395
+ logger,
4396
+ exec
4397
+ });
4398
+ if (rmExit !== 0) return rmExit;
4399
+ const remaining = await findContainerIds(filters, exec);
4400
+ if (remaining.length > 0) {
4401
+ const warn = logger.warn ?? logger.info;
4402
+ warn(
4403
+ `ERROR: containers under project ${projectName} reappeared after removal.
4404
+ This typically means VS Code's Remote Containers extension is connected
4405
+ to this devcontainer and auto-recreated it. Close the dev container
4406
+ session in VS Code (Cmd+Shift+P \u2192 'Dev Containers: Close Remote Connection')
4407
+ and retry \`monoceros apply\`.`
4408
+ );
4409
+ return 1;
4172
4410
  }
4173
- result.ports = ports;
4174
- }
4175
- if (config.routing?.vscodeAutoForward !== void 0) {
4176
- result.vscodeAutoForward = config.routing.vscodeAutoForward;
4411
+ return runStart({
4412
+ root,
4413
+ ...opts.devcontainerSpawn ? { spawn: opts.devcontainerSpawn } : {},
4414
+ logger
4415
+ });
4177
4416
  }
4178
- return result;
4417
+ logger.info(`Recreating image-mode devcontainer at ${root}\u2026`);
4418
+ const spawnFn = opts.devcontainerSpawn ?? spawnDevcontainer;
4419
+ return spawnFn(
4420
+ [
4421
+ "up",
4422
+ "--workspace-folder",
4423
+ root,
4424
+ "--mount-workspace-git-root=false",
4425
+ "--remove-existing-container"
4426
+ ],
4427
+ root
4428
+ );
4429
+ }
4430
+ function runStop(opts) {
4431
+ return runComposeAction(
4432
+ (service) => ["stop", ...service ? [service] : []],
4433
+ opts
4434
+ );
4435
+ }
4436
+ function runStatus(opts) {
4437
+ return runComposeAction(
4438
+ (service) => ["ps", ...service ? [service] : []],
4439
+ opts
4440
+ );
4441
+ }
4442
+ function runLogs(opts) {
4443
+ const follow = opts.follow ?? true;
4444
+ return runComposeAction(
4445
+ (service) => [
4446
+ "logs",
4447
+ ...follow ? ["-f"] : [],
4448
+ ...service ? [service] : []
4449
+ ],
4450
+ opts
4451
+ );
4179
4452
  }
4180
4453
 
4181
4454
  // src/devcontainer/repo-reachability.ts
@@ -4271,13 +4544,19 @@ function formatUnreachableReposError(failures) {
4271
4544
  lines.push(headerForKind(kind));
4272
4545
  for (const e of entries) {
4273
4546
  lines.push(` \u2022 ${e.url}`);
4547
+ if (e.detail) {
4548
+ for (const detailLine of e.detail.split("\n")) {
4549
+ const trimmed = detailLine.trim();
4550
+ if (trimmed) lines.push(` git: ${trimmed}`);
4551
+ }
4552
+ }
4274
4553
  }
4275
4554
  for (const advice of adviceForKind(kind)) {
4276
4555
  lines.push(` - ${advice}`);
4277
4556
  }
4278
4557
  lines.push("");
4279
4558
  }
4280
- lines.push(`Then re-run ${cyan("monoceros apply")}.`);
4559
+ lines.push(`Then re-run ${cyan2("monoceros apply")}.`);
4281
4560
  return lines.join("\n");
4282
4561
  }
4283
4562
  function headerForKind(kind) {
@@ -4317,11 +4596,85 @@ function adviceForKind(kind) {
4317
4596
  }
4318
4597
  }
4319
4598
 
4320
- // src/devcontainer/docker-mode.ts
4599
+ // src/devcontainer/repo-clone.ts
4321
4600
  import { spawn as spawn7 } from "child_process";
4601
+ import { existsSync as existsSync7, promises as fs10 } from "fs";
4602
+ import path13 from "path";
4603
+ var realGitClone = (url, dest) => {
4604
+ return new Promise((resolve, reject) => {
4605
+ const child = spawn7("git", ["clone", "--", url, dest], {
4606
+ stdio: ["ignore", "pipe", "pipe"],
4607
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
4608
+ });
4609
+ let stdout = "";
4610
+ let stderr = "";
4611
+ child.stdout.on("data", (c) => {
4612
+ stdout += c.toString();
4613
+ });
4614
+ child.stderr.on("data", (c) => {
4615
+ stderr += c.toString();
4616
+ });
4617
+ child.on("error", reject);
4618
+ child.on(
4619
+ "exit",
4620
+ (code) => resolve({ stdout, stderr, exitCode: code ?? 0 })
4621
+ );
4622
+ });
4623
+ };
4624
+ async function cloneReposHostSide(containerRoot, repos, options = {}) {
4625
+ const spawnFn = options.spawn ?? realGitClone;
4626
+ const results = [];
4627
+ for (const repo of repos) {
4628
+ const dest = path13.join(containerRoot, "projects", repo.path);
4629
+ if (existsSync7(dest)) {
4630
+ results.push({ path: repo.path, url: repo.url, status: "skipped" });
4631
+ continue;
4632
+ }
4633
+ await fs10.mkdir(path13.dirname(dest), { recursive: true });
4634
+ let r;
4635
+ try {
4636
+ r = await spawnFn(repo.url, dest);
4637
+ } catch (err) {
4638
+ results.push({
4639
+ path: repo.path,
4640
+ url: repo.url,
4641
+ status: "failed",
4642
+ detail: err instanceof Error ? err.message : String(err)
4643
+ });
4644
+ continue;
4645
+ }
4646
+ results.push(
4647
+ r.exitCode === 0 ? { path: repo.path, url: repo.url, status: "cloned" } : {
4648
+ path: repo.path,
4649
+ url: repo.url,
4650
+ status: "failed",
4651
+ detail: r.stderr.trim()
4652
+ }
4653
+ );
4654
+ }
4655
+ return results;
4656
+ }
4657
+ function formatCloneFailuresError(failures) {
4658
+ const lines = failures.length === 1 ? [`Failed to clone declared repo: ${failures[0].url}`, ""] : [`Failed to clone ${failures.length} declared repos:`, ""];
4659
+ for (const f of failures) {
4660
+ lines.push(` \u2022 ${f.url} \u2192 projects/${f.path}`);
4661
+ if (f.detail) lines.push(` ${f.detail}`);
4662
+ }
4663
+ lines.push("");
4664
+ lines.push(
4665
+ "Reachability was confirmed earlier, so this is usually a local issue"
4666
+ );
4667
+ lines.push(
4668
+ "(disk space, a leftover non-empty target dir). Fix it and re-run " + cyan2("monoceros apply") + "."
4669
+ );
4670
+ return lines.join("\n");
4671
+ }
4672
+
4673
+ // src/devcontainer/docker-mode.ts
4674
+ import { spawn as spawn8 } from "child_process";
4322
4675
  var realDockerInfo = () => {
4323
4676
  return new Promise((resolve, reject) => {
4324
- const child = spawn7(
4677
+ const child = spawn8(
4325
4678
  "docker",
4326
4679
  ["info", "--format", "{{json .SecurityOptions}}"],
4327
4680
  {
@@ -4360,14 +4713,14 @@ function formatRootlessNotSupportedError() {
4360
4713
  ``,
4361
4714
  `To fix, switch back to standard rootful Docker:`,
4362
4715
  ``,
4363
- cyan(
4716
+ cyan2(
4364
4717
  ` systemctl --user stop docker.service docker.socket 2>/dev/null || true`
4365
4718
  ),
4366
- cyan(` dockerd-rootless-setuptool.sh uninstall`),
4367
- cyan(` rootlesskit rm -rf ~/.local/share/docker`),
4368
- cyan(` unset DOCKER_HOST DOCKER_CONTEXT`),
4369
- cyan(` sudo systemctl enable --now docker`),
4370
- cyan(` sudo usermod -aG docker $USER`),
4719
+ cyan2(` dockerd-rootless-setuptool.sh uninstall`),
4720
+ cyan2(` rootlesskit rm -rf ~/.local/share/docker`),
4721
+ cyan2(` unset DOCKER_HOST DOCKER_CONTEXT`),
4722
+ cyan2(` sudo systemctl enable --now docker`),
4723
+ cyan2(` sudo usermod -aG docker $USER`),
4371
4724
  ``,
4372
4725
  `If you added DOCKER_HOST or DOCKER_CONTEXT to ~/.bashrc /`,
4373
4726
  `~/.profile (the rootless setup may have suggested it), remove`,
@@ -4380,13 +4733,13 @@ function formatRootlessNotSupportedError() {
4380
4733
  }
4381
4734
 
4382
4735
  // src/devcontainer/identity.ts
4383
- import { spawn as spawn8 } from "child_process";
4384
- import { promises as fs10 } from "fs";
4385
- import path12 from "path";
4736
+ import { spawn as spawn9 } from "child_process";
4737
+ import { promises as fs11 } from "fs";
4738
+ import path14 from "path";
4386
4739
  import { consola as consola10 } from "consola";
4387
4740
  var realGitConfigGet = (key) => {
4388
4741
  return new Promise((resolve, reject) => {
4389
- const child = spawn8("git", ["config", "--global", "--get", key], {
4742
+ const child = spawn9("git", ["config", "--global", "--get", key], {
4390
4743
  stdio: ["ignore", "pipe", "inherit"]
4391
4744
  });
4392
4745
  let stdout = "";
@@ -4496,8 +4849,8 @@ async function resolveIdentityWithPrompt(options = {}) {
4496
4849
  };
4497
4850
  }
4498
4851
  async function collectGitIdentity(devContainerRoot, options = {}) {
4499
- const gitconfigDir = path12.join(devContainerRoot, ".monoceros");
4500
- const gitconfigPath = path12.join(gitconfigDir, "gitconfig");
4852
+ const gitconfigDir = path14.join(devContainerRoot, ".monoceros");
4853
+ const gitconfigPath = path14.join(gitconfigDir, "gitconfig");
4501
4854
  const logger = options.logger ?? { info: () => {
4502
4855
  }, warn: () => {
4503
4856
  } };
@@ -4510,8 +4863,8 @@ async function collectGitIdentity(devContainerRoot, options = {}) {
4510
4863
  const lines = ["[user]"];
4511
4864
  if (resolved.name !== void 0) lines.push(` name = ${resolved.name}`);
4512
4865
  if (resolved.email !== void 0) lines.push(` email = ${resolved.email}`);
4513
- await fs10.mkdir(gitconfigDir, { recursive: true });
4514
- await fs10.writeFile(gitconfigPath, lines.join("\n") + "\n");
4866
+ await fs11.mkdir(gitconfigDir, { recursive: true });
4867
+ await fs11.writeFile(gitconfigPath, lines.join("\n") + "\n");
4515
4868
  return {
4516
4869
  ...resolved.name !== void 0 ? { name: resolved.name } : {},
4517
4870
  ...resolved.email !== void 0 ? { email: resolved.email } : {},
@@ -4554,7 +4907,7 @@ async function readKeyFromHost(spawnFn, key, logger) {
4554
4907
  }
4555
4908
  async function readExistingGitconfig(filePath) {
4556
4909
  try {
4557
- const content = await fs10.readFile(filePath, "utf8");
4910
+ const content = await fs11.readFile(filePath, "utf8");
4558
4911
  const result = {};
4559
4912
  const nameMatch = /^\s*name\s*=\s*(.+?)\s*$/m.exec(content);
4560
4913
  const emailMatch = /^\s*email\s*=\s*(.+?)\s*$/m.exec(content);
@@ -4587,7 +4940,7 @@ ${sectionLine(label)}
4587
4940
  );
4588
4941
  }
4589
4942
  const ymlPath = containerConfigPath(opts.name, home);
4590
- if (!existsSync6(ymlPath)) {
4943
+ if (!existsSync8(ymlPath)) {
4591
4944
  throw new Error(
4592
4945
  `No such config: ${ymlPath}. Run \`monoceros init <template> ${opts.name}\` first.`
4593
4946
  );
@@ -4604,6 +4957,20 @@ ${sectionLine(label)}
4604
4957
  globalConfig?.defaults?.features ?? {}
4605
4958
  )
4606
4959
  );
4960
+ const envPath = containerEnvPath(opts.name, home);
4961
+ await ensureEnvGitignored(containerConfigsDir(home));
4962
+ const envVars = readEnvFile(envPath);
4963
+ const interpServices = interpolateServices(createOpts.services, envVars);
4964
+ const interpFeatures = interpolateFeatures(
4965
+ createOpts.features ?? {},
4966
+ envVars
4967
+ );
4968
+ const missingVars = [...interpServices.missing, ...interpFeatures.missing];
4969
+ if (missingVars.length > 0) {
4970
+ throw new Error(formatMissingVarsError(missingVars, prettyPath(envPath)));
4971
+ }
4972
+ createOpts.services = interpServices.services;
4973
+ if (createOpts.features) createOpts.features = interpFeatures.features;
4607
4974
  validateOptions(createOpts);
4608
4975
  logger.success(`yml validated ${dim(`(${prettyPath(ymlPath)})`)}`);
4609
4976
  const hasRepos = (createOpts.repos ?? []).length > 0;
@@ -4658,7 +5025,7 @@ ${sectionLine(label)}
4658
5025
  if (dockerMode === "rootless") {
4659
5026
  throw new Error(formatRootlessNotSupportedError());
4660
5027
  }
4661
- await fs11.mkdir(targetDir, { recursive: true });
5028
+ await fs12.mkdir(targetDir, { recursive: true });
4662
5029
  await writeScaffold(createOpts, targetDir, { dockerMode });
4663
5030
  await writeStateFile(
4664
5031
  targetDir,
@@ -4669,10 +5036,27 @@ ${sectionLine(label)}
4669
5036
  })
4670
5037
  );
4671
5038
  logger.success(`materialized into ${prettyPath(targetDir)}`);
5039
+ const reposToClone = createOpts.repos ?? [];
5040
+ if (reposToClone.length > 0) {
5041
+ const cloneResults = await cloneReposHostSide(targetDir, reposToClone, {
5042
+ ...opts.cloneSpawn ? { spawn: opts.cloneSpawn } : {}
5043
+ });
5044
+ for (const r of cloneResults) {
5045
+ if (r.status === "cloned") {
5046
+ logger.success(`cloned ${cyan2(r.path)} ${dim(`(${r.url})`)}`);
5047
+ } else if (r.status === "skipped") {
5048
+ logger.info(`projects/${r.path} already present \u2014 skipped clone`);
5049
+ }
5050
+ }
5051
+ const cloneFailures = cloneResults.filter((r) => r.status === "failed");
5052
+ if (cloneFailures.length > 0) {
5053
+ throw new Error(formatCloneFailuresError(cloneFailures));
5054
+ }
5055
+ }
4672
5056
  section("Container");
4673
5057
  const featureRefs = parsed.config.features.map((f) => f.ref);
4674
5058
  if (featureRefs.length > 0) {
4675
- logger.info(`Features: ${featureRefs.map((r) => cyan(r)).join(", ")}`);
5059
+ logger.info(`Features: ${featureRefs.map((r) => cyan2(r)).join(", ")}`);
4676
5060
  }
4677
5061
  logger.info(
4678
5062
  dim(
@@ -4695,14 +5079,8 @@ ${sectionLine(label)}
4695
5079
  hostPort: proxyHostPort(globalConfig),
4696
5080
  logger
4697
5081
  });
4698
- await kickProxyReload({
4699
- ...opts.proxyDocker ? { docker: opts.proxyDocker } : {}
4700
- });
4701
5082
  } else {
4702
5083
  await removeDynamicConfig(opts.name, { monocerosHome: home });
4703
- await kickProxyReload({
4704
- ...opts.proxyDocker ? { docker: opts.proxyDocker } : {}
4705
- });
4706
5084
  }
4707
5085
  } catch (err) {
4708
5086
  logger.warn?.(
@@ -4717,13 +5095,13 @@ ${sectionLine(label)}
4717
5095
  });
4718
5096
  if (exitCode === 0) {
4719
5097
  section("Next steps");
4720
- logger.info(` ${cyan(`monoceros shell ${opts.name}`)}`);
5098
+ logger.info(` ${cyan2(`monoceros shell ${opts.name}`)}`);
4721
5099
  }
4722
5100
  return { targetDir, configPath: ymlPath, containerExitCode: exitCode };
4723
5101
  }
4724
5102
  async function assertSafeTargetDir(targetDir, expectedOrigin) {
4725
- if (!existsSync6(targetDir)) return;
4726
- const entries = await fs11.readdir(targetDir);
5103
+ if (!existsSync8(targetDir)) return;
5104
+ const entries = await fs12.readdir(targetDir);
4727
5105
  if (entries.length === 0) return;
4728
5106
  const state = await readStateFile(targetDir);
4729
5107
  if (state) {
@@ -4793,7 +5171,7 @@ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
4793
5171
  }
4794
5172
  if (wantContainer) {
4795
5173
  try {
4796
- const text = await fs11.readFile(ymlPath, "utf8");
5174
+ const text = await fs12.readFile(ymlPath, "utf8");
4797
5175
  const parsed = parseConfig(text, ymlPath);
4798
5176
  const changed = setContainerGitUserInDoc(parsed.doc, {
4799
5177
  name: prompted.name,
@@ -4801,7 +5179,7 @@ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
4801
5179
  });
4802
5180
  if (changed) {
4803
5181
  const out = stringifyConfig(parsed.doc);
4804
- await fs11.writeFile(ymlPath, out, "utf8");
5182
+ await fs12.writeFile(ymlPath, out, "utf8");
4805
5183
  logger.info(
4806
5184
  `Saved identity in this container \u2014 wrote git.user into ${prettyPath(ymlPath)}.`
4807
5185
  );
@@ -4815,7 +5193,7 @@ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
4815
5193
  }
4816
5194
 
4817
5195
  // src/version.ts
4818
- var CLI_VERSION = true ? "1.11.11" : "dev";
5196
+ var CLI_VERSION = true ? "1.13.0" : "dev";
4819
5197
 
4820
5198
  // src/commands/_dispatch.ts
4821
5199
  import { consola as consola12 } from "consola";
@@ -4975,8 +5353,8 @@ var completionCommand = defineCommand9({
4975
5353
  import { defineCommand as defineCommand10 } from "citty";
4976
5354
 
4977
5355
  // src/completion/resolve.ts
4978
- import { existsSync as existsSync7, promises as fs12 } from "fs";
4979
- import path13 from "path";
5356
+ import { existsSync as existsSync9, promises as fs13 } from "fs";
5357
+ import path15 from "path";
4980
5358
  async function resolveCompletions(line, point, opts = {}) {
4981
5359
  const { prev, current } = parseCompletionLine(line, point);
4982
5360
  const ctx = { prev, current, opts };
@@ -5124,15 +5502,11 @@ function filterPrefix(values, fragment) {
5124
5502
  }
5125
5503
  async function listContainerNames(ctx) {
5126
5504
  const home = ctx.opts.monocerosHome ?? monocerosHome();
5127
- const dir = path13.join(home, "container-configs");
5128
- if (!existsSync7(dir)) return [];
5129
- const entries = await fs12.readdir(dir);
5505
+ const dir = path15.join(home, "container-configs");
5506
+ if (!existsSync9(dir)) return [];
5507
+ const entries = await fs13.readdir(dir);
5130
5508
  return entries.filter((e) => e.endsWith(".yml")).map((e) => e.slice(0, -".yml".length)).sort();
5131
5509
  }
5132
- async function listAllCatalogComponents() {
5133
- const catalog = await loadComponentCatalog();
5134
- return [...catalog.keys()].sort();
5135
- }
5136
5510
  async function listFeatureComponents() {
5137
5511
  const catalog = await loadComponentCatalog();
5138
5512
  return [...catalog.values()].filter((c) => c.file.category === "feature").map((c) => c.name).sort();
@@ -5234,8 +5608,14 @@ var COMMAND_SPECS = {
5234
5608
  // flag suggestions.
5235
5609
  positionalCount: 1,
5236
5610
  flags: {
5237
- "--with": { type: "value", values: () => listAllCatalogComponents() },
5238
- "--with-repo": { type: "value" },
5611
+ "--with-languages": { type: "value", values: () => listLanguageNames() },
5612
+ "--with-features": {
5613
+ type: "value",
5614
+ values: () => listFeatureComponents()
5615
+ },
5616
+ "--with-services": { type: "value", values: () => listServiceNames() },
5617
+ "--with-apt-packages": { type: "value" },
5618
+ "--with-repos": { type: "value" },
5239
5619
  "--with-ports": { type: "value" }
5240
5620
  }
5241
5621
  },
@@ -5379,22 +5759,22 @@ import { defineCommand as defineCommand11 } from "citty";
5379
5759
  import { consola as consola14 } from "consola";
5380
5760
 
5381
5761
  // src/init/index.ts
5382
- import { existsSync as existsSync8, promises as fs13 } from "fs";
5762
+ import { existsSync as existsSync10, promises as fs14 } from "fs";
5763
+ import path16 from "path";
5383
5764
  import { consola as consola13 } from "consola";
5384
5765
 
5385
5766
  // src/init/generator.ts
5386
5767
  var SCHEMA_HEADER_ACTIVE = "# Solution-config \u2014 describes what should be inside your dev-container.\n# Edit any section, then run `monoceros apply <name>` to (re-)build.";
5387
5768
  var SCHEMA_HEADER_DOCUMENTED = "# Solution-config \u2014 describes what should be inside your dev-container.\n# Every section is commented out by default; un-comment what you need\n# (strip one `#` per line of the block), then run `monoceros apply <name>`.";
5388
5769
  var COMMENT_WIDTH = 76;
5389
- function generateComposedYml(name, components, lookupManifest, repoUrls = [], ports = []) {
5390
- const merged = mergeComponents(components);
5770
+ function generateComposedYml(name, composed, lookupManifest, repoUrls = [], ports = []) {
5391
5771
  const lines = [];
5392
5772
  pushHeader(lines, SCHEMA_HEADER_ACTIVE, name);
5393
5773
  lines.push("");
5394
5774
  lines.push("schemaVersion: 1");
5395
5775
  lines.push(`name: ${name}`);
5396
5776
  lines.push("");
5397
- if (merged.languages.length > 0) {
5777
+ if (composed.languages.length > 0) {
5398
5778
  pushSectionHeader(
5399
5779
  lines,
5400
5780
  LANGUAGES_HEADER,
@@ -5402,10 +5782,21 @@ function generateComposedYml(name, components, lookupManifest, repoUrls = [], po
5402
5782
  false
5403
5783
  );
5404
5784
  lines.push("languages:");
5405
- for (const lang of merged.languages) lines.push(` - ${lang}`);
5785
+ for (const lang of composed.languages) lines.push(` - ${lang}`);
5406
5786
  lines.push("");
5407
5787
  }
5408
- if (merged.services.length > 0) {
5788
+ if (composed.aptPackages.length > 0) {
5789
+ pushSectionHeader(
5790
+ lines,
5791
+ APT_PACKAGES_HEADER,
5792
+ /* commented */
5793
+ false
5794
+ );
5795
+ lines.push("aptPackages:");
5796
+ for (const pkg of composed.aptPackages) lines.push(` - ${pkg}`);
5797
+ lines.push("");
5798
+ }
5799
+ if (composed.services.length > 0) {
5409
5800
  pushSectionHeader(
5410
5801
  lines,
5411
5802
  SERVICES_HEADER,
@@ -5413,10 +5804,10 @@ function generateComposedYml(name, components, lookupManifest, repoUrls = [], po
5413
5804
  false
5414
5805
  );
5415
5806
  lines.push("services:");
5416
- for (const svc of merged.services) lines.push(` - ${svc}`);
5807
+ for (const svc of composed.services) pushServiceEntry(lines, svc);
5417
5808
  lines.push("");
5418
5809
  }
5419
- if (merged.features.length > 0) {
5810
+ if (composed.features.length > 0) {
5420
5811
  pushSectionHeader(
5421
5812
  lines,
5422
5813
  FEATURES_HEADER_ACTIVE,
@@ -5424,7 +5815,7 @@ function generateComposedYml(name, components, lookupManifest, repoUrls = [], po
5424
5815
  false
5425
5816
  );
5426
5817
  lines.push("features:");
5427
- for (const f of merged.features) {
5818
+ for (const f of composed.features) {
5428
5819
  lines.push("");
5429
5820
  renderFeatureBlock(
5430
5821
  lines,
@@ -5505,7 +5896,9 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
5505
5896
  lines.push("# services:");
5506
5897
  for (const c of byCategory.service) {
5507
5898
  for (const svc of c.file.contributes.services ?? []) {
5508
- lines.push(`# - ${svc}`);
5899
+ const body = renderServiceObjectBody(expandCuratedService(svc));
5900
+ lines.push(`# - ${body[0]}`);
5901
+ for (const line of body.slice(1)) lines.push(`# ${line}`);
5509
5902
  }
5510
5903
  }
5511
5904
  lines.push("");
@@ -5612,6 +6005,22 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], por
5612
6005
  }
5613
6006
  var LANGUAGES_HEADER = "Language runtimes installed inside the dev-container. Pick the ones your projects build against. The catalog of available runtimes is shown by `monoceros list-components`.";
5614
6007
  var SERVICES_HEADER = "Sibling containers that run alongside the dev-container (databases, caches, message queues, \u2026). Each service is reachable from inside the dev-container by its name as hostname (e.g. `postgres://postgres:5432`). Activating any service switches the container to docker-compose mode automatically.";
6008
+ var APT_PACKAGES_HEADER = "Debian/Ubuntu apt packages installed in the dev-container at build time. No curated list \u2014 any apt package name works; an invalid name surfaces as an apt error during build.";
6009
+ function pushServiceEntry(out, svc) {
6010
+ if (svc.kind === "custom") {
6011
+ const { bodyLines, comment } = renderCustomService(
6012
+ svc.name,
6013
+ svc.image ?? ""
6014
+ );
6015
+ out.push(` - ${bodyLines[0]}`);
6016
+ for (const line of bodyLines.slice(1)) out.push(` ${line}`);
6017
+ for (const cl of comment.split("\n")) out.push(` #${cl}`);
6018
+ return;
6019
+ }
6020
+ const body = renderServiceObjectBody(expandCuratedService(svc.name));
6021
+ out.push(` - ${body[0]}`);
6022
+ for (const line of body.slice(1)) out.push(` ${line}`);
6023
+ }
5615
6024
  var FEATURES_HEADER_ACTIVE = "A Monoceros dev-container is shaped by features \u2014 pluggable units that drop tooling (AI assistants, language CLIs, cloud SDKs, \u2026) into the container and bring their own options. The features active for this container are listed below; adjust their options as needed. Shared credentials used across containers belong in monoceros-config.yml under `defaults.features.<ref>` rather than here. Full catalog: `monoceros list-components`.";
5616
6025
  var FEATURES_HEADER_DOCUMENTED = "A Monoceros dev-container is shaped by features \u2014 pluggable units that drop tooling (AI assistants, language CLIs, cloud SDKs, \u2026) into the container and bring their own options. Un-comment the blocks below for the features you want active. Shared credentials used across containers belong in monoceros-config.yml under `defaults.features.<ref>` rather than here. Full catalog: `monoceros list-components`.";
5617
6026
  var REPOS_HEADER = "Git repositories cloned into `projects/` on container start-up. HTTPS URLs only. The provider is auto-detected for github.com / gitlab.com / bitbucket.org; for any other host (self-hosted GitLab, Gitea, \u2026) declare `provider:` explicitly. Add more later with `monoceros add-repo`.";
@@ -5626,15 +6035,15 @@ function renderFeatureBlock(out, feature, summary, commented) {
5626
6035
  out.push(`${yamlPrefix} - ref: ${feature.ref}`);
5627
6036
  const options = feature.options ?? {};
5628
6037
  const activeKeys = Object.entries(options);
5629
- const hintKeys = (summary?.optionHints ?? []).filter((h) => !(h in options));
5630
- if (activeKeys.length === 0 && hintKeys.length === 0) return;
6038
+ const hints = featureOptionHints(summary, feature.ref, Object.keys(options));
6039
+ if (activeKeys.length === 0 && hints.length === 0) return;
5631
6040
  if (commented) {
5632
6041
  out.push(`${yamlPrefix} options:`);
5633
6042
  for (const [key, value] of activeKeys) {
5634
6043
  out.push(`${yamlPrefix} ${key}: ${renderScalarValue(value)}`);
5635
6044
  }
5636
- for (const key of hintKeys) {
5637
- out.push(`${yamlPrefix} ${key}:`);
6045
+ for (const hint of hints) {
6046
+ out.push(`${yamlPrefix} ${hint.key}: ${hint.placeholder}`);
5638
6047
  }
5639
6048
  return;
5640
6049
  }
@@ -5644,10 +6053,10 @@ function renderFeatureBlock(out, feature, summary, commented) {
5644
6053
  out.push(` ${key}: ${renderScalarValue(value)}`);
5645
6054
  }
5646
6055
  }
5647
- if (hintKeys.length > 0) {
6056
+ if (hints.length > 0) {
5648
6057
  out.push(` # options:`);
5649
- for (const key of hintKeys) {
5650
- out.push(` # ${key}:`);
6058
+ for (const hint of hints) {
6059
+ out.push(` # ${hint.key}: ${hint.placeholder}`);
5651
6060
  }
5652
6061
  }
5653
6062
  }
@@ -5701,7 +6110,7 @@ async function runInit(opts) {
5701
6110
  );
5702
6111
  }
5703
6112
  const dest = containerConfigPath(opts.name, home);
5704
- if (existsSync8(dest)) {
6113
+ if (existsSync10(dest)) {
5705
6114
  throw new Error(
5706
6115
  `Config already exists: ${dest}. Delete it manually before re-running \`monoceros init\` \u2014 this protects any hand-edits.`
5707
6116
  );
@@ -5771,15 +6180,28 @@ async function runInit(opts) {
5771
6180
  });
5772
6181
  }
5773
6182
  let text;
5774
- const requested = opts.with ?? [];
5775
- if (requested.length === 0) {
6183
+ const composed = resolveComposedInit(catalog, {
6184
+ languages: opts.languages ?? [],
6185
+ features: opts.features ?? [],
6186
+ services: opts.services ?? [],
6187
+ aptPackages: opts.aptPackages ?? []
6188
+ });
6189
+ const anyComposed = composed.languages.length > 0 || composed.features.length > 0 || composed.services.length > 0 || composed.aptPackages.length > 0;
6190
+ if (!anyComposed) {
5776
6191
  text = generateDocumentedYml(opts.name, catalog, lookup, repos, ports);
5777
6192
  } else {
5778
- const components = resolveComponents(catalog, requested);
5779
- text = generateComposedYml(opts.name, components, lookup, repos, ports);
5780
- }
5781
- await fs13.mkdir(containerConfigsDir(home), { recursive: true });
5782
- await fs13.writeFile(dest, text, "utf8");
6193
+ text = generateComposedYml(opts.name, composed, lookup, repos, ports);
6194
+ }
6195
+ await fs14.mkdir(containerConfigsDir(home), { recursive: true });
6196
+ await ensureEnvGitignored(containerConfigsDir(home));
6197
+ await fs14.writeFile(dest, text, "utf8");
6198
+ const envPath = containerEnvPath(opts.name, home);
6199
+ const featureVars = composed.features.flatMap(
6200
+ (f) => featureOptionHints(lookup(f.ref), f.ref, Object.keys(f.options ?? {})).map(
6201
+ (h) => h.envVar
6202
+ )
6203
+ );
6204
+ await ensureEnvVars(envPath, opts.name, featureVars);
5783
6205
  if (promptedIdentity?.prompted) {
5784
6206
  const { name, email, scope } = promptedIdentity.prompted;
5785
6207
  if (scope === "g" || scope === "b") {
@@ -5809,11 +6231,11 @@ async function runInit(opts) {
5809
6231
  }
5810
6232
  if (scope === "c" || scope === "b") {
5811
6233
  try {
5812
- const written = await fs13.readFile(dest, "utf8");
6234
+ const written = await fs14.readFile(dest, "utf8");
5813
6235
  const parsed = parseConfig(written, dest);
5814
6236
  const changed = setContainerGitUserInDoc(parsed.doc, { name, email });
5815
6237
  if (changed) {
5816
- await fs13.writeFile(dest, stringifyConfig(parsed.doc), "utf8");
6238
+ await fs14.writeFile(dest, stringifyConfig(parsed.doc), "utf8");
5817
6239
  logger.info(
5818
6240
  `Saved identity in ${prettyPath(dest)} (container-level git.user).`
5819
6241
  );
@@ -5825,29 +6247,136 @@ async function runInit(opts) {
5825
6247
  }
5826
6248
  }
5827
6249
  }
5828
- const documented = requested.length === 0;
5829
- const displayPath = prettyPath(dest);
6250
+ const documented = !anyComposed;
6251
+ const ymlRel = path16.relative(home, dest);
6252
+ const envRel = path16.relative(home, envPath);
5830
6253
  if (documented) {
5831
- logger.success(
5832
- `Wrote documented default to ${displayPath}. Un-comment what you need, then \`monoceros apply ${opts.name}\`.`
6254
+ logger.success(`Wrote documented default to ${ymlRel} and ${envRel}.`);
6255
+ logger.info(
6256
+ `Un-comment what you need, then \`monoceros apply ${opts.name}\`.`
5833
6257
  );
5834
6258
  } else {
5835
- logger.success(
5836
- `Composed ${requested.length} component(s) into ${displayPath}: ${requested.join(", ")}`
5837
- );
6259
+ logger.success(`Composed into ${ymlRel} and ${envRel}.`);
5838
6260
  logger.info(
5839
- `Edit the file if you need to tweak, then \`monoceros apply ${opts.name}\`.`
6261
+ `Edit the files if you need to tweak, then \`monoceros apply ${opts.name}\`.`
5840
6262
  );
5841
6263
  }
5842
6264
  return { configPath: dest, documented };
5843
6265
  }
6266
+ function resolveComposedInit(catalog, raw) {
6267
+ return {
6268
+ languages: resolveInitLanguages(raw.languages),
6269
+ aptPackages: resolveInitAptPackages(raw.aptPackages),
6270
+ services: resolveInitServices(raw.services),
6271
+ features: resolveInitFeatures(catalog, raw.features)
6272
+ };
6273
+ }
6274
+ function resolveInitLanguages(entries) {
6275
+ const known = new Set(knownLanguages());
6276
+ const out = [];
6277
+ const seen = /* @__PURE__ */ new Set();
6278
+ const unknown = [];
6279
+ for (const raw of entries) {
6280
+ const e = raw.trim();
6281
+ if (!e || seen.has(e)) continue;
6282
+ const spec = parseLanguageSpec(e);
6283
+ if (!spec || !known.has(spec.name)) {
6284
+ unknown.push(e);
6285
+ continue;
6286
+ }
6287
+ seen.add(e);
6288
+ out.push(e);
6289
+ }
6290
+ if (unknown.length > 0) {
6291
+ throw new Error(
6292
+ `Unknown language${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. Known: ${knownLanguages().join(", ")}.`
6293
+ );
6294
+ }
6295
+ return out;
6296
+ }
6297
+ function resolveInitAptPackages(entries) {
6298
+ const out = [];
6299
+ const seen = /* @__PURE__ */ new Set();
6300
+ const bad = [];
6301
+ for (const raw of entries) {
6302
+ const e = raw.trim();
6303
+ if (!e || seen.has(e)) continue;
6304
+ if (!REGEX.aptPackage.test(e)) {
6305
+ bad.push(e);
6306
+ continue;
6307
+ }
6308
+ seen.add(e);
6309
+ out.push(e);
6310
+ }
6311
+ if (bad.length > 0) {
6312
+ throw new Error(
6313
+ `Invalid apt package name${bad.length > 1 ? "s" : ""}: ${bad.join(", ")}. Expected lowercase alphanumeric plus '.+-'.`
6314
+ );
6315
+ }
6316
+ return out;
6317
+ }
6318
+ function resolveInitServices(entries) {
6319
+ const out = [];
6320
+ const byName = /* @__PURE__ */ new Map();
6321
+ for (const raw of entries) {
6322
+ const e = raw.trim();
6323
+ if (!e) continue;
6324
+ const svc = isCuratedService(e) ? { kind: "curated", name: e } : { kind: "custom", name: deriveServiceName(e), image: e };
6325
+ const existing = byName.get(svc.name);
6326
+ if (existing) {
6327
+ if (existing.kind === svc.kind && existing.image === svc.image) continue;
6328
+ throw new Error(
6329
+ `Two --with-services entries resolve to the service name '${svc.name}'. Add one after init with \`monoceros add-service ${"<name>"} <image> --as=<other>\`.`
6330
+ );
6331
+ }
6332
+ byName.set(svc.name, svc);
6333
+ out.push(svc);
6334
+ }
6335
+ return out;
6336
+ }
6337
+ function resolveInitFeatures(catalog, entries) {
6338
+ const byRef = /* @__PURE__ */ new Map();
6339
+ const unknown = [];
6340
+ for (const raw of entries) {
6341
+ const e = raw.trim();
6342
+ if (!e) continue;
6343
+ if (REGEX.featureRef.test(e)) {
6344
+ if (!byRef.has(e)) byRef.set(e, { ref: e, options: {} });
6345
+ continue;
6346
+ }
6347
+ const c = catalog.get(e);
6348
+ if (!c || c.file.category !== "feature") {
6349
+ unknown.push(e);
6350
+ continue;
6351
+ }
6352
+ for (const f of c.file.contributes.features ?? []) {
6353
+ const existing = byRef.get(f.ref);
6354
+ if (!existing) {
6355
+ byRef.set(f.ref, { ref: f.ref, options: { ...f.options ?? {} } });
6356
+ } else {
6357
+ existing.options = mergeFeatureOptions(
6358
+ existing.options,
6359
+ f.options ?? {}
6360
+ );
6361
+ }
6362
+ }
6363
+ }
6364
+ if (unknown.length > 0) {
6365
+ const featureNames = [...catalog.values()].filter((c) => c.file.category === "feature").map((c) => c.name).sort();
6366
+ throw new Error(
6367
+ `Unknown feature${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.
6368
+ Use a catalog short name (${featureNames.join(", ")}) or a full OCI ref (ghcr.io/\u2026/<name>:<tag>).`
6369
+ );
6370
+ }
6371
+ return [...byRef.values()];
6372
+ }
5844
6373
 
5845
6374
  // src/commands/init.ts
5846
6375
  var initCommand = defineCommand11({
5847
6376
  meta: {
5848
6377
  name: "init",
5849
6378
  group: "lifecycle",
5850
- description: "Create a fresh container-config yml at .local/container-configs/<name>.yml. Without --with, the file is a documented default with every component commented out. With --with=<names>, the named components are composed into an active, immediately-applyable yml. Then run `monoceros apply <name>`."
6379
+ description: "Create a fresh container-config yml at <MONOCEROS_HOME>/container-configs/<name>.yml. Without any --with-* flag, the file is a documented default with every component commented out. With --with-languages / --with-features / --with-services / --with-apt-packages, the named pieces are composed into an active, immediately-applyable yml. Then run `monoceros apply <name>`."
5851
6380
  },
5852
6381
  args: {
5853
6382
  name: {
@@ -5855,14 +6384,29 @@ var initCommand = defineCommand11({
5855
6384
  description: "Config name. The yml lands at <MONOCEROS_HOME>/container-configs/<name>.yml and becomes the source-of-truth for `monoceros apply <name>`.",
5856
6385
  required: true
5857
6386
  },
5858
- with: {
6387
+ "with-languages": {
6388
+ type: "string",
6389
+ description: "Language runtimes to install, comma-separated or repeated, e.g. --with-languages=java,node. Optional :version (java:17). Curated catalog only \u2014 see `monoceros list-components`.",
6390
+ required: false
6391
+ },
6392
+ "with-features": {
6393
+ type: "string",
6394
+ description: "Features (AI tools, language CLIs, \u2026), comma-separated or repeated. Catalog short name (claude, atlassian/twg) or a full OCI ref (ghcr.io/foo/bar:1).",
6395
+ required: false
6396
+ },
6397
+ "with-services": {
6398
+ type: "string",
6399
+ description: "Backing services, comma-separated or repeated. Curated name (postgres, mysql, redis) \u2192 full editable block; any other image (rustfs/rustfs:latest) \u2192 name + image + commented scaffold.",
6400
+ required: false
6401
+ },
6402
+ "with-apt-packages": {
5859
6403
  type: "string",
5860
- description: "Comma-separated list of component names to compose, e.g. 'node,postgres,github,claude'. Sub-components use a slash, e.g. 'atlassian/twg'. When omitted, init writes a documented default with every catalog component commented out.",
6404
+ description: "Debian/Ubuntu apt packages to install, comma-separated or repeated, e.g. --with-apt-packages=openssl,make. No curated list.",
5861
6405
  required: false
5862
6406
  },
5863
- "with-repo": {
6407
+ "with-repos": {
5864
6408
  type: "string",
5865
- description: "Git URL of a repo to clone into projects/ on first apply. Repeatable: pass --with-repo=URL1 --with-repo=URL2 for multiple repos. Folder name derived from URL (foo.git \u2192 projects/foo/); use `monoceros add-repo --path=...` post-init for subfolder paths.",
6409
+ description: "Git URLs to clone into projects/ on first apply, comma-separated or repeated. Folder name derived from URL (foo.git \u2192 projects/foo/); use `monoceros add-repo --path=...` post-init for subfolder paths. Canonical hosts only (github.com / gitlab.com / bitbucket.org).",
5866
6410
  required: false
5867
6411
  },
5868
6412
  "with-ports": {
@@ -5873,14 +6417,20 @@ var initCommand = defineCommand11({
5873
6417
  },
5874
6418
  async run({ args, rawArgs }) {
5875
6419
  try {
5876
- const withList = collectWithList(args.with, rawArgs);
5877
- const withRepoList = collectWithRepoList(rawArgs);
5878
- const withPortsList = collectWithPortsList(args["with-ports"], rawArgs);
6420
+ const languages = collectListFlag("--with-languages", rawArgs);
6421
+ const features = collectListFlag("--with-features", rawArgs);
6422
+ const services = collectListFlag("--with-services", rawArgs);
6423
+ const aptPackages = collectListFlag("--with-apt-packages", rawArgs);
6424
+ const repos = collectListFlag("--with-repos", rawArgs);
6425
+ const ports = collectWithPortsList(args["with-ports"], rawArgs);
5879
6426
  await runInit({
5880
6427
  name: args.name,
5881
- ...withList ? { with: withList } : {},
5882
- ...withRepoList.length > 0 ? { withRepo: withRepoList } : {},
5883
- ...withPortsList && withPortsList.length > 0 ? { withPorts: withPortsList } : {}
6428
+ ...languages.length > 0 ? { languages } : {},
6429
+ ...features.length > 0 ? { features } : {},
6430
+ ...services.length > 0 ? { services } : {},
6431
+ ...aptPackages.length > 0 ? { aptPackages } : {},
6432
+ ...repos.length > 0 ? { withRepo: repos } : {},
6433
+ ...ports && ports.length > 0 ? { withPorts: ports } : {}
5884
6434
  });
5885
6435
  } catch (err) {
5886
6436
  consola14.error(err instanceof Error ? err.message : String(err));
@@ -5888,6 +6438,30 @@ var initCommand = defineCommand11({
5888
6438
  }
5889
6439
  }
5890
6440
  });
6441
+ function collectListFlag(flag, rawArgs) {
6442
+ const eq = `${flag}=`;
6443
+ const pieces = [];
6444
+ for (let i = 0; i < rawArgs.length; i += 1) {
6445
+ const t = rawArgs[i];
6446
+ let scanStart = -1;
6447
+ if (t === flag) {
6448
+ scanStart = i + 1;
6449
+ } else if (t.startsWith(eq)) {
6450
+ pieces.push(t.slice(eq.length));
6451
+ scanStart = i + 1;
6452
+ }
6453
+ if (scanStart < 0) continue;
6454
+ let j = scanStart;
6455
+ while (j < rawArgs.length) {
6456
+ const u = rawArgs[j];
6457
+ if (u.startsWith("-")) break;
6458
+ pieces.push(u);
6459
+ j += 1;
6460
+ }
6461
+ i = j - 1;
6462
+ }
6463
+ return pieces.flatMap((s) => s.split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
6464
+ }
5891
6465
  function collectWithPortsList(_withPortsArg, rawArgs) {
5892
6466
  const pieces = [];
5893
6467
  for (let i = 0; i < rawArgs.length; i += 1) {
@@ -5923,43 +6497,6 @@ function collectWithPortsList(_withPortsArg, rawArgs) {
5923
6497
  }
5924
6498
  return out;
5925
6499
  }
5926
- function collectWithRepoList(rawArgs) {
5927
- const urls = [];
5928
- for (let i = 0; i < rawArgs.length; i += 1) {
5929
- const t = rawArgs[i];
5930
- if (t === "--with-repo") {
5931
- const next = rawArgs[i + 1];
5932
- if (typeof next === "string" && !next.startsWith("-")) {
5933
- urls.push(next);
5934
- i += 1;
5935
- }
5936
- } else if (t.startsWith("--with-repo=")) {
5937
- urls.push(t.slice("--with-repo=".length));
5938
- }
5939
- }
5940
- return urls;
5941
- }
5942
- function collectWithList(withArg, rawArgs) {
5943
- if (typeof withArg !== "string" || withArg.trim().length === 0) {
5944
- return void 0;
5945
- }
5946
- let combined = withArg.trim();
5947
- const startIdx = rawArgs.findIndex(
5948
- (t) => t === "--with" || t.startsWith("--with=")
5949
- );
5950
- if (startIdx >= 0) {
5951
- let scanFrom = startIdx + 1;
5952
- if (rawArgs[startIdx] === "--with") scanFrom += 1;
5953
- for (let i = scanFrom; i < rawArgs.length; i += 1) {
5954
- const t = rawArgs[i];
5955
- if (t.startsWith("--") || t === "-h" || t === "--help") break;
5956
- const sep = combined.endsWith(",") ? "" : ",";
5957
- combined += sep + t;
5958
- }
5959
- }
5960
- const pieces = combined.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
5961
- return pieces.length > 0 ? pieces : void 0;
5962
- }
5963
6500
 
5964
6501
  // src/commands/list-components.ts
5965
6502
  import { defineCommand as defineCommand12 } from "citty";
@@ -6247,8 +6784,8 @@ import { consola as consola20 } from "consola";
6247
6784
  import { createInterface } from "readline/promises";
6248
6785
 
6249
6786
  // src/remove/index.ts
6250
- import { existsSync as existsSync9, promises as fs14 } from "fs";
6251
- import path14 from "path";
6787
+ import { existsSync as existsSync11, promises as fs15 } from "fs";
6788
+ import path17 from "path";
6252
6789
  import { consola as consola19 } from "consola";
6253
6790
  async function runRemove(opts) {
6254
6791
  const home = opts.monocerosHome ?? monocerosHome();
@@ -6263,9 +6800,11 @@ async function runRemove(opts) {
6263
6800
  );
6264
6801
  }
6265
6802
  const ymlPath = containerConfigPath(opts.name, home);
6803
+ const envPath = containerEnvPath(opts.name, home);
6266
6804
  const containerPath = containerDir(opts.name, home);
6267
- const hasYml = existsSync9(ymlPath);
6268
- const hasContainer = existsSync9(containerPath);
6805
+ const hasYml = existsSync11(ymlPath);
6806
+ const hasEnv = existsSync11(envPath);
6807
+ const hasContainer = existsSync11(containerPath);
6269
6808
  if (!hasYml && !hasContainer) {
6270
6809
  throw new Error(
6271
6810
  `Nothing to remove for '${opts.name}': neither ${ymlPath} nor ${containerPath} exists.`
@@ -6277,7 +6816,7 @@ async function runRemove(opts) {
6277
6816
  projectName,
6278
6817
  filters: [
6279
6818
  `label=com.docker.compose.project=${projectName}`,
6280
- `label=devcontainer.local_folder=${dockerLocalFolderLabel(containerPath)}`,
6819
+ `label=devcontainer.local_folder=${containerPath}`,
6281
6820
  `name=^${projectName}-`,
6282
6821
  `name=^vsc-${opts.name}-`
6283
6822
  ],
@@ -6289,24 +6828,30 @@ async function runRemove(opts) {
6289
6828
  let backupPath = null;
6290
6829
  if (!opts.noBackup && (hasYml || hasContainer)) {
6291
6830
  const ts = (opts.now ?? /* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
6292
- backupPath = path14.join(home, "container-backups", `${opts.name}-${ts}`);
6293
- await fs14.mkdir(backupPath, { recursive: true });
6831
+ backupPath = path17.join(home, "container-backups", `${opts.name}-${ts}`);
6832
+ await fs15.mkdir(backupPath, { recursive: true });
6294
6833
  if (hasYml) {
6295
- await fs14.copyFile(ymlPath, path14.join(backupPath, `${opts.name}.yml`));
6834
+ await fs15.copyFile(ymlPath, path17.join(backupPath, `${opts.name}.yml`));
6835
+ }
6836
+ if (hasEnv) {
6837
+ await fs15.copyFile(envPath, path17.join(backupPath, `${opts.name}.env`));
6296
6838
  }
6297
6839
  if (hasContainer) {
6298
- await fs14.cp(containerPath, path14.join(backupPath, "container"), {
6840
+ await fs15.cp(containerPath, path17.join(backupPath, "container"), {
6299
6841
  recursive: true
6300
6842
  });
6301
6843
  }
6302
6844
  logger.info(`Backup written to ${prettyPath(backupPath)}.`);
6303
6845
  }
6304
6846
  if (hasYml) {
6305
- await fs14.rm(ymlPath, { force: true });
6847
+ await fs15.rm(ymlPath, { force: true });
6848
+ }
6849
+ if (hasEnv) {
6850
+ await fs15.rm(envPath, { force: true });
6306
6851
  }
6307
6852
  if (hasContainer) {
6308
6853
  try {
6309
- await fs14.rm(containerPath, { recursive: true, force: true });
6854
+ await fs15.rm(containerPath, { recursive: true, force: true });
6310
6855
  } catch (err) {
6311
6856
  const code = err.code;
6312
6857
  if (code !== "EACCES" && code !== "EPERM") {
@@ -6332,7 +6877,7 @@ async function runRemove(opts) {
6332
6877
  `docker-based cleanup of ${containerPath} exited ${exit}. Inspect with \`sudo ls -la ${containerPath}\` and clean manually.`
6333
6878
  );
6334
6879
  }
6335
- await fs14.rm(containerPath, { recursive: true, force: true });
6880
+ await fs15.rm(containerPath, { recursive: true, force: true });
6336
6881
  }
6337
6882
  }
6338
6883
  logger.success(
@@ -6345,9 +6890,6 @@ async function runRemove(opts) {
6345
6890
  }
6346
6891
  try {
6347
6892
  await removeDynamicConfig(opts.name, { monocerosHome: home });
6348
- await kickProxyReload({
6349
- ...opts.proxyDocker ? { docker: opts.proxyDocker } : {}
6350
- });
6351
6893
  } catch (err) {
6352
6894
  logger.warn?.(
6353
6895
  `Could not remove Traefik dynamic config for ${opts.name}: ${err instanceof Error ? err.message : String(err)}. Ignored.`
@@ -6438,8 +6980,8 @@ import { defineCommand as defineCommand18 } from "citty";
6438
6980
  import { consola as consola22 } from "consola";
6439
6981
 
6440
6982
  // src/restore/index.ts
6441
- import { existsSync as existsSync10, promises as fs15 } from "fs";
6442
- import path15 from "path";
6983
+ import { existsSync as existsSync12, promises as fs16 } from "fs";
6984
+ import path18 from "path";
6443
6985
  import { consola as consola21 } from "consola";
6444
6986
  async function runRestore(opts) {
6445
6987
  const home = opts.monocerosHome ?? monocerosHome();
@@ -6447,15 +6989,15 @@ async function runRestore(opts) {
6447
6989
  info: (msg) => consola21.info(msg),
6448
6990
  success: (msg) => consola21.success(msg)
6449
6991
  };
6450
- const backup = path15.resolve(opts.backupPath);
6451
- if (!existsSync10(backup)) {
6992
+ const backup = path18.resolve(opts.backupPath);
6993
+ if (!existsSync12(backup)) {
6452
6994
  throw new Error(`Backup not found: ${backup}.`);
6453
6995
  }
6454
- const stat = await fs15.stat(backup);
6996
+ const stat = await fs16.stat(backup);
6455
6997
  if (!stat.isDirectory()) {
6456
6998
  throw new Error(`Backup path is not a directory: ${backup}.`);
6457
6999
  }
6458
- const entries = await fs15.readdir(backup);
7000
+ const entries = await fs16.readdir(backup);
6459
7001
  const ymlFiles = entries.filter((f) => f.endsWith(".yml"));
6460
7002
  if (ymlFiles.length === 0) {
6461
7003
  throw new Error(
@@ -6469,24 +7011,29 @@ async function runRestore(opts) {
6469
7011
  }
6470
7012
  const ymlFile = ymlFiles[0];
6471
7013
  const name = ymlFile.replace(/\.yml$/, "");
6472
- const containerInBackup = path15.join(backup, "container");
6473
- const hasContainer = existsSync10(containerInBackup);
7014
+ const containerInBackup = path18.join(backup, "container");
7015
+ const hasContainer = existsSync12(containerInBackup);
7016
+ const envInBackup = path18.join(backup, `${name}.env`);
7017
+ const hasEnv = existsSync12(envInBackup);
6474
7018
  const destYml = containerConfigPath(name, home);
6475
7019
  const destContainer = containerDir(name, home);
6476
- if (existsSync10(destYml)) {
7020
+ if (existsSync12(destYml)) {
6477
7021
  throw new Error(
6478
7022
  `Refusing to restore: ${destYml} already exists. Remove the current container first (\`monoceros remove ${name}\`) or rename the existing config.`
6479
7023
  );
6480
7024
  }
6481
- if (hasContainer && existsSync10(destContainer)) {
7025
+ if (hasContainer && existsSync12(destContainer)) {
6482
7026
  throw new Error(
6483
7027
  `Refusing to restore: ${destContainer} already exists. Remove the current container first (\`monoceros remove ${name}\`).`
6484
7028
  );
6485
7029
  }
6486
- await fs15.mkdir(containerConfigsDir(home), { recursive: true });
6487
- await fs15.copyFile(path15.join(backup, ymlFile), destYml);
7030
+ await fs16.mkdir(containerConfigsDir(home), { recursive: true });
7031
+ await fs16.copyFile(path18.join(backup, ymlFile), destYml);
7032
+ if (hasEnv) {
7033
+ await fs16.copyFile(envInBackup, containerEnvPath(name, home));
7034
+ }
6488
7035
  if (hasContainer) {
6489
- await fs15.cp(containerInBackup, destContainer, { recursive: true });
7036
+ await fs16.cp(containerInBackup, destContainer, { recursive: true });
6490
7037
  }
6491
7038
  logger.success(`Restored '${name}' from ${prettyPath(backup)}.`);
6492
7039
  logger.info(
@@ -6744,8 +7291,8 @@ import { defineCommand as defineCommand24 } from "citty";
6744
7291
  import { consola as consola28 } from "consola";
6745
7292
 
6746
7293
  // src/devcontainer/shell.ts
6747
- import { existsSync as existsSync11 } from "fs";
6748
- import path16 from "path";
7294
+ import { existsSync as existsSync13 } from "fs";
7295
+ import path19 from "path";
6749
7296
  async function runShell(opts) {
6750
7297
  assertContainerExists(opts.root);
6751
7298
  const spawnFn = opts.spawn ?? spawnDevcontainer;
@@ -6768,7 +7315,7 @@ async function runShell(opts) {
6768
7315
  );
6769
7316
  }
6770
7317
  function assertContainerExists(root) {
6771
- if (!existsSync11(path16.join(root, ".devcontainer"))) {
7318
+ if (!existsSync13(path19.join(root, ".devcontainer"))) {
6772
7319
  throw new Error(
6773
7320
  `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
6774
7321
  );
@@ -6980,15 +7527,15 @@ import { defineCommand as defineCommand29 } from "citty";
6980
7527
  import { consola as consola33 } from "consola";
6981
7528
 
6982
7529
  // src/tunnel/run.ts
6983
- import { spawn as spawn9 } from "child_process";
7530
+ import { spawn as spawn10 } from "child_process";
6984
7531
  import { consola as consola32 } from "consola";
6985
7532
 
6986
7533
  // src/tunnel/resolve.ts
6987
- import { existsSync as existsSync12 } from "fs";
6988
- import path17 from "path";
7534
+ import { existsSync as existsSync14 } from "fs";
7535
+ import path20 from "path";
6989
7536
  async function resolveTunnelTarget(opts) {
6990
7537
  const ymlPath = containerConfigPath(opts.name, opts.monocerosHome);
6991
- if (!existsSync12(ymlPath)) {
7538
+ if (!existsSync14(ymlPath)) {
6992
7539
  throw new Error(
6993
7540
  `No yml profile for '${opts.name}' at ${ymlPath}. Run \`monoceros init ${opts.name}\` first.`
6994
7541
  );
@@ -6996,13 +7543,13 @@ async function resolveTunnelTarget(opts) {
6996
7543
  const parsed = await readConfig(ymlPath);
6997
7544
  const config = parsed.config;
6998
7545
  const containerRoot = containerDir(opts.name, opts.monocerosHome);
6999
- if (!existsSync12(containerRoot)) {
7546
+ if (!existsSync14(containerRoot)) {
7000
7547
  throw new Error(
7001
7548
  `Container '${opts.name}' is not materialised at ${containerRoot}. Run \`monoceros apply ${opts.name}\` first.`
7002
7549
  );
7003
7550
  }
7004
- const composePath = path17.join(containerRoot, ".devcontainer", "compose.yaml");
7005
- const isCompose = existsSync12(composePath);
7551
+ const composePath = path20.join(containerRoot, ".devcontainer", "compose.yaml");
7552
+ const isCompose = existsSync14(composePath);
7006
7553
  const parsedTarget = parseTargetArg(opts.target, config);
7007
7554
  const docker = opts.docker ?? defaultDockerExec;
7008
7555
  if (isCompose) {
@@ -7021,23 +7568,41 @@ async function resolveTunnelTarget(opts) {
7021
7568
  });
7022
7569
  }
7023
7570
  function parseTargetArg(raw, config) {
7571
+ const colon = raw.indexOf(":");
7572
+ if (colon > 0) {
7573
+ const name = raw.slice(0, colon);
7574
+ const port = Number(raw.slice(colon + 1));
7575
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
7576
+ throw new Error(
7577
+ `Invalid target '${raw}'. Use <service>:<port> with a numeric port (1\u201365535), a bare port number, or a configured service name.`
7578
+ );
7579
+ }
7580
+ findConfiguredService(config, name);
7581
+ return { kind: "service", service: name, port };
7582
+ }
7024
7583
  const asNumber = Number(raw);
7025
7584
  if (Number.isInteger(asNumber) && asNumber > 0 && asNumber < 65536) {
7026
7585
  return { kind: "port", port: asNumber };
7027
7586
  }
7028
- const entry2 = SERVICE_CATALOG[raw];
7029
- if (!entry2) {
7030
- const candidates = knownServices().join(", ");
7587
+ const match = findConfiguredService(config, raw);
7588
+ if (match.port === void 0) {
7031
7589
  throw new Error(
7032
- `Unknown service '${raw}'. Known services: ${candidates}. Or pass a port number (e.g. \`monoceros tunnel <name> 8080\`).`
7590
+ `Service '${raw}' declares no port, so tunnel can't know what to forward. Add \`port: <n>\` to the service in the yml and re-apply, or pass one explicitly: \`monoceros tunnel <name> ${raw}:<port>\`.`
7033
7591
  );
7034
7592
  }
7035
- if (!config.services.includes(raw)) {
7593
+ return { kind: "service", service: raw, port: match.port };
7594
+ }
7595
+ function findConfiguredService(config, name) {
7596
+ const services = config.services.map(resolveService);
7597
+ const match = services.find((s) => s.name === name);
7598
+ if (!match) {
7599
+ const names = services.map((s) => s.name);
7600
+ const list = names.length > 0 ? names.join(", ") : "(none configured)";
7036
7601
  throw new Error(
7037
- `Service '${raw}' is not declared in this container's yml. Add it with \`monoceros add-service ${config.services.length === 0 ? "<name>" : "\u2026"} ${raw}\` and re-apply.`
7602
+ `Service '${name}' is not configured in this container's yml. Configured services: ${list}. Or pass a port number (e.g. \`monoceros tunnel <name> 8080\`).`
7038
7603
  );
7039
7604
  }
7040
- return { kind: "service", service: raw, port: entry2.defaultPort };
7605
+ return match;
7041
7606
  }
7042
7607
  function resolveCompose2(args) {
7043
7608
  const network = `${composeProjectName(args.containerRoot)}_default`;
@@ -7046,7 +7611,7 @@ function resolveCompose2(args) {
7046
7611
  network,
7047
7612
  targetHost: args.parsedTarget.service,
7048
7613
  internalPort: args.parsedTarget.port,
7049
- display: `${args.name}/${args.parsedTarget.service}`
7614
+ display: `${args.name}/${args.parsedTarget.service}:${args.parsedTarget.port}`
7050
7615
  };
7051
7616
  }
7052
7617
  return {
@@ -7087,10 +7652,7 @@ async function lookupContainerNetwork(args) {
7087
7652
  "ps",
7088
7653
  "-q",
7089
7654
  "--filter",
7090
- // Windows-normalize: devcontainer-cli lowercases the drive letter
7091
- // when stamping the label, docker filter is byte-exact. No-op
7092
- // off Windows.
7093
- `label=devcontainer.local_folder=${dockerLocalFolderLabel(args.containerRoot)}`
7655
+ `label=devcontainer.local_folder=${args.containerRoot}`
7094
7656
  ]);
7095
7657
  if (psResult.exitCode !== 0) {
7096
7658
  throw new Error(
@@ -7208,7 +7770,7 @@ function formatLocalPortHeldError(port, address, result) {
7208
7770
  // src/tunnel/run.ts
7209
7771
  var SOCAT_IMAGE = "alpine/socat:1.8.0.3";
7210
7772
  var defaultDockerSpawn = (args) => {
7211
- const child = spawn9("docker", args, {
7773
+ const child = spawn10("docker", args, {
7212
7774
  stdio: "inherit"
7213
7775
  });
7214
7776
  const exited = new Promise((resolve, reject) => {
@@ -7331,7 +7893,7 @@ var tunnelCommand = defineCommand29({
7331
7893
  },
7332
7894
  target: {
7333
7895
  type: "positional",
7334
- description: "Service name from the container yml (e.g. `postgres`) or an in-container port number (e.g. `8080`).",
7896
+ description: "Service name from the container yml (e.g. `postgres`), `service:port` for an explicit in-container port (e.g. `rustfs:9001`), or a bare in-container port number \u2192 workspace (e.g. `8080`).",
7335
7897
  required: true
7336
7898
  },
7337
7899
  "local-port": {
@@ -7412,7 +7974,6 @@ var main = defineCommand30({
7412
7974
 
7413
7975
  // src/bin.ts
7414
7976
  bootstrapDockerGroup();
7415
- bootstrapWslBackend();
7416
7977
  consumeInnerArgsFromProcessArgv();
7417
7978
  async function entry() {
7418
7979
  if (await maybeRenderHelp(process.argv.slice(2), main)) {