@getmonoceros/workbench 1.11.10 → 1.12.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 +1232 -1329
  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
  }
@@ -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 path8 from "path";
403
315
 
404
316
  // src/config/io.ts
405
317
  import { promises as fs } from "fs";
@@ -661,6 +573,38 @@ function prettyPath(p) {
661
573
  import { spawn } from "child_process";
662
574
  import { promises as fs2 } from "fs";
663
575
  import path2 from "path";
576
+
577
+ // src/util/format.ts
578
+ var ESC = "\x1B[";
579
+ var ANSI_BOLD2 = `${ESC}1m`;
580
+ var ANSI_UNDERLINE2 = `${ESC}4m`;
581
+ var ANSI_CYAN2 = `${ESC}36m`;
582
+ var ANSI_GREY2 = `${ESC}90m`;
583
+ var ANSI_RESET2 = `${ESC}0m`;
584
+ function makeWrap(isTty2) {
585
+ return (s, ...codes) => isTty2 ? codes.join("") + s + ANSI_RESET2 : s;
586
+ }
587
+ function makePalette(isTty2) {
588
+ const wrap = makeWrap(isTty2);
589
+ return {
590
+ bold: (s) => wrap(s, ANSI_BOLD2),
591
+ underline: (s) => wrap(s, ANSI_UNDERLINE2),
592
+ cyan: (s) => wrap(s, ANSI_CYAN2),
593
+ dim: (s) => wrap(s, ANSI_GREY2),
594
+ sectionLine: (label) => wrap(`\u25B8 ${label}`, ANSI_BOLD2, ANSI_UNDERLINE2)
595
+ };
596
+ }
597
+ function colorsFor(stream) {
598
+ return makePalette(stream.isTTY ?? false);
599
+ }
600
+ var stderrPalette = makePalette(process.stderr.isTTY ?? false);
601
+ var bold2 = stderrPalette.bold;
602
+ var underline2 = stderrPalette.underline;
603
+ var cyan2 = stderrPalette.cyan;
604
+ var dim = stderrPalette.dim;
605
+ var sectionLine = stderrPalette.sectionLine;
606
+
607
+ // src/devcontainer/credentials.ts
664
608
  var realGitCredentialFill = (input) => {
665
609
  return new Promise((resolve, reject) => {
666
610
  const child = spawn("git", ["credential", "fill"], {
@@ -715,16 +659,18 @@ function uniqueHttpsHosts(repos) {
715
659
  }
716
660
  return [...byHost.values()];
717
661
  }
662
+ var BREW_INSTALL_COMMAND = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"';
718
663
  function installCommandForOS(opts) {
719
- switch (process.platform) {
720
- case "darwin":
721
- return cyan(opts.brew);
722
- case "win32":
723
- return cyan(opts.winget);
724
- default:
725
- if (opts.linuxBrew) return cyan(opts.linuxBrew);
726
- return `See ${opts.linuxDocsUrl} for package instructions.`;
727
- }
664
+ const withBrewBootstrap = (cmd) => [
665
+ "",
666
+ cyan2(BREW_INSTALL_COMMAND),
667
+ cyan2(cmd),
668
+ "",
669
+ dim("(Skip the first line if you already have Homebrew.)")
670
+ ].join("\n");
671
+ if (process.platform === "darwin") return withBrewBootstrap(opts.brew);
672
+ if (opts.linuxBrew) return withBrewBootstrap(opts.linuxBrew);
673
+ return `See ${opts.linuxDocsUrl} for package instructions.`;
728
674
  }
729
675
  function providerSetupHint(host, provider) {
730
676
  if (provider === "github") {
@@ -732,7 +678,6 @@ function providerSetupHint(host, provider) {
732
678
  const hostArg = isSaas ? "" : ` --hostname ${host}`;
733
679
  const install = installCommandForOS({
734
680
  brew: "brew install gh",
735
- winget: "winget install --id GitHub.cli",
736
681
  linuxBrew: "brew install gh",
737
682
  linuxDocsUrl: "https://github.com/cli/cli#installation"
738
683
  });
@@ -743,8 +688,8 @@ function providerSetupHint(host, provider) {
743
688
  install,
744
689
  "",
745
690
  "Then run once:",
746
- cyan(`gh auth login${hostArg}`),
747
- cyan(`gh auth setup-git${hostArg}`),
691
+ cyan2(`gh auth login${hostArg}`),
692
+ cyan2(`gh auth setup-git${hostArg}`),
748
693
  "",
749
694
  "`gh auth login` walks through OAuth in your browser.",
750
695
  "`gh auth setup-git` wires gh into git as a credential helper."
@@ -756,7 +701,6 @@ function providerSetupHint(host, provider) {
756
701
  const hostArg = isSaas ? "" : ` --hostname ${host}`;
757
702
  const install = installCommandForOS({
758
703
  brew: "brew install glab",
759
- winget: "winget install --id GLab.GLab",
760
704
  linuxBrew: "brew install glab",
761
705
  linuxDocsUrl: "https://gitlab.com/gitlab-org/cli#installation"
762
706
  });
@@ -767,7 +711,7 @@ function providerSetupHint(host, provider) {
767
711
  install,
768
712
  "",
769
713
  "Then run once:",
770
- cyan(`glab auth login${hostArg}`),
714
+ cyan2(`glab auth login${hostArg}`),
771
715
  "",
772
716
  "Choose `HTTPS` when asked for git-protocol, then accept",
773
717
  '"Authenticate Git with your GitLab credentials" \u2014 glab',
@@ -786,7 +730,7 @@ function providerSetupHint(host, provider) {
786
730
  "https://id.atlassian.com/manage-profile/security/api-tokens",
787
731
  "",
788
732
  "Then store it via your OS credential helper:",
789
- cyan(
733
+ cyan2(
790
734
  `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-atlassian-email>\\npassword=<token>\\n'`
791
735
  )
792
736
  ].join("\n")
@@ -802,7 +746,7 @@ function providerSetupHint(host, provider) {
802
746
  "at least repo-read + repo-write scopes for the repos you need.",
803
747
  "",
804
748
  "Then store it via your OS credential helper:",
805
- cyan(
749
+ cyan2(
806
750
  `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-bitbucket-username>\\npassword=<token>\\n'`
807
751
  )
808
752
  ].join("\n")
@@ -820,7 +764,7 @@ function providerSetupHint(host, provider) {
820
764
  "need push from the container).",
821
765
  "",
822
766
  "Then store it via your OS credential helper:",
823
- cyan(
767
+ cyan2(
824
768
  `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-gitea-username>\\npassword=<token>\\n'`
825
769
  )
826
770
  ].join("\n")
@@ -933,7 +877,7 @@ function formatMissingCredentialsError(missing) {
933
877
  "",
934
878
  hint.body,
935
879
  "",
936
- `Then re-run ${cyan("monoceros apply")}.`
880
+ `Then re-run ${cyan2("monoceros apply")}.`
937
881
  ].join("\n");
938
882
  }
939
883
  const lines = [
@@ -947,7 +891,7 @@ function formatMissingCredentialsError(missing) {
947
891
  lines.push(hint.body);
948
892
  lines.push("");
949
893
  }
950
- lines.push(`Then re-run ${cyan("monoceros apply")}.`);
894
+ lines.push(`Then re-run ${cyan2("monoceros apply")}.`);
951
895
  return lines.join("\n");
952
896
  }
953
897
  function formatUnknownProviderError(hosts) {
@@ -959,32 +903,18 @@ function formatUnknownProviderError(hosts) {
959
903
  "For any other host (self-hosted GitLab, Gitea, Bitbucket Server, \u2026)",
960
904
  "declare the provider explicitly in the yml. Edit the repo entry:",
961
905
  "",
962
- cyan(" repos:"),
963
- cyan(` - url: https://${sorted[0]}/\u2026`),
964
- cyan(" provider: gitlab # or: github, bitbucket, gitea"),
906
+ cyan2(" repos:"),
907
+ cyan2(` - url: https://${sorted[0]}/\u2026`),
908
+ cyan2(" provider: gitlab # or: github, bitbucket, gitea"),
965
909
  "",
966
- `Or re-add with ${cyan("monoceros add-repo <name> <url> --provider=<github|gitlab|bitbucket|gitea>")}.`
910
+ `Or re-add with ${cyan2("monoceros add-repo <name> <url> --provider=<github|gitlab|bitbucket|gitea>")}.`
967
911
  ];
968
912
  return lines.join("\n");
969
913
  }
970
914
 
971
915
  // src/devcontainer/locate-running.ts
972
- import { spawn as spawn5 } from "child_process";
973
-
974
- // src/devcontainer/compose.ts
975
- import { spawn as spawn4 } from "child_process";
976
- import { existsSync as existsSync2 } from "fs";
977
- import path5 from "path";
978
- import { consola } from "consola";
979
-
980
- // src/proxy/index.ts
981
916
  import { spawn as spawn2 } from "child_process";
982
- import { promises as fs3 } from "fs";
983
- import path3 from "path";
984
- var PROXY_CONTAINER_NAME = "monoceros-proxy";
985
- var PROXY_NETWORK_NAME = "monoceros-proxy";
986
- var TRAEFIK_IMAGE = "traefik:v3.3";
987
- var defaultDockerExec = (args) => {
917
+ var realDockerLookup = (args) => {
988
918
  return new Promise((resolve, reject) => {
989
919
  const child = spawn2("docker", args, {
990
920
  stdio: ["ignore", "pipe", "pipe"]
@@ -1004,988 +934,638 @@ var defaultDockerExec = (args) => {
1004
934
  );
1005
935
  });
1006
936
  };
1007
- var realDocker = defaultDockerExec;
1008
- function proxyDynamicDir(home) {
1009
- return path3.join(home ?? monocerosHome(), "traefik", "dynamic");
1010
- }
1011
- async function kickProxyReload(opts = {}) {
1012
- if (process.platform !== "win32") return;
1013
- const docker = opts.docker ?? realDocker;
1014
- const inspect = await docker([
1015
- "inspect",
1016
- "--format",
1017
- "{{.State.Running}}",
1018
- PROXY_CONTAINER_NAME
937
+ async function findRunningContainerByLocalFolder(containerPath, opts = {}) {
938
+ const docker = opts.docker ?? realDockerLookup;
939
+ const result = await docker([
940
+ "ps",
941
+ "-q",
942
+ "--filter",
943
+ `label=devcontainer.local_folder=${containerPath}`,
944
+ "--filter",
945
+ "status=running"
1019
946
  ]);
1020
- if (inspect.exitCode !== 0) return;
1021
- if (inspect.stdout.trim() !== "true") return;
1022
- await docker(["restart", PROXY_CONTAINER_NAME]);
947
+ if (result.exitCode !== 0) return null;
948
+ const ids = result.stdout.split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
949
+ return ids[0] ?? null;
1023
950
  }
1024
- async function ensureProxy(opts = {}) {
1025
- const docker = opts.docker ?? realDocker;
1026
- const dyn = proxyDynamicDir(opts.monocerosHome);
1027
- await fs3.mkdir(dyn, { recursive: true });
1028
- const netInspect = await docker(["network", "inspect", PROXY_NETWORK_NAME]);
1029
- if (netInspect.exitCode !== 0) {
1030
- const create = await docker(["network", "create", PROXY_NETWORK_NAME]);
1031
- if (create.exitCode !== 0) {
1032
- throw new Error(
1033
- `Could not create docker network ${PROXY_NETWORK_NAME}: ${create.stderr.trim() || `exit ${create.exitCode}`}`
1034
- );
1035
- }
1036
- }
1037
- const state = await docker([
1038
- "inspect",
1039
- "--format",
1040
- "{{.State.Running}}",
1041
- PROXY_CONTAINER_NAME
1042
- ]);
1043
- if (state.exitCode === 0) {
1044
- if (state.stdout.trim() === "true") return;
1045
- const start = await docker(["start", PROXY_CONTAINER_NAME]);
1046
- if (start.exitCode !== 0) {
1047
- throw new Error(
1048
- `Could not start existing ${PROXY_CONTAINER_NAME} container: ${start.stderr.trim() || `exit ${start.exitCode}`}`
1049
- );
1050
- }
1051
- return;
951
+ var realContainerExec = (containerId, argv) => {
952
+ return new Promise((resolve, reject) => {
953
+ const child = spawn2("docker", ["exec", containerId, ...argv], {
954
+ // Inherit stdio so live git output reaches the user.
955
+ stdio: ["ignore", "inherit", "inherit"]
956
+ });
957
+ child.on("error", reject);
958
+ child.on("exit", (code) => resolve({ exitCode: code ?? 0 }));
959
+ });
960
+ };
961
+
962
+ // src/config/global.ts
963
+ import { promises as fs3 } from "fs";
964
+ import { z as z2 } from "zod";
965
+ import { isMap, Pair, parseDocument as parseDocument2, Scalar, YAMLMap } from "yaml";
966
+ var SCHEMA_VERSION = 1;
967
+ var MonocerosConfigSchema = z2.object({
968
+ schemaVersion: z2.literal(SCHEMA_VERSION),
969
+ // .nullish() (= .optional().nullable()) on defaults so the shipped
970
+ // sample yml where `defaults:` is uncommented but every sub-block
971
+ // is commented out — parses cleanly. YAML produces `defaults: null`
972
+ // in that case; without .nullish() the schema would reject it and
973
+ // we'd be back to forcing builders to comment-juggle three lines.
974
+ defaults: z2.object({
975
+ // .nullish() (not just .optional()) so the sample yml can leave
976
+ // `git:` uncommented as a category marker — YAML produces
977
+ // `git: null` for an empty mapping, which zod's plain
978
+ // `.optional()` would reject.
979
+ git: z2.object({
980
+ user: GitUserSchema.optional()
981
+ }).nullish(),
982
+ // .nullish() for the same reason as `git` — the sample keeps
983
+ // `features:` uncommented as a category marker.
984
+ features: z2.record(
985
+ z2.string().regex(
986
+ REGEX.featureRef,
987
+ "Invalid feature ref. Expected an OCI-image-style ref like 'ghcr.io/getmonoceros/monoceros-features/<name>:<tag>'."
988
+ ),
989
+ z2.record(z2.string(), FeatureOptionValueSchema)
990
+ ).nullish()
991
+ }).nullish(),
992
+ // Machine-global routing settings — one Traefik per builder, so
993
+ // host-port and similar live here rather than in any container yml.
994
+ // See ADR 0007.
995
+ routing: z2.object({
996
+ hostPort: z2.number().int().min(1).max(65535).optional().describe(
997
+ "Host port the Traefik singleton binds. Default 80. Set this when 80 is held by another service on your machine \u2014 URLs then become http://<name>.localhost:<port>/."
998
+ )
999
+ }).nullish()
1000
+ });
1001
+ async function readMonocerosConfig(opts = {}) {
1002
+ const home = opts.monocerosHome ?? monocerosHome();
1003
+ const filePath = monocerosConfigPath(home);
1004
+ let text;
1005
+ try {
1006
+ text = await fs3.readFile(filePath, "utf8");
1007
+ } catch {
1008
+ return void 0;
1052
1009
  }
1053
- const hostPort = opts.hostPort ?? 80;
1054
- const run = await docker([
1055
- "run",
1056
- "-d",
1057
- "--name",
1058
- PROXY_CONTAINER_NAME,
1059
- "--network",
1060
- PROXY_NETWORK_NAME,
1061
- "-p",
1062
- `${hostPort}:80`,
1063
- "-v",
1064
- `${dyn}:/etc/traefik/dynamic:ro`,
1065
- "--label",
1066
- "monoceros.role=proxy",
1067
- TRAEFIK_IMAGE,
1068
- "--entrypoints.web.address=:80",
1069
- "--providers.file.directory=/etc/traefik/dynamic",
1070
- "--providers.file.watch=true",
1071
- "--providers.docker=false",
1072
- "--api.dashboard=false",
1073
- "--log.level=INFO"
1074
- ]);
1075
- if (run.exitCode !== 0) {
1010
+ const doc = parseDocument2(text, { prettyErrors: true });
1011
+ if (doc.errors.length > 0) {
1076
1012
  throw new Error(
1077
- `Could not start ${PROXY_CONTAINER_NAME}: ${run.stderr.trim() || `exit ${run.exitCode}`}`
1013
+ `yaml parse error in ${filePath}: ${doc.errors[0].message}`
1078
1014
  );
1079
1015
  }
1080
- opts.logger?.info(
1081
- `Started ${PROXY_CONTAINER_NAME} (Traefik on :${hostPort}).`
1082
- );
1083
- }
1084
- async function maybeStopProxy(opts = {}) {
1085
- const docker = opts.docker ?? realDocker;
1086
- const logger = opts.logger;
1087
- const inspect = await docker([
1088
- "network",
1089
- "inspect",
1090
- PROXY_NETWORK_NAME,
1091
- "--format",
1092
- "{{range $k, $v := .Containers}}{{$v.Name}}\n{{end}}"
1093
- ]);
1094
- if (inspect.exitCode !== 0) {
1095
- return;
1096
- }
1097
- const others = inspect.stdout.split("\n").map((n) => n.trim()).filter((n) => n.length > 0 && n !== PROXY_CONTAINER_NAME);
1098
- if (others.length > 0) return;
1099
- await docker(["rm", "-f", PROXY_CONTAINER_NAME]);
1100
- const netRm = await docker(["network", "rm", PROXY_NETWORK_NAME]);
1101
- if (netRm.exitCode !== 0) {
1102
- logger?.warn?.(
1103
- `Could not remove docker network ${PROXY_NETWORK_NAME}: ${netRm.stderr.trim() || `exit ${netRm.exitCode}`}`
1016
+ const result = MonocerosConfigSchema.safeParse(doc.toJS());
1017
+ if (!result.success) {
1018
+ const issues = result.error.issues.map((issue) => {
1019
+ const where = issue.path.length > 0 ? issue.path.join(".") : "(root)";
1020
+ return ` - ${where}: ${issue.message}`;
1021
+ }).join("\n");
1022
+ throw new Error(
1023
+ `Invalid ${filePath}:
1024
+ ${issues}
1025
+
1026
+ See ${filePath.replace(
1027
+ /\.yml$/,
1028
+ ".sample.yml"
1029
+ )} for a valid example.`
1104
1030
  );
1105
- return;
1106
1031
  }
1107
- logger?.info(
1108
- `Stopped ${PROXY_CONTAINER_NAME} (no dev-containers with ports left).`
1109
- );
1032
+ return result.data;
1110
1033
  }
1111
-
1112
- // src/util/mask-secrets.ts
1113
- import { Transform } from "stream";
1114
- var PATTERNS = [
1115
- // Atlassian Cloud API token. Starts with literal `ATATT3xFf` plus
1116
- // a long URL-safe-base64 tail. Tightened to that prefix to avoid
1117
- // matching unrelated all-caps words.
1118
- { name: "atlassian-api", re: /ATATT3xFf[A-Za-z0-9+/=_-]{20,}/g },
1119
- // Bitbucket Cloud app password.
1120
- { name: "bitbucket-app", re: /ATBB[A-Za-z0-9+/=_-]{20,}/g },
1121
- // GitHub PAT (classic), OAuth, user, server, refresh — all share
1122
- // the `gh<lower-letter>_<base62>` shape per GitHub's token format.
1123
- { name: "github-token", re: /gh[a-z]_[A-Za-z0-9]{20,}/g },
1124
- // GitHub fine-grained PAT.
1125
- { name: "github-pat", re: /github_pat_[A-Za-z0-9_]{20,}/g },
1126
- // Anthropic API key.
1127
- { name: "anthropic-api", re: /sk-ant-[A-Za-z0-9_-]{20,}/g }
1128
- ];
1129
- function maskSecrets(text) {
1130
- let result = text;
1131
- for (const { re } of PATTERNS) {
1132
- result = result.replace(re, maskOne);
1034
+ var DEFAULT_PROXY_HOST_PORT = 80;
1035
+ function proxyHostPort(config) {
1036
+ return config?.routing?.hostPort ?? DEFAULT_PROXY_HOST_PORT;
1037
+ }
1038
+ async function writeGlobalDefaultGitUser(user, opts = {}) {
1039
+ const home = opts.monocerosHome ?? monocerosHome();
1040
+ const filePath = monocerosConfigPath(home);
1041
+ let text;
1042
+ try {
1043
+ text = await fs3.readFile(filePath, "utf8");
1044
+ } catch {
1045
+ text = void 0;
1133
1046
  }
1134
- return result;
1047
+ if (text === void 0) {
1048
+ const fresh = [
1049
+ "# Optional \u2014 global defaults for monoceros containers.",
1050
+ "",
1051
+ "schemaVersion: 1",
1052
+ "",
1053
+ "defaults:",
1054
+ " git:",
1055
+ " user:",
1056
+ ` name: ${user.name}`,
1057
+ ` email: ${user.email}`,
1058
+ ""
1059
+ ].join("\n");
1060
+ await fs3.mkdir(home, { recursive: true });
1061
+ await fs3.writeFile(filePath, fresh, "utf8");
1062
+ return { filePath, created: true, alreadySet: false };
1063
+ }
1064
+ const doc = parseDocument2(text, { prettyErrors: true });
1065
+ if (doc.errors.length > 0) {
1066
+ throw new Error(
1067
+ `yaml parse error in ${filePath}: ${doc.errors[0].message}`
1068
+ );
1069
+ }
1070
+ const defaultsMap = ensureMap(doc, "defaults");
1071
+ const gitMap = ensureSubMapAtTop(defaultsMap, "git");
1072
+ const userMap = ensureSubMap(gitMap, "user");
1073
+ const existingName = userMap.get("name");
1074
+ const existingEmail = userMap.get("email");
1075
+ if (typeof existingName === "string" && existingName.length > 0 && typeof existingEmail === "string" && existingEmail.length > 0) {
1076
+ return { filePath, created: false, alreadySet: true };
1077
+ }
1078
+ relocateLeakedLeafComments(userMap, defaultsMap, "git");
1079
+ userMap.set("name", user.name);
1080
+ userMap.set("email", user.email);
1081
+ const newText = String(doc);
1082
+ await fs3.writeFile(filePath, newText, "utf8");
1083
+ return { filePath, created: false, alreadySet: false };
1135
1084
  }
1136
- function maskOne(token) {
1137
- if (token.length <= 12) return token;
1138
- return `${token.slice(0, 5)}\u2026${token.slice(-6)}`;
1085
+ function ensureMap(doc, key) {
1086
+ const node = doc.get(key, true);
1087
+ if (node && isMap(node)) return node;
1088
+ const m = new YAMLMap();
1089
+ doc.set(key, m);
1090
+ return m;
1139
1091
  }
1140
- function createSecretMaskStream() {
1141
- let buffer = "";
1142
- return new Transform({
1143
- decodeStrings: true,
1144
- transform(chunk, _enc, cb) {
1145
- const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
1146
- buffer += text;
1147
- const lastNewline = buffer.lastIndexOf("\n");
1148
- if (lastNewline === -1) {
1149
- cb(null);
1150
- return;
1151
- }
1152
- const flushable = buffer.slice(0, lastNewline + 1);
1153
- buffer = buffer.slice(lastNewline + 1);
1154
- cb(null, maskSecrets(flushable));
1155
- },
1156
- flush(cb) {
1157
- if (buffer.length > 0) {
1158
- const tail = maskSecrets(buffer);
1159
- buffer = "";
1160
- cb(null, tail);
1161
- return;
1092
+ function ensureSubMap(parent, key) {
1093
+ const node = parent.get(key, true);
1094
+ if (node && isMap(node)) return node;
1095
+ const m = new YAMLMap();
1096
+ parent.set(key, m);
1097
+ return m;
1098
+ }
1099
+ function ensureSubMapAtTop(parent, key) {
1100
+ const node = parent.get(key, true);
1101
+ if (node && isMap(node)) return node;
1102
+ const parentMaybe = parent;
1103
+ const newKey = new Scalar(key);
1104
+ if (parent.items.length > 0 && typeof parentMaybe.commentBefore === "string" && parentMaybe.commentBefore.length > 0) {
1105
+ const cleaned = stripCommentedKeySkeleton(parentMaybe.commentBefore, key);
1106
+ const blankMatch = cleaned.match(/\n[ \t]*\n/);
1107
+ let head;
1108
+ let tail;
1109
+ if (blankMatch && blankMatch.index !== void 0) {
1110
+ head = cleaned.slice(0, blankMatch.index);
1111
+ tail = cleaned.slice(blankMatch.index + blankMatch[0].length);
1112
+ } else {
1113
+ head = cleaned;
1114
+ tail = "";
1115
+ }
1116
+ if (head.length > 0) {
1117
+ newKey.commentBefore = head;
1118
+ if (parentMaybe.spaceBefore) newKey.spaceBefore = true;
1119
+ }
1120
+ if (tail.length > 0) {
1121
+ const firstKey = parent.items[0].key;
1122
+ if (firstKey && typeof firstKey === "object") {
1123
+ const existing = firstKey.commentBefore ?? "";
1124
+ firstKey.commentBefore = existing ? `${tail}
1125
+ ${existing}` : tail;
1126
+ firstKey.spaceBefore = true;
1162
1127
  }
1163
- cb(null);
1164
1128
  }
1165
- });
1129
+ parentMaybe.commentBefore = null;
1130
+ parentMaybe.spaceBefore = false;
1131
+ }
1132
+ const m = new YAMLMap();
1133
+ const pair = new Pair(newKey, m);
1134
+ parent.items.unshift(pair);
1135
+ return m;
1166
1136
  }
1167
-
1168
- // src/devcontainer/cli.ts
1169
- import { spawn as spawn3 } from "child_process";
1170
- import { readFileSync } from "fs";
1171
- import { createRequire } from "module";
1172
- import path4 from "path";
1173
-
1174
- // src/devcontainer/runtime-pull-hint.ts
1175
- import { Transform as Transform2 } from "stream";
1176
- var RUNTIME_PULL_MARKER = "No manifest found for ghcr.io/getmonoceros/monoceros-runtime";
1177
- 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...';
1178
- function createRuntimePullHintStream(state) {
1179
- let buffer = "";
1180
- const appendHintIfMarker = (block) => {
1181
- if (state.hinted || !block.includes(RUNTIME_PULL_MARKER)) return block;
1182
- state.hinted = true;
1183
- return `${block}${dim(`(i) ${RUNTIME_PULL_HINT}`)}
1184
- `;
1185
- };
1186
- return new Transform2({
1187
- decodeStrings: true,
1188
- transform(chunk, _enc, cb) {
1189
- const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
1190
- buffer += text;
1191
- const lastNewline = buffer.lastIndexOf("\n");
1192
- if (lastNewline === -1) {
1193
- cb(null);
1194
- return;
1195
- }
1196
- const flushable = buffer.slice(0, lastNewline + 1);
1197
- buffer = buffer.slice(lastNewline + 1);
1198
- cb(null, appendHintIfMarker(flushable));
1199
- },
1200
- flush(cb) {
1201
- if (buffer.length === 0) {
1202
- cb(null);
1203
- return;
1137
+ function stripCommentedKeySkeleton(commentBody, key) {
1138
+ const lines = commentBody.split("\n");
1139
+ const headRe = new RegExp(`^ ${escapeRegExp(key)}:\\s*$`);
1140
+ const out = [];
1141
+ let i = 0;
1142
+ while (i < lines.length) {
1143
+ const line = lines[i];
1144
+ if (headRe.test(line)) {
1145
+ i++;
1146
+ while (i < lines.length && /^ {2,}\S/.test(lines[i])) {
1147
+ i++;
1204
1148
  }
1205
- const tail = buffer;
1206
- buffer = "";
1207
- cb(null, appendHintIfMarker(tail));
1149
+ continue;
1208
1150
  }
1209
- });
1210
- }
1211
-
1212
- // src/devcontainer/cli.ts
1213
- var require_ = createRequire(import.meta.url);
1214
- var cachedBinaryPath = null;
1215
- function devcontainerCliPath() {
1216
- if (cachedBinaryPath) return cachedBinaryPath;
1217
- const pkgJsonPath = require_.resolve("@devcontainers/cli/package.json");
1218
- const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
1219
- const binEntry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.devcontainer ?? "";
1220
- if (!binEntry) {
1221
- throw new Error("Could not resolve @devcontainers/cli bin entry.");
1151
+ out.push(line);
1152
+ i++;
1222
1153
  }
1223
- cachedBinaryPath = path4.resolve(path4.dirname(pkgJsonPath), binEntry);
1224
- return cachedBinaryPath;
1154
+ return out.join("\n");
1225
1155
  }
1226
- var spawnDevcontainer = (args, cwd, options = {}) => {
1227
- const binPath = devcontainerCliPath();
1228
- return new Promise((resolve, reject) => {
1229
- if (options.interactive) {
1230
- const child2 = spawn3(process.execPath, [binPath, ...args], {
1231
- cwd,
1232
- stdio: "inherit"
1233
- });
1234
- child2.on("error", reject);
1235
- child2.on("exit", (code) => resolve(code ?? 0));
1236
- return;
1237
- }
1238
- const child = spawn3(process.execPath, [binPath, ...args], {
1239
- cwd,
1240
- stdio: ["ignore", "pipe", "pipe"]
1241
- });
1242
- if (options.quiet) {
1243
- const stdoutChunks = [];
1244
- const stderrChunks = [];
1245
- child.stdout?.on("data", (chunk) => stdoutChunks.push(chunk));
1246
- child.stderr?.on("data", (chunk) => stderrChunks.push(chunk));
1247
- child.on("error", reject);
1248
- child.on("exit", (code) => {
1249
- const exitCode = code ?? 0;
1250
- if (exitCode !== 0) {
1251
- process.stderr.write(
1252
- maskSecrets(Buffer.concat(stderrChunks).toString("utf8"))
1253
- );
1254
- process.stderr.write(
1255
- maskSecrets(Buffer.concat(stdoutChunks).toString("utf8"))
1256
- );
1257
- }
1258
- resolve(exitCode);
1259
- });
1260
- return;
1261
- }
1262
- const pullHint = { hinted: false };
1263
- child.stdout?.pipe(createSecretMaskStream()).pipe(createRuntimePullHintStream(pullHint)).pipe(process.stdout);
1264
- child.stderr?.pipe(createSecretMaskStream()).pipe(createRuntimePullHintStream(pullHint)).pipe(process.stderr);
1265
- child.on("error", reject);
1266
- child.on("exit", (code) => resolve(code ?? 0));
1156
+ function escapeRegExp(s) {
1157
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1158
+ }
1159
+ function relocateLeakedLeafComments(leafMap, parent, ancestorKey) {
1160
+ const items = parent.items;
1161
+ const ancestorIdx = items.findIndex((p) => {
1162
+ const k = p.key;
1163
+ const v = typeof k === "string" ? k : k?.value ?? null;
1164
+ return v === ancestorKey;
1267
1165
  });
1268
- };
1269
-
1270
- // src/devcontainer/compose.ts
1271
- var spawnDockerCompose = (args, cwd) => {
1272
- return new Promise((resolve, reject) => {
1273
- const child = spawn4("docker", ["compose", ...args], {
1274
- cwd,
1275
- stdio: ["inherit", "pipe", "pipe"]
1166
+ if (ancestorIdx < 0 || ancestorIdx + 1 >= items.length) return;
1167
+ const target = items[ancestorIdx + 1];
1168
+ for (const pair of leafMap.items) {
1169
+ const value = pair.value;
1170
+ if (!value || typeof value !== "object") continue;
1171
+ const leakedComment = value.comment;
1172
+ const leakedSpace = value.spaceBefore;
1173
+ if (!leakedComment && !leakedSpace) continue;
1174
+ if (leakedComment) {
1175
+ const targetKey = target.key;
1176
+ if (targetKey && typeof targetKey === "object") {
1177
+ const existing = targetKey.commentBefore ?? "";
1178
+ targetKey.commentBefore = existing ? `${leakedComment}
1179
+ ${existing}` : leakedComment;
1180
+ if (leakedSpace) targetKey.spaceBefore = true;
1181
+ }
1182
+ value.comment = null;
1183
+ }
1184
+ if (leakedSpace) value.spaceBefore = false;
1185
+ }
1186
+ }
1187
+
1188
+ // src/init/components.ts
1189
+ import { existsSync as existsSync2, promises as fs4 } from "fs";
1190
+ import path3 from "path";
1191
+ import { z as z3 } from "zod";
1192
+ import { parse as parseYaml } from "yaml";
1193
+ var CategorySchema = z3.enum(["language", "service", "feature"]);
1194
+ var FeatureContributionSchema = z3.object({
1195
+ ref: z3.string().regex(REGEX.featureRef),
1196
+ options: z3.record(z3.string(), FeatureOptionValueSchema).optional()
1197
+ });
1198
+ var ComponentFileSchema = z3.object({
1199
+ displayName: z3.string().min(1),
1200
+ description: z3.string().min(1),
1201
+ category: CategorySchema,
1202
+ contributes: z3.object({
1203
+ languages: z3.array(z3.string().min(1)).optional(),
1204
+ services: z3.array(z3.string().min(1)).optional(),
1205
+ features: z3.array(FeatureContributionSchema).optional()
1206
+ })
1207
+ }).superRefine((data, ctx) => {
1208
+ const c = data.contributes;
1209
+ const filled = [
1210
+ c.languages && c.languages.length > 0 ? "languages" : null,
1211
+ c.services && c.services.length > 0 ? "services" : null,
1212
+ c.features && c.features.length > 0 ? "features" : null
1213
+ ].filter((x) => x !== null);
1214
+ if (filled.length === 0) {
1215
+ ctx.addIssue({
1216
+ code: z3.ZodIssueCode.custom,
1217
+ message: "contributes must set at least one of languages/services/features"
1276
1218
  });
1277
- child.stdout?.pipe(createSecretMaskStream()).pipe(process.stdout);
1278
- child.stderr?.pipe(createSecretMaskStream()).pipe(process.stderr);
1279
- child.on("error", reject);
1280
- child.on("exit", (code) => resolve(code ?? 0));
1281
- });
1282
- };
1283
- var spawnDocker = (args) => {
1219
+ return;
1220
+ }
1221
+ if (filled.length > 1) {
1222
+ ctx.addIssue({
1223
+ code: z3.ZodIssueCode.custom,
1224
+ message: `contributes must set exactly one of languages/services/features, got: ${filled.join(", ")}`
1225
+ });
1226
+ return;
1227
+ }
1228
+ const expected = data.category === "language" ? "languages" : data.category === "service" ? "services" : "features";
1229
+ if (filled[0] !== expected) {
1230
+ ctx.addIssue({
1231
+ code: z3.ZodIssueCode.custom,
1232
+ message: `category '${data.category}' requires contributes.${expected}, got contributes.${filled[0]}`
1233
+ });
1234
+ }
1235
+ });
1236
+ async function loadComponentCatalog(rootDir = componentsDir()) {
1237
+ if (!existsSync2(rootDir)) {
1238
+ return /* @__PURE__ */ new Map();
1239
+ }
1240
+ const out = /* @__PURE__ */ new Map();
1241
+ await walk(rootDir, rootDir, out);
1242
+ return out;
1243
+ }
1244
+ async function walk(baseDir, currentDir, out) {
1245
+ const entries = await fs4.readdir(currentDir, { withFileTypes: true });
1246
+ for (const entry2 of entries) {
1247
+ const full = path3.join(currentDir, entry2.name);
1248
+ if (entry2.isDirectory()) {
1249
+ await walk(baseDir, full, out);
1250
+ continue;
1251
+ }
1252
+ if (!entry2.isFile() || !entry2.name.endsWith(".yml")) continue;
1253
+ const relative = path3.relative(baseDir, full);
1254
+ const name = relative.replace(/\.yml$/, "").split(path3.sep).join("/");
1255
+ const text = await fs4.readFile(full, "utf8");
1256
+ let raw;
1257
+ try {
1258
+ raw = parseYaml(text);
1259
+ } catch (err) {
1260
+ throw new Error(
1261
+ `Failed to parse component ${name} (${full}): ${err.message}`
1262
+ );
1263
+ }
1264
+ const parsed = ComponentFileSchema.safeParse(raw);
1265
+ if (!parsed.success) {
1266
+ const issues = parsed.error.issues.map((issue) => {
1267
+ const where = issue.path.length > 0 ? issue.path.join(".") : "(root)";
1268
+ return ` - ${where}: ${issue.message}`;
1269
+ }).join("\n");
1270
+ throw new Error(`Invalid component ${name} (${full}):
1271
+ ${issues}`);
1272
+ }
1273
+ out.set(name, { name, sourcePath: full, file: parsed.data });
1274
+ }
1275
+ }
1276
+ function mergeComponents(resolved) {
1277
+ const languages = [];
1278
+ const services = [];
1279
+ const featureByRef = /* @__PURE__ */ new Map();
1280
+ for (const entry2 of resolved) {
1281
+ const c = isResolvedComponent(entry2) ? entry2.component : entry2;
1282
+ const version = isResolvedComponent(entry2) ? entry2.version : void 0;
1283
+ const ct = c.file.contributes;
1284
+ for (const lang of ct.languages ?? []) {
1285
+ const value = version !== void 0 ? `${lang}:${version}` : lang;
1286
+ if (!languages.includes(value)) languages.push(value);
1287
+ }
1288
+ for (const svc of ct.services ?? []) {
1289
+ if (!services.includes(svc)) services.push(svc);
1290
+ }
1291
+ for (const f of ct.features ?? []) {
1292
+ const existing = featureByRef.get(f.ref);
1293
+ if (!existing) {
1294
+ featureByRef.set(f.ref, {
1295
+ ref: f.ref,
1296
+ options: { ...f.options ?? {} }
1297
+ });
1298
+ continue;
1299
+ }
1300
+ existing.options = mergeFeatureOptions(existing.options, f.options ?? {});
1301
+ }
1302
+ }
1303
+ return {
1304
+ languages,
1305
+ services,
1306
+ features: [...featureByRef.values()]
1307
+ };
1308
+ }
1309
+ function isResolvedComponent(x) {
1310
+ return "component" in x;
1311
+ }
1312
+ function mergeFeatureOptions(a, b) {
1313
+ const result = { ...a };
1314
+ for (const [key, valueB] of Object.entries(b)) {
1315
+ const valueA = result[key];
1316
+ if (typeof valueA === "boolean" && typeof valueB === "boolean") {
1317
+ result[key] = valueA || valueB;
1318
+ continue;
1319
+ }
1320
+ result[key] = valueB;
1321
+ }
1322
+ return result;
1323
+ }
1324
+ function resolveComponents(catalog, names) {
1325
+ const unknown = [];
1326
+ const out = [];
1327
+ for (const raw of names) {
1328
+ const colon = raw.indexOf(":");
1329
+ const name = colon === -1 ? raw : raw.slice(0, colon);
1330
+ const version = colon === -1 ? void 0 : raw.slice(colon + 1);
1331
+ const c = catalog.get(name);
1332
+ if (!c) {
1333
+ unknown.push(raw);
1334
+ continue;
1335
+ }
1336
+ if (version !== void 0 && c.file.category !== "language") {
1337
+ throw new Error(
1338
+ `Component '${name}' is a ${c.file.category}, not a language \u2014 a ':${version}' suffix has no meaning here.`
1339
+ );
1340
+ }
1341
+ out.push({ component: c, ...version !== void 0 ? { version } : {} });
1342
+ }
1343
+ if (unknown.length > 0) {
1344
+ const available = [...catalog.keys()].sort();
1345
+ throw new Error(
1346
+ `Unknown component${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.
1347
+ Available: ${available.join(", ")}.`
1348
+ );
1349
+ }
1350
+ return out;
1351
+ }
1352
+
1353
+ // src/proxy/index.ts
1354
+ import { spawn as spawn3 } from "child_process";
1355
+ import { promises as fs5 } from "fs";
1356
+ import path4 from "path";
1357
+ var PROXY_CONTAINER_NAME = "monoceros-proxy";
1358
+ var PROXY_NETWORK_NAME = "monoceros-proxy";
1359
+ var TRAEFIK_IMAGE = "traefik:v3.3";
1360
+ var defaultDockerExec = (args) => {
1284
1361
  return new Promise((resolve, reject) => {
1285
- const child = spawn4("docker", args, {
1362
+ const child = spawn3("docker", args, {
1286
1363
  stdio: ["ignore", "pipe", "pipe"]
1287
1364
  });
1288
1365
  let stdout = "";
1289
1366
  let stderr = "";
1290
- child.stdout?.on("data", (chunk) => {
1291
- stdout += chunk.toString("utf8");
1367
+ child.stdout.on("data", (chunk) => {
1368
+ stdout += chunk.toString();
1292
1369
  });
1293
- child.stderr?.on("data", (chunk) => {
1294
- stderr += chunk.toString("utf8");
1370
+ child.stderr.on("data", (chunk) => {
1371
+ stderr += chunk.toString();
1295
1372
  });
1296
1373
  child.on("error", reject);
1297
1374
  child.on(
1298
1375
  "exit",
1299
- (code) => resolve({ exitCode: code ?? 0, stdout, stderr })
1376
+ (code) => resolve({ stdout, stderr, exitCode: code ?? 0 })
1300
1377
  );
1301
1378
  });
1302
1379
  };
1303
- async function findContainerIds(filters, exec = spawnDocker) {
1304
- const ids = /* @__PURE__ */ new Set();
1305
- for (const filter of filters) {
1306
- const result = await exec(["ps", "-aq", "--filter", filter]);
1307
- if (result.exitCode !== 0) continue;
1308
- for (const line of result.stdout.split(/\r?\n/)) {
1309
- const id = line.trim();
1310
- if (id) ids.add(id);
1380
+ var realDocker = defaultDockerExec;
1381
+ function proxyDynamicDir(home) {
1382
+ return path4.join(home ?? monocerosHome(), "traefik", "dynamic");
1383
+ }
1384
+ async function ensureProxy(opts = {}) {
1385
+ const docker = opts.docker ?? realDocker;
1386
+ const dyn = proxyDynamicDir(opts.monocerosHome);
1387
+ await fs5.mkdir(dyn, { recursive: true });
1388
+ const netInspect = await docker(["network", "inspect", PROXY_NETWORK_NAME]);
1389
+ if (netInspect.exitCode !== 0) {
1390
+ const create = await docker(["network", "create", PROXY_NETWORK_NAME]);
1391
+ if (create.exitCode !== 0) {
1392
+ throw new Error(
1393
+ `Could not create docker network ${PROXY_NETWORK_NAME}: ${create.stderr.trim() || `exit ${create.exitCode}`}`
1394
+ );
1311
1395
  }
1312
1396
  }
1313
- return [...ids];
1314
- }
1315
- async function cleanupDockerObjects(opts) {
1316
- const exec = opts.exec ?? spawnDocker;
1317
- const tag = opts.logTag ?? "cleanup";
1318
- opts.logger.info(`[${tag}] tearing down docker project ${opts.projectName}\u2026`);
1319
- const ids = await findContainerIds(opts.filters, exec);
1320
- let rmExit = 0;
1321
- if (ids.length > 0) {
1322
- opts.logger.info(`[${tag}] removing containers: ${ids.join(" ")}`);
1323
- const rmResult = await exec(["rm", "-f", ...ids]);
1324
- rmExit = rmResult.exitCode;
1325
- if (rmExit !== 0 && rmResult.stderr.trim()) {
1326
- opts.logger.info(`[${tag}] ${rmResult.stderr.trim()}`);
1397
+ const state = await docker([
1398
+ "inspect",
1399
+ "--format",
1400
+ "{{.State.Running}}",
1401
+ PROXY_CONTAINER_NAME
1402
+ ]);
1403
+ if (state.exitCode === 0) {
1404
+ if (state.stdout.trim() === "true") return;
1405
+ const start = await docker(["start", PROXY_CONTAINER_NAME]);
1406
+ if (start.exitCode !== 0) {
1407
+ throw new Error(
1408
+ `Could not start existing ${PROXY_CONTAINER_NAME} container: ${start.stderr.trim() || `exit ${start.exitCode}`}`
1409
+ );
1327
1410
  }
1328
- } else {
1329
- opts.logger.info(`[${tag}] no containers found`);
1411
+ return;
1330
1412
  }
1331
- if (opts.network) {
1332
- const netResult = await exec(["network", "rm", opts.network]);
1333
- if (netResult.exitCode === 0) {
1334
- opts.logger.info(`[${tag}] network ${opts.network} removed`);
1335
- }
1413
+ const hostPort = opts.hostPort ?? 80;
1414
+ const run = await docker([
1415
+ "run",
1416
+ "-d",
1417
+ "--name",
1418
+ PROXY_CONTAINER_NAME,
1419
+ "--network",
1420
+ PROXY_NETWORK_NAME,
1421
+ "-p",
1422
+ `${hostPort}:80`,
1423
+ "-v",
1424
+ `${dyn}:/etc/traefik/dynamic:ro`,
1425
+ "--label",
1426
+ "monoceros.role=proxy",
1427
+ TRAEFIK_IMAGE,
1428
+ "--entrypoints.web.address=:80",
1429
+ "--providers.file.directory=/etc/traefik/dynamic",
1430
+ "--providers.file.watch=true",
1431
+ "--providers.docker=false",
1432
+ "--api.dashboard=false",
1433
+ "--log.level=INFO"
1434
+ ]);
1435
+ if (run.exitCode !== 0) {
1436
+ throw new Error(
1437
+ `Could not start ${PROXY_CONTAINER_NAME}: ${run.stderr.trim() || `exit ${run.exitCode}`}`
1438
+ );
1336
1439
  }
1337
- opts.logger.info(`[${tag}] docker cleanup done`);
1338
- return { exitCode: rmExit, removedIds: ids };
1339
- }
1340
- function dockerLocalFolderLabel(p) {
1341
- if (process.platform !== "win32") return p;
1342
- return p.replace(
1343
- /^([A-Z]):/,
1344
- (_, drive) => `${drive.toLowerCase()}:`
1440
+ opts.logger?.info(
1441
+ `Started ${PROXY_CONTAINER_NAME} (Traefik on :${hostPort}).`
1345
1442
  );
1346
1443
  }
1347
- function composeProjectName(root) {
1348
- return `${path5.basename(root)}_devcontainer`;
1349
- }
1350
- function resolveCompose(root) {
1351
- if (!existsSync2(path5.join(root, ".devcontainer"))) {
1352
- throw new Error(
1353
- `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
1354
- );
1444
+ async function maybeStopProxy(opts = {}) {
1445
+ const docker = opts.docker ?? realDocker;
1446
+ const logger = opts.logger;
1447
+ const inspect = await docker([
1448
+ "network",
1449
+ "inspect",
1450
+ PROXY_NETWORK_NAME,
1451
+ "--format",
1452
+ "{{range $k, $v := .Containers}}{{$v.Name}}\n{{end}}"
1453
+ ]);
1454
+ if (inspect.exitCode !== 0) {
1455
+ return;
1355
1456
  }
1356
- const composeFile = path5.join(root, ".devcontainer", "compose.yaml");
1357
- if (!existsSync2(composeFile)) {
1358
- throw new Error(
1359
- `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.`
1457
+ const others = inspect.stdout.split("\n").map((n) => n.trim()).filter((n) => n.length > 0 && n !== PROXY_CONTAINER_NAME);
1458
+ if (others.length > 0) return;
1459
+ await docker(["rm", "-f", PROXY_CONTAINER_NAME]);
1460
+ const netRm = await docker(["network", "rm", PROXY_NETWORK_NAME]);
1461
+ if (netRm.exitCode !== 0) {
1462
+ logger?.warn?.(
1463
+ `Could not remove docker network ${PROXY_NETWORK_NAME}: ${netRm.stderr.trim() || `exit ${netRm.exitCode}`}`
1360
1464
  );
1465
+ return;
1361
1466
  }
1362
- return { composeFile, projectName: composeProjectName(root) };
1363
- }
1364
- async function runComposeAction(buildSubArgs, opts) {
1365
- const { composeFile, projectName } = resolveCompose(opts.root);
1366
- const spawnFn = opts.spawn ?? spawnDockerCompose;
1367
- const subArgs = buildSubArgs(opts.service);
1368
- return spawnFn(["-f", composeFile, "-p", projectName, ...subArgs], opts.root);
1369
- }
1370
- async function runStart(opts) {
1371
- resolveCompose(opts.root);
1372
- const logger = opts.logger ?? { info: (msg) => consola.info(msg) };
1373
- const spawnFn = opts.spawn ?? spawnDevcontainer;
1374
- logger.info(`Bringing devcontainer up at ${opts.root}\u2026`);
1375
- return spawnFn(
1376
- ["up", "--workspace-folder", opts.root, "--mount-workspace-git-root=false"],
1377
- opts.root
1467
+ logger?.info(
1468
+ `Stopped ${PROXY_CONTAINER_NAME} (no dev-containers with ports left).`
1378
1469
  );
1379
1470
  }
1380
- async function runContainerCycle(root, opts) {
1381
- const { hasCompose, logger } = opts;
1382
- if (hasCompose) {
1383
- const projectName = composeProjectName(root);
1384
- logger.info(
1385
- `Force-removing existing ${projectName} containers (volumes preserved)\u2026`
1471
+
1472
+ // src/proxy/dynamic.ts
1473
+ import { promises as fs6 } from "fs";
1474
+ import path5 from "path";
1475
+ async function writeDynamicConfig(name, ports, opts = {}) {
1476
+ if (ports.length === 0) {
1477
+ throw new Error(
1478
+ `writeDynamicConfig requires at least one port. For empty port lists, call removeDynamicConfig(${JSON.stringify(name)}).`
1386
1479
  );
1387
- const exec = opts.dockerExec ?? spawnDocker;
1388
- const filters = [
1389
- `label=com.docker.compose.project=${projectName}`,
1390
- `name=^${projectName}-`
1391
- ];
1392
- const { exitCode: rmExit } = await cleanupDockerObjects({
1393
- projectName,
1394
- filters,
1395
- network: `${projectName}_default`,
1396
- logger,
1397
- exec
1398
- });
1399
- if (rmExit !== 0) return rmExit;
1400
- const remaining = await findContainerIds(filters, exec);
1401
- if (remaining.length > 0) {
1402
- const warn = logger.warn ?? logger.info;
1403
- warn(
1404
- `ERROR: containers under project ${projectName} reappeared after removal.
1405
- This typically means VS Code's Remote Containers extension is connected
1406
- to this devcontainer and auto-recreated it. Close the dev container
1407
- session in VS Code (Cmd+Shift+P \u2192 'Dev Containers: Close Remote Connection')
1408
- and retry \`monoceros apply\`.`
1409
- );
1410
- return 1;
1411
- }
1412
- return runStart({
1413
- root,
1414
- ...opts.devcontainerSpawn ? { spawn: opts.devcontainerSpawn } : {},
1415
- logger
1416
- });
1417
1480
  }
1418
- logger.info(`Recreating image-mode devcontainer at ${root}\u2026`);
1419
- const spawnFn = opts.devcontainerSpawn ?? spawnDevcontainer;
1420
- return spawnFn(
1421
- [
1422
- "up",
1423
- "--workspace-folder",
1424
- root,
1425
- "--mount-workspace-git-root=false",
1426
- "--remove-existing-container"
1427
- ],
1428
- root
1429
- );
1481
+ const dir = proxyDynamicDir(opts.monocerosHome);
1482
+ await fs6.mkdir(dir, { recursive: true });
1483
+ const file = path5.join(dir, `${name}.yml`);
1484
+ await fs6.writeFile(file, renderDynamicConfig(name, ports), "utf8");
1485
+ return file;
1430
1486
  }
1431
- function runStop(opts) {
1432
- return runComposeAction(
1433
- (service) => ["stop", ...service ? [service] : []],
1434
- opts
1435
- );
1487
+ async function removeDynamicConfig(name, opts = {}) {
1488
+ const file = path5.join(proxyDynamicDir(opts.monocerosHome), `${name}.yml`);
1489
+ await fs6.rm(file, { force: true });
1436
1490
  }
1437
- function runStatus(opts) {
1438
- return runComposeAction(
1439
- (service) => ["ps", ...service ? [service] : []],
1440
- opts
1491
+ function renderDynamicConfig(name, ports) {
1492
+ const lines = [];
1493
+ lines.push("# Generated by Monoceros \u2014 do not edit by hand.");
1494
+ lines.push(`# Container: ${name}`);
1495
+ lines.push(`# Ports: ${ports.join(", ")}`);
1496
+ lines.push("# Traefik file-provider re-reads this on change (~100 ms);");
1497
+ lines.push(
1498
+ "# to change routing, edit container-configs/" + name + ".yml or use"
1441
1499
  );
1500
+ lines.push("# `monoceros add-port` / `monoceros remove-port`.");
1501
+ lines.push("http:");
1502
+ lines.push(" routers:");
1503
+ ports.forEach((port, idx) => {
1504
+ const router = `${name}-${port}`;
1505
+ const hostExplicit = `${name}-${port}.localhost`;
1506
+ const rule = idx === 0 ? `"Host(\`${name}.localhost\`) || Host(\`${hostExplicit}\`)"` : `"Host(\`${hostExplicit}\`)"`;
1507
+ lines.push(` ${router}:`);
1508
+ lines.push(` rule: ${rule}`);
1509
+ lines.push(` service: ${router}`);
1510
+ lines.push(" entryPoints:");
1511
+ lines.push(" - web");
1512
+ });
1513
+ lines.push(" services:");
1514
+ for (const port of ports) {
1515
+ const svc = `${name}-${port}`;
1516
+ lines.push(` ${svc}:`);
1517
+ lines.push(" loadBalancer:");
1518
+ lines.push(" servers:");
1519
+ lines.push(` - url: "http://${name}:${port}"`);
1520
+ }
1521
+ return lines.join("\n") + "\n";
1442
1522
  }
1443
- function runLogs(opts) {
1444
- const follow = opts.follow ?? true;
1445
- return runComposeAction(
1446
- (service) => [
1447
- "logs",
1448
- ...follow ? ["-f"] : [],
1449
- ...service ? [service] : []
1450
- ],
1451
- opts
1452
- );
1523
+ function proxyUrlsFor(name, ports, hostPort = 80) {
1524
+ const portSuffix = hostPort === 80 ? "" : `:${hostPort}`;
1525
+ return ports.map((port, idx) => ({
1526
+ port,
1527
+ url: `http://${name}-${port}.localhost${portSuffix}`,
1528
+ isDefault: idx === 0
1529
+ }));
1453
1530
  }
1454
1531
 
1455
- // src/devcontainer/locate-running.ts
1456
- var realDockerLookup = (args) => {
1457
- return new Promise((resolve, reject) => {
1458
- const child = spawn5("docker", args, {
1459
- stdio: ["ignore", "pipe", "pipe"]
1532
+ // src/proxy/port-check.ts
1533
+ import { Socket } from "net";
1534
+ var CONNECT_TIMEOUT_MS = 750;
1535
+ var realPortProbe = (port) => {
1536
+ return new Promise((resolve) => {
1537
+ const socket = new Socket();
1538
+ let settled = false;
1539
+ const settle = (result) => {
1540
+ if (settled) return;
1541
+ settled = true;
1542
+ socket.destroy();
1543
+ resolve(result);
1544
+ };
1545
+ socket.setTimeout(CONNECT_TIMEOUT_MS);
1546
+ socket.once("connect", () => {
1547
+ settle({
1548
+ ok: false,
1549
+ code: "EADDRINUSE",
1550
+ message: `another process is listening on ${port}`
1551
+ });
1460
1552
  });
1461
- let stdout = "";
1462
- let stderr = "";
1463
- child.stdout.on("data", (chunk) => {
1464
- stdout += chunk.toString();
1553
+ socket.once("timeout", () => {
1554
+ settle({ ok: true });
1465
1555
  });
1466
- child.stderr.on("data", (chunk) => {
1467
- stderr += chunk.toString();
1556
+ socket.once("error", (err) => {
1557
+ const code = err.code ?? "UNKNOWN";
1558
+ if (code === "ECONNREFUSED") {
1559
+ settle({ ok: true });
1560
+ } else {
1561
+ settle({
1562
+ ok: false,
1563
+ code,
1564
+ message: err.message
1565
+ });
1566
+ }
1468
1567
  });
1469
- child.on("error", reject);
1470
- child.on(
1471
- "exit",
1472
- (code) => resolve({ stdout, stderr, exitCode: code ?? 0 })
1473
- );
1474
- });
1475
- };
1476
- async function findRunningContainerByLocalFolder(containerPath, opts = {}) {
1477
- const docker = opts.docker ?? realDockerLookup;
1478
- const result = await docker([
1479
- "ps",
1480
- "-q",
1481
- "--filter",
1482
- `label=devcontainer.local_folder=${dockerLocalFolderLabel(containerPath)}`,
1483
- "--filter",
1484
- "status=running"
1485
- ]);
1486
- if (result.exitCode !== 0) return null;
1487
- const ids = result.stdout.split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
1488
- return ids[0] ?? null;
1489
- }
1490
- var realContainerExec = (containerId, argv) => {
1491
- return new Promise((resolve, reject) => {
1492
- const child = spawn5("docker", ["exec", containerId, ...argv], {
1493
- // Inherit stdio so live git output reaches the user.
1494
- stdio: ["ignore", "inherit", "inherit"]
1495
- });
1496
- child.on("error", reject);
1497
- child.on("exit", (code) => resolve({ exitCode: code ?? 0 }));
1498
- });
1499
- };
1500
-
1501
- // src/config/global.ts
1502
- import { promises as fs4 } from "fs";
1503
- import { z as z2 } from "zod";
1504
- import { isMap, Pair, parseDocument as parseDocument2, Scalar, YAMLMap } from "yaml";
1505
- var SCHEMA_VERSION = 1;
1506
- var MonocerosConfigSchema = z2.object({
1507
- schemaVersion: z2.literal(SCHEMA_VERSION),
1508
- // .nullish() (= .optional().nullable()) on defaults so the shipped
1509
- // sample yml — where `defaults:` is uncommented but every sub-block
1510
- // is commented out — parses cleanly. YAML produces `defaults: null`
1511
- // in that case; without .nullish() the schema would reject it and
1512
- // we'd be back to forcing builders to comment-juggle three lines.
1513
- defaults: z2.object({
1514
- // .nullish() (not just .optional()) so the sample yml can leave
1515
- // `git:` uncommented as a category marker — YAML produces
1516
- // `git: null` for an empty mapping, which zod's plain
1517
- // `.optional()` would reject.
1518
- git: z2.object({
1519
- user: GitUserSchema.optional()
1520
- }).nullish(),
1521
- // .nullish() for the same reason as `git` — the sample keeps
1522
- // `features:` uncommented as a category marker.
1523
- features: z2.record(
1524
- z2.string().regex(
1525
- REGEX.featureRef,
1526
- "Invalid feature ref. Expected an OCI-image-style ref like 'ghcr.io/getmonoceros/monoceros-features/<name>:<tag>'."
1527
- ),
1528
- z2.record(z2.string(), FeatureOptionValueSchema)
1529
- ).nullish()
1530
- }).nullish(),
1531
- // Machine-global routing settings — one Traefik per builder, so
1532
- // host-port and similar live here rather than in any container yml.
1533
- // See ADR 0007.
1534
- routing: z2.object({
1535
- hostPort: z2.number().int().min(1).max(65535).optional().describe(
1536
- "Host port the Traefik singleton binds. Default 80. Set this when 80 is held by another service on your machine \u2014 URLs then become http://<name>.localhost:<port>/."
1537
- )
1538
- }).nullish()
1539
- });
1540
- async function readMonocerosConfig(opts = {}) {
1541
- const home = opts.monocerosHome ?? monocerosHome();
1542
- const filePath = monocerosConfigPath(home);
1543
- let text;
1544
- try {
1545
- text = await fs4.readFile(filePath, "utf8");
1546
- } catch {
1547
- return void 0;
1548
- }
1549
- const doc = parseDocument2(text, { prettyErrors: true });
1550
- if (doc.errors.length > 0) {
1551
- throw new Error(
1552
- `yaml parse error in ${filePath}: ${doc.errors[0].message}`
1553
- );
1554
- }
1555
- const result = MonocerosConfigSchema.safeParse(doc.toJS());
1556
- if (!result.success) {
1557
- const issues = result.error.issues.map((issue) => {
1558
- const where = issue.path.length > 0 ? issue.path.join(".") : "(root)";
1559
- return ` - ${where}: ${issue.message}`;
1560
- }).join("\n");
1561
- throw new Error(
1562
- `Invalid ${filePath}:
1563
- ${issues}
1564
-
1565
- See ${filePath.replace(
1566
- /\.yml$/,
1567
- ".sample.yml"
1568
- )} for a valid example.`
1569
- );
1570
- }
1571
- return result.data;
1572
- }
1573
- var DEFAULT_PROXY_HOST_PORT = 80;
1574
- function proxyHostPort(config) {
1575
- return config?.routing?.hostPort ?? DEFAULT_PROXY_HOST_PORT;
1576
- }
1577
- async function writeGlobalDefaultGitUser(user, opts = {}) {
1578
- const home = opts.monocerosHome ?? monocerosHome();
1579
- const filePath = monocerosConfigPath(home);
1580
- let text;
1581
- try {
1582
- text = await fs4.readFile(filePath, "utf8");
1583
- } catch {
1584
- text = void 0;
1585
- }
1586
- if (text === void 0) {
1587
- const fresh = [
1588
- "# Optional \u2014 global defaults for monoceros containers.",
1589
- "",
1590
- "schemaVersion: 1",
1591
- "",
1592
- "defaults:",
1593
- " git:",
1594
- " user:",
1595
- ` name: ${user.name}`,
1596
- ` email: ${user.email}`,
1597
- ""
1598
- ].join("\n");
1599
- await fs4.mkdir(home, { recursive: true });
1600
- await fs4.writeFile(filePath, fresh, "utf8");
1601
- return { filePath, created: true, alreadySet: false };
1602
- }
1603
- const doc = parseDocument2(text, { prettyErrors: true });
1604
- if (doc.errors.length > 0) {
1605
- throw new Error(
1606
- `yaml parse error in ${filePath}: ${doc.errors[0].message}`
1607
- );
1608
- }
1609
- const defaultsMap = ensureMap(doc, "defaults");
1610
- const gitMap = ensureSubMapAtTop(defaultsMap, "git");
1611
- const userMap = ensureSubMap(gitMap, "user");
1612
- const existingName = userMap.get("name");
1613
- const existingEmail = userMap.get("email");
1614
- if (typeof existingName === "string" && existingName.length > 0 && typeof existingEmail === "string" && existingEmail.length > 0) {
1615
- return { filePath, created: false, alreadySet: true };
1616
- }
1617
- relocateLeakedLeafComments(userMap, defaultsMap, "git");
1618
- userMap.set("name", user.name);
1619
- userMap.set("email", user.email);
1620
- const newText = String(doc);
1621
- await fs4.writeFile(filePath, newText, "utf8");
1622
- return { filePath, created: false, alreadySet: false };
1623
- }
1624
- function ensureMap(doc, key) {
1625
- const node = doc.get(key, true);
1626
- if (node && isMap(node)) return node;
1627
- const m = new YAMLMap();
1628
- doc.set(key, m);
1629
- return m;
1630
- }
1631
- function ensureSubMap(parent, key) {
1632
- const node = parent.get(key, true);
1633
- if (node && isMap(node)) return node;
1634
- const m = new YAMLMap();
1635
- parent.set(key, m);
1636
- return m;
1637
- }
1638
- function ensureSubMapAtTop(parent, key) {
1639
- const node = parent.get(key, true);
1640
- if (node && isMap(node)) return node;
1641
- const parentMaybe = parent;
1642
- const newKey = new Scalar(key);
1643
- if (parent.items.length > 0 && typeof parentMaybe.commentBefore === "string" && parentMaybe.commentBefore.length > 0) {
1644
- const cleaned = stripCommentedKeySkeleton(parentMaybe.commentBefore, key);
1645
- const blankMatch = cleaned.match(/\n[ \t]*\n/);
1646
- let head;
1647
- let tail;
1648
- if (blankMatch && blankMatch.index !== void 0) {
1649
- head = cleaned.slice(0, blankMatch.index);
1650
- tail = cleaned.slice(blankMatch.index + blankMatch[0].length);
1651
- } else {
1652
- head = cleaned;
1653
- tail = "";
1654
- }
1655
- if (head.length > 0) {
1656
- newKey.commentBefore = head;
1657
- if (parentMaybe.spaceBefore) newKey.spaceBefore = true;
1658
- }
1659
- if (tail.length > 0) {
1660
- const firstKey = parent.items[0].key;
1661
- if (firstKey && typeof firstKey === "object") {
1662
- const existing = firstKey.commentBefore ?? "";
1663
- firstKey.commentBefore = existing ? `${tail}
1664
- ${existing}` : tail;
1665
- firstKey.spaceBefore = true;
1666
- }
1667
- }
1668
- parentMaybe.commentBefore = null;
1669
- parentMaybe.spaceBefore = false;
1670
- }
1671
- const m = new YAMLMap();
1672
- const pair = new Pair(newKey, m);
1673
- parent.items.unshift(pair);
1674
- return m;
1675
- }
1676
- function stripCommentedKeySkeleton(commentBody, key) {
1677
- const lines = commentBody.split("\n");
1678
- const headRe = new RegExp(`^ ${escapeRegExp(key)}:\\s*$`);
1679
- const out = [];
1680
- let i = 0;
1681
- while (i < lines.length) {
1682
- const line = lines[i];
1683
- if (headRe.test(line)) {
1684
- i++;
1685
- while (i < lines.length && /^ {2,}\S/.test(lines[i])) {
1686
- i++;
1687
- }
1688
- continue;
1689
- }
1690
- out.push(line);
1691
- i++;
1692
- }
1693
- return out.join("\n");
1694
- }
1695
- function escapeRegExp(s) {
1696
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1697
- }
1698
- function relocateLeakedLeafComments(leafMap, parent, ancestorKey) {
1699
- const items = parent.items;
1700
- const ancestorIdx = items.findIndex((p) => {
1701
- const k = p.key;
1702
- const v = typeof k === "string" ? k : k?.value ?? null;
1703
- return v === ancestorKey;
1704
- });
1705
- if (ancestorIdx < 0 || ancestorIdx + 1 >= items.length) return;
1706
- const target = items[ancestorIdx + 1];
1707
- for (const pair of leafMap.items) {
1708
- const value = pair.value;
1709
- if (!value || typeof value !== "object") continue;
1710
- const leakedComment = value.comment;
1711
- const leakedSpace = value.spaceBefore;
1712
- if (!leakedComment && !leakedSpace) continue;
1713
- if (leakedComment) {
1714
- const targetKey = target.key;
1715
- if (targetKey && typeof targetKey === "object") {
1716
- const existing = targetKey.commentBefore ?? "";
1717
- targetKey.commentBefore = existing ? `${leakedComment}
1718
- ${existing}` : leakedComment;
1719
- if (leakedSpace) targetKey.spaceBefore = true;
1720
- }
1721
- value.comment = null;
1722
- }
1723
- if (leakedSpace) value.spaceBefore = false;
1724
- }
1725
- }
1726
-
1727
- // src/init/components.ts
1728
- import { existsSync as existsSync3, promises as fs5 } from "fs";
1729
- import path6 from "path";
1730
- import { z as z3 } from "zod";
1731
- import { parse as parseYaml } from "yaml";
1732
- var CategorySchema = z3.enum(["language", "service", "feature"]);
1733
- var FeatureContributionSchema = z3.object({
1734
- ref: z3.string().regex(REGEX.featureRef),
1735
- options: z3.record(z3.string(), FeatureOptionValueSchema).optional()
1736
- });
1737
- var ComponentFileSchema = z3.object({
1738
- displayName: z3.string().min(1),
1739
- description: z3.string().min(1),
1740
- category: CategorySchema,
1741
- contributes: z3.object({
1742
- languages: z3.array(z3.string().min(1)).optional(),
1743
- services: z3.array(z3.string().min(1)).optional(),
1744
- features: z3.array(FeatureContributionSchema).optional()
1745
- })
1746
- }).superRefine((data, ctx) => {
1747
- const c = data.contributes;
1748
- const filled = [
1749
- c.languages && c.languages.length > 0 ? "languages" : null,
1750
- c.services && c.services.length > 0 ? "services" : null,
1751
- c.features && c.features.length > 0 ? "features" : null
1752
- ].filter((x) => x !== null);
1753
- if (filled.length === 0) {
1754
- ctx.addIssue({
1755
- code: z3.ZodIssueCode.custom,
1756
- message: "contributes must set at least one of languages/services/features"
1757
- });
1758
- return;
1759
- }
1760
- if (filled.length > 1) {
1761
- ctx.addIssue({
1762
- code: z3.ZodIssueCode.custom,
1763
- message: `contributes must set exactly one of languages/services/features, got: ${filled.join(", ")}`
1764
- });
1765
- return;
1766
- }
1767
- const expected = data.category === "language" ? "languages" : data.category === "service" ? "services" : "features";
1768
- if (filled[0] !== expected) {
1769
- ctx.addIssue({
1770
- code: z3.ZodIssueCode.custom,
1771
- message: `category '${data.category}' requires contributes.${expected}, got contributes.${filled[0]}`
1772
- });
1773
- }
1774
- });
1775
- async function loadComponentCatalog(rootDir = componentsDir()) {
1776
- if (!existsSync3(rootDir)) {
1777
- return /* @__PURE__ */ new Map();
1778
- }
1779
- const out = /* @__PURE__ */ new Map();
1780
- await walk(rootDir, rootDir, out);
1781
- return out;
1782
- }
1783
- async function walk(baseDir, currentDir, out) {
1784
- const entries = await fs5.readdir(currentDir, { withFileTypes: true });
1785
- for (const entry2 of entries) {
1786
- const full = path6.join(currentDir, entry2.name);
1787
- if (entry2.isDirectory()) {
1788
- await walk(baseDir, full, out);
1789
- continue;
1790
- }
1791
- if (!entry2.isFile() || !entry2.name.endsWith(".yml")) continue;
1792
- const relative = path6.relative(baseDir, full);
1793
- const name = relative.replace(/\.yml$/, "").split(path6.sep).join("/");
1794
- const text = await fs5.readFile(full, "utf8");
1795
- let raw;
1796
- try {
1797
- raw = parseYaml(text);
1798
- } catch (err) {
1799
- throw new Error(
1800
- `Failed to parse component ${name} (${full}): ${err.message}`
1801
- );
1802
- }
1803
- const parsed = ComponentFileSchema.safeParse(raw);
1804
- if (!parsed.success) {
1805
- const issues = parsed.error.issues.map((issue) => {
1806
- const where = issue.path.length > 0 ? issue.path.join(".") : "(root)";
1807
- return ` - ${where}: ${issue.message}`;
1808
- }).join("\n");
1809
- throw new Error(`Invalid component ${name} (${full}):
1810
- ${issues}`);
1811
- }
1812
- out.set(name, { name, sourcePath: full, file: parsed.data });
1813
- }
1814
- }
1815
- function mergeComponents(resolved) {
1816
- const languages = [];
1817
- const services = [];
1818
- const featureByRef = /* @__PURE__ */ new Map();
1819
- for (const entry2 of resolved) {
1820
- const c = isResolvedComponent(entry2) ? entry2.component : entry2;
1821
- const version = isResolvedComponent(entry2) ? entry2.version : void 0;
1822
- const ct = c.file.contributes;
1823
- for (const lang of ct.languages ?? []) {
1824
- const value = version !== void 0 ? `${lang}:${version}` : lang;
1825
- if (!languages.includes(value)) languages.push(value);
1826
- }
1827
- for (const svc of ct.services ?? []) {
1828
- if (!services.includes(svc)) services.push(svc);
1829
- }
1830
- for (const f of ct.features ?? []) {
1831
- const existing = featureByRef.get(f.ref);
1832
- if (!existing) {
1833
- featureByRef.set(f.ref, {
1834
- ref: f.ref,
1835
- options: { ...f.options ?? {} }
1836
- });
1837
- continue;
1838
- }
1839
- existing.options = mergeFeatureOptions(existing.options, f.options ?? {});
1840
- }
1841
- }
1842
- return {
1843
- languages,
1844
- services,
1845
- features: [...featureByRef.values()]
1846
- };
1847
- }
1848
- function isResolvedComponent(x) {
1849
- return "component" in x;
1850
- }
1851
- function mergeFeatureOptions(a, b) {
1852
- const result = { ...a };
1853
- for (const [key, valueB] of Object.entries(b)) {
1854
- const valueA = result[key];
1855
- if (typeof valueA === "boolean" && typeof valueB === "boolean") {
1856
- result[key] = valueA || valueB;
1857
- continue;
1858
- }
1859
- result[key] = valueB;
1860
- }
1861
- return result;
1862
- }
1863
- function resolveComponents(catalog, names) {
1864
- const unknown = [];
1865
- const out = [];
1866
- for (const raw of names) {
1867
- const colon = raw.indexOf(":");
1868
- const name = colon === -1 ? raw : raw.slice(0, colon);
1869
- const version = colon === -1 ? void 0 : raw.slice(colon + 1);
1870
- const c = catalog.get(name);
1871
- if (!c) {
1872
- unknown.push(raw);
1873
- continue;
1874
- }
1875
- if (version !== void 0 && c.file.category !== "language") {
1876
- throw new Error(
1877
- `Component '${name}' is a ${c.file.category}, not a language \u2014 a ':${version}' suffix has no meaning here.`
1878
- );
1879
- }
1880
- out.push({ component: c, ...version !== void 0 ? { version } : {} });
1881
- }
1882
- if (unknown.length > 0) {
1883
- const available = [...catalog.keys()].sort();
1884
- throw new Error(
1885
- `Unknown component${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.
1886
- Available: ${available.join(", ")}.`
1887
- );
1888
- }
1889
- return out;
1890
- }
1891
-
1892
- // src/proxy/dynamic.ts
1893
- import { promises as fs6 } from "fs";
1894
- import path7 from "path";
1895
- async function writeDynamicConfig(name, ports, opts = {}) {
1896
- if (ports.length === 0) {
1897
- throw new Error(
1898
- `writeDynamicConfig requires at least one port. For empty port lists, call removeDynamicConfig(${JSON.stringify(name)}).`
1899
- );
1900
- }
1901
- const dir = proxyDynamicDir(opts.monocerosHome);
1902
- await fs6.mkdir(dir, { recursive: true });
1903
- const file = path7.join(dir, `${name}.yml`);
1904
- await fs6.writeFile(file, renderDynamicConfig(name, ports), "utf8");
1905
- return file;
1906
- }
1907
- async function removeDynamicConfig(name, opts = {}) {
1908
- const file = path7.join(proxyDynamicDir(opts.monocerosHome), `${name}.yml`);
1909
- await fs6.rm(file, { force: true });
1910
- }
1911
- function renderDynamicConfig(name, ports) {
1912
- const lines = [];
1913
- lines.push("# Generated by Monoceros \u2014 do not edit by hand.");
1914
- lines.push(`# Container: ${name}`);
1915
- lines.push(`# Ports: ${ports.join(", ")}`);
1916
- lines.push("# Traefik file-provider re-reads this on change (~100 ms);");
1917
- lines.push(
1918
- "# to change routing, edit container-configs/" + name + ".yml or use"
1919
- );
1920
- lines.push("# `monoceros add-port` / `monoceros remove-port`.");
1921
- lines.push("http:");
1922
- lines.push(" routers:");
1923
- ports.forEach((port, idx) => {
1924
- const router = `${name}-${port}`;
1925
- const hostExplicit = `${name}-${port}.localhost`;
1926
- const rule = idx === 0 ? `"Host(\`${name}.localhost\`) || Host(\`${hostExplicit}\`)"` : `"Host(\`${hostExplicit}\`)"`;
1927
- lines.push(` ${router}:`);
1928
- lines.push(` rule: ${rule}`);
1929
- lines.push(` service: ${router}`);
1930
- lines.push(" entryPoints:");
1931
- lines.push(" - web");
1932
- });
1933
- lines.push(" services:");
1934
- for (const port of ports) {
1935
- const svc = `${name}-${port}`;
1936
- lines.push(` ${svc}:`);
1937
- lines.push(" loadBalancer:");
1938
- lines.push(" servers:");
1939
- lines.push(` - url: "http://${name}:${port}"`);
1940
- }
1941
- return lines.join("\n") + "\n";
1942
- }
1943
- function proxyUrlsFor(name, ports, hostPort = 80) {
1944
- const portSuffix = hostPort === 80 ? "" : `:${hostPort}`;
1945
- return ports.map((port, idx) => ({
1946
- port,
1947
- url: `http://${name}-${port}.localhost${portSuffix}`,
1948
- isDefault: idx === 0
1949
- }));
1950
- }
1951
-
1952
- // src/proxy/port-check.ts
1953
- import { Socket } from "net";
1954
- var CONNECT_TIMEOUT_MS = 750;
1955
- var realPortProbe = (port) => {
1956
- return new Promise((resolve) => {
1957
- const socket = new Socket();
1958
- let settled = false;
1959
- const settle = (result) => {
1960
- if (settled) return;
1961
- settled = true;
1962
- socket.destroy();
1963
- resolve(result);
1964
- };
1965
- socket.setTimeout(CONNECT_TIMEOUT_MS);
1966
- socket.once("connect", () => {
1967
- settle({
1968
- ok: false,
1969
- code: "EADDRINUSE",
1970
- message: `another process is listening on ${port}`
1971
- });
1972
- });
1973
- socket.once("timeout", () => {
1974
- settle({ ok: true });
1975
- });
1976
- socket.once("error", (err) => {
1977
- const code = err.code ?? "UNKNOWN";
1978
- if (code === "ECONNREFUSED") {
1979
- settle({ ok: true });
1980
- } else {
1981
- settle({
1982
- ok: false,
1983
- code,
1984
- message: err.message
1985
- });
1986
- }
1987
- });
1988
- socket.connect(port, "127.0.0.1");
1568
+ socket.connect(port, "127.0.0.1");
1989
1569
  });
1990
1570
  };
1991
1571
  async function preflightHostPort(hostPort, opts = {}) {
@@ -2110,8 +1690,8 @@ function knownServices() {
2110
1690
  }
2111
1691
 
2112
1692
  // src/create/scaffold.ts
2113
- import { existsSync as existsSync4, readFileSync as readFileSync2, promises as fs7 } from "fs";
2114
- import path8 from "path";
1693
+ import { existsSync as existsSync3, readFileSync, promises as fs7 } from "fs";
1694
+ import path6 from "path";
2115
1695
 
2116
1696
  // src/util/ref.ts
2117
1697
  var FEATURE_NAME_CHARSET = "[a-z0-9._-]+";
@@ -2282,8 +1862,8 @@ function resolveFeatures(opts) {
2282
1862
  if (match) {
2283
1863
  const name = match.name;
2284
1864
  const checkout = workbenchCheckoutRoot();
2285
- const localSourceDir = checkout ? path8.join(checkout, "images", "features", name) : null;
2286
- if (localSourceDir && existsSync4(localSourceDir)) {
1865
+ const localSourceDir = checkout ? path6.join(checkout, "images", "features", name) : null;
1866
+ if (localSourceDir && existsSync3(localSourceDir)) {
2287
1867
  const { paths, files } = readPersistentHomeEntries(localSourceDir);
2288
1868
  resolved.push({
2289
1869
  devcontainerKey: `./features/${name}`,
@@ -2307,9 +1887,9 @@ function resolveFeatures(opts) {
2307
1887
  return resolved;
2308
1888
  }
2309
1889
  function readPersistentHomeEntries(localSourceDir) {
2310
- const manifestPath = path8.join(localSourceDir, "devcontainer-feature.json");
1890
+ const manifestPath = path6.join(localSourceDir, "devcontainer-feature.json");
2311
1891
  try {
2312
- const text = readFileSync2(manifestPath, "utf8");
1892
+ const text = readFileSync(manifestPath, "utf8");
2313
1893
  const parsed = JSON.parse(text);
2314
1894
  return {
2315
1895
  paths: filterSubpaths(parsed["x-monoceros"]?.persistentHomePaths),
@@ -2592,17 +2172,17 @@ function buildPostCreateScript(opts) {
2592
2172
  return lines.join("\n") + "\n";
2593
2173
  }
2594
2174
  async function writePostCreateScript(devcontainerDir, opts) {
2595
- const dest = path8.join(devcontainerDir, "post-create.sh");
2175
+ const dest = path6.join(devcontainerDir, "post-create.sh");
2596
2176
  await fs7.writeFile(dest, buildPostCreateScript(opts));
2597
2177
  await fs7.chmod(dest, 493);
2598
2178
  }
2599
2179
  async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2600
2180
  const dockerMode = scaffoldOpts.dockerMode ?? "rootful";
2601
- const devcontainerDir = path8.join(targetDir, ".devcontainer");
2602
- const monocerosDir = path8.join(targetDir, ".monoceros");
2603
- const projectsDir = path8.join(targetDir, "projects");
2604
- const homeDir = path8.join(targetDir, "home");
2605
- const dataDir = path8.join(targetDir, "data");
2181
+ const devcontainerDir = path6.join(targetDir, ".devcontainer");
2182
+ const monocerosDir = path6.join(targetDir, ".monoceros");
2183
+ const projectsDir = path6.join(targetDir, "projects");
2184
+ const homeDir = path6.join(targetDir, "home");
2185
+ const dataDir = path6.join(targetDir, "data");
2606
2186
  await fs7.mkdir(devcontainerDir, { recursive: true });
2607
2187
  await fs7.mkdir(monocerosDir, { recursive: true });
2608
2188
  await fs7.mkdir(projectsDir, { recursive: true });
@@ -2612,56 +2192,56 @@ async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2612
2192
  for (const svcId of opts.services) {
2613
2193
  const def = SERVICE_CATALOG[svcId];
2614
2194
  if (def?.dataMount) {
2615
- await fs7.mkdir(path8.join(dataDir, def.id), { recursive: true });
2195
+ await fs7.mkdir(path6.join(dataDir, def.id), { recursive: true });
2616
2196
  }
2617
2197
  }
2618
2198
  }
2619
- const containerGitignore = path8.join(targetDir, ".gitignore");
2199
+ const containerGitignore = path6.join(targetDir, ".gitignore");
2620
2200
  await fs7.writeFile(containerGitignore, "/home/\n/.monoceros/\n/data/\n");
2621
- const gitkeep = path8.join(projectsDir, ".gitkeep");
2622
- if (!existsSync4(gitkeep)) {
2201
+ const gitkeep = path6.join(projectsDir, ".gitkeep");
2202
+ if (!existsSync3(gitkeep)) {
2623
2203
  await fs7.writeFile(gitkeep, "");
2624
2204
  }
2625
2205
  await fs7.writeFile(
2626
- path8.join(monocerosDir, ".gitignore"),
2206
+ path6.join(monocerosDir, ".gitignore"),
2627
2207
  "git-credentials*\ngitconfig\n"
2628
2208
  );
2629
2209
  const devcontainerJson = buildDevcontainerJson(opts, dockerMode);
2630
2210
  await fs7.writeFile(
2631
- path8.join(devcontainerDir, "devcontainer.json"),
2211
+ path6.join(devcontainerDir, "devcontainer.json"),
2632
2212
  JSON.stringify(devcontainerJson, null, 2) + "\n"
2633
2213
  );
2634
- const featuresDir = path8.join(devcontainerDir, "features");
2635
- if (existsSync4(featuresDir)) {
2214
+ const featuresDir = path6.join(devcontainerDir, "features");
2215
+ if (existsSync3(featuresDir)) {
2636
2216
  await fs7.rm(featuresDir, { recursive: true, force: true });
2637
2217
  }
2638
2218
  const resolvedFeatures = resolveFeatures(opts);
2639
2219
  for (const f of resolvedFeatures) {
2640
2220
  if (!f.localSourceDir || !f.localName) continue;
2641
- const dest = path8.join(featuresDir, f.localName);
2221
+ const dest = path6.join(featuresDir, f.localName);
2642
2222
  await fs7.mkdir(dest, { recursive: true });
2643
2223
  await fs7.cp(f.localSourceDir, dest, { recursive: true });
2644
2224
  }
2645
2225
  for (const f of resolvedFeatures) {
2646
2226
  for (const sub of f.persistentHomePaths) {
2647
- await fs7.mkdir(path8.join(homeDir, sub), { recursive: true });
2227
+ await fs7.mkdir(path6.join(homeDir, sub), { recursive: true });
2648
2228
  }
2649
2229
  for (const entry2 of f.persistentHomeFiles) {
2650
- const filePath = path8.join(homeDir, entry2.path);
2651
- await fs7.mkdir(path8.dirname(filePath), { recursive: true });
2652
- if (!existsSync4(filePath)) {
2230
+ const filePath = path6.join(homeDir, entry2.path);
2231
+ await fs7.mkdir(path6.dirname(filePath), { recursive: true });
2232
+ if (!existsSync3(filePath)) {
2653
2233
  await fs7.writeFile(filePath, entry2.initialContent);
2654
2234
  }
2655
2235
  }
2656
2236
  }
2657
2237
  await writePostCreateScript(devcontainerDir, opts);
2658
- const composePath = path8.join(devcontainerDir, "compose.yaml");
2238
+ const composePath = path6.join(devcontainerDir, "compose.yaml");
2659
2239
  if (needsCompose(opts)) {
2660
2240
  await fs7.writeFile(composePath, buildComposeYaml(opts, dockerMode));
2661
- } else if (existsSync4(composePath)) {
2241
+ } else if (existsSync3(composePath)) {
2662
2242
  await fs7.rm(composePath);
2663
2243
  }
2664
- const workspacePath = path8.join(targetDir, `${opts.name}.code-workspace`);
2244
+ const workspacePath = path6.join(targetDir, `${opts.name}.code-workspace`);
2665
2245
  let existingWorkspace;
2666
2246
  try {
2667
2247
  const raw = await fs7.readFile(workspacePath, "utf8");
@@ -2686,25 +2266,25 @@ import {
2686
2266
  } from "yaml";
2687
2267
 
2688
2268
  // src/init/manifest.ts
2689
- import { existsSync as existsSync5, readFileSync as readFileSync3 } from "fs";
2690
- import path9 from "path";
2269
+ import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
2270
+ import path7 from "path";
2691
2271
  function resolveManifestPath(name, checkoutRoot) {
2692
2272
  if (checkoutRoot) {
2693
- const checkoutPath = path9.join(
2273
+ const checkoutPath = path7.join(
2694
2274
  checkoutRoot,
2695
2275
  "images",
2696
2276
  "features",
2697
2277
  name,
2698
2278
  "devcontainer-feature.json"
2699
2279
  );
2700
- if (existsSync5(checkoutPath)) return checkoutPath;
2280
+ if (existsSync4(checkoutPath)) return checkoutPath;
2701
2281
  }
2702
- const bundlePath = path9.join(
2282
+ const bundlePath = path7.join(
2703
2283
  bundledFeaturesDir(),
2704
2284
  name,
2705
2285
  "devcontainer-feature.json"
2706
2286
  );
2707
- if (existsSync5(bundlePath)) return bundlePath;
2287
+ if (existsSync4(bundlePath)) return bundlePath;
2708
2288
  return null;
2709
2289
  }
2710
2290
  function loadFeatureManifestSummary(ref, checkoutRoot = workbenchCheckoutRoot()) {
@@ -2713,7 +2293,7 @@ function loadFeatureManifestSummary(ref, checkoutRoot = workbenchCheckoutRoot())
2713
2293
  const manifestPath = resolveManifestPath(match.name, checkoutRoot);
2714
2294
  if (!manifestPath) return void 0;
2715
2295
  try {
2716
- const text = readFileSync3(manifestPath, "utf8");
2296
+ const text = readFileSync2(manifestPath, "utf8");
2717
2297
  const parsed = JSON.parse(text);
2718
2298
  const rawHints = parsed["x-monoceros"]?.optionHints;
2719
2299
  const optionHints = Array.isArray(rawHints) ? rawHints.filter(
@@ -3389,7 +2969,7 @@ async function tryCloneInRunningContainer(input, entry2) {
3389
2969
  logger.info(
3390
2970
  `Cloned ${entry2.url} into /workspaces/${containerName2}/${targetRel} inside the running container.`
3391
2971
  );
3392
- void path10;
2972
+ void path8;
3393
2973
  }
3394
2974
  function shquote(value) {
3395
2975
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -3608,13 +3188,13 @@ async function mutate(opts, apply) {
3608
3188
  }
3609
3189
  function defaultLogger() {
3610
3190
  return {
3611
- info: (m) => consola2.info(m),
3612
- success: (m) => consola2.success(m),
3613
- warn: (m) => consola2.warn(m)
3191
+ info: (m) => consola.info(m),
3192
+ success: (m) => consola.success(m),
3193
+ warn: (m) => consola.warn(m)
3614
3194
  };
3615
3195
  }
3616
3196
  var defaultConfirm = async (message) => {
3617
- const result = await consola2.prompt(message, {
3197
+ const result = await consola.prompt(message, {
3618
3198
  type: "confirm",
3619
3199
  initial: false
3620
3200
  });
@@ -3654,9 +3234,6 @@ async function syncPortsToProxy(input) {
3654
3234
  ...input.proxyDocker ? { docker: input.proxyDocker } : {},
3655
3235
  logger: { info: (m) => logger.info(m), warn: (m) => logger.warn(m) }
3656
3236
  });
3657
- await kickProxyReload({
3658
- ...input.proxyDocker ? { docker: input.proxyDocker } : {}
3659
- });
3660
3237
  const urls = proxyUrlsFor(input.name, allPorts, hostPort);
3661
3238
  const lines = urls.map((u) => {
3662
3239
  const tag = u.isDefault ? " (default)" : "";
@@ -3666,9 +3243,6 @@ async function syncPortsToProxy(input) {
3666
3243
  ${lines.join("\n")}`);
3667
3244
  } else {
3668
3245
  await removeDynamicConfig(input.name, { monocerosHome: home });
3669
- await kickProxyReload({
3670
- ...input.proxyDocker ? { docker: input.proxyDocker } : {}
3671
- });
3672
3246
  await maybeStopProxy({
3673
3247
  monocerosHome: home,
3674
3248
  ...input.proxyDocker ? { docker: input.proxyDocker } : {},
@@ -3705,7 +3279,7 @@ var addAptPackagesCommand = defineCommand({
3705
3279
  async run({ args }) {
3706
3280
  const packages = [...getInnerArgs()];
3707
3281
  if (packages.length === 0) {
3708
- consola3.error(
3282
+ consola2.error(
3709
3283
  "No package names given. Usage: `monoceros add-apt-packages <containername> [--yes] -- <pkg> [<pkg> \u2026]`."
3710
3284
  );
3711
3285
  process.exit(1);
@@ -3718,7 +3292,7 @@ var addAptPackagesCommand = defineCommand({
3718
3292
  });
3719
3293
  process.exit(result.status === "aborted" ? 1 : 0);
3720
3294
  } catch (err) {
3721
- consola3.error(err instanceof Error ? err.message : String(err));
3295
+ consola2.error(err instanceof Error ? err.message : String(err));
3722
3296
  process.exit(1);
3723
3297
  }
3724
3298
  }
@@ -3726,7 +3300,7 @@ var addAptPackagesCommand = defineCommand({
3726
3300
 
3727
3301
  // src/commands/add-feature.ts
3728
3302
  import { defineCommand as defineCommand2 } from "citty";
3729
- import { consola as consola4 } from "consola";
3303
+ import { consola as consola3 } from "consola";
3730
3304
  var addFeatureCommand = defineCommand2({
3731
3305
  meta: {
3732
3306
  name: "add-feature",
@@ -3756,7 +3330,7 @@ var addFeatureCommand = defineCommand2({
3756
3330
  try {
3757
3331
  options = parseOptionsAfterDashes(getInnerArgs());
3758
3332
  } catch (err) {
3759
- consola4.error(err instanceof Error ? err.message : String(err));
3333
+ consola3.error(err instanceof Error ? err.message : String(err));
3760
3334
  process.exit(1);
3761
3335
  }
3762
3336
  try {
@@ -3768,7 +3342,7 @@ var addFeatureCommand = defineCommand2({
3768
3342
  });
3769
3343
  process.exit(result.status === "aborted" ? 1 : 0);
3770
3344
  } catch (err) {
3771
- consola4.error(err instanceof Error ? err.message : String(err));
3345
+ consola3.error(err instanceof Error ? err.message : String(err));
3772
3346
  process.exit(1);
3773
3347
  }
3774
3348
  }
@@ -3800,7 +3374,7 @@ function coerce(value) {
3800
3374
 
3801
3375
  // src/commands/add-from-url.ts
3802
3376
  import { defineCommand as defineCommand3 } from "citty";
3803
- import { consola as consola5 } from "consola";
3377
+ import { consola as consola4 } from "consola";
3804
3378
  var addFromUrlCommand = defineCommand3({
3805
3379
  meta: {
3806
3380
  name: "add-from-url",
@@ -3837,7 +3411,7 @@ var addFromUrlCommand = defineCommand3({
3837
3411
  });
3838
3412
  process.exit(result.status === "aborted" ? 1 : 0);
3839
3413
  } catch (err) {
3840
- consola5.error(err instanceof Error ? err.message : String(err));
3414
+ consola4.error(err instanceof Error ? err.message : String(err));
3841
3415
  process.exit(1);
3842
3416
  }
3843
3417
  }
@@ -3872,7 +3446,7 @@ function printSecurityWarning(url) {
3872
3446
 
3873
3447
  // src/commands/add-repo.ts
3874
3448
  import { defineCommand as defineCommand4 } from "citty";
3875
- import { consola as consola6 } from "consola";
3449
+ import { consola as consola5 } from "consola";
3876
3450
  var addRepoCommand = defineCommand4({
3877
3451
  meta: {
3878
3452
  name: "add-repo",
@@ -3926,7 +3500,7 @@ var addRepoCommand = defineCommand4({
3926
3500
  });
3927
3501
  process.exit(result.status === "aborted" ? 1 : 0);
3928
3502
  } catch (err) {
3929
- consola6.error(err instanceof Error ? err.message : String(err));
3503
+ consola5.error(err instanceof Error ? err.message : String(err));
3930
3504
  process.exit(1);
3931
3505
  }
3932
3506
  }
@@ -3934,7 +3508,7 @@ var addRepoCommand = defineCommand4({
3934
3508
 
3935
3509
  // src/commands/add-language.ts
3936
3510
  import { defineCommand as defineCommand5 } from "citty";
3937
- import { consola as consola7 } from "consola";
3511
+ import { consola as consola6 } from "consola";
3938
3512
  var addLanguageCommand = defineCommand5({
3939
3513
  meta: {
3940
3514
  name: "add-language",
@@ -3967,207 +3541,549 @@ var addLanguageCommand = defineCommand5({
3967
3541
  yes: args.yes
3968
3542
  });
3969
3543
  process.exit(result.status === "aborted" ? 1 : 0);
3544
+ } catch (err) {
3545
+ consola6.error(err instanceof Error ? err.message : String(err));
3546
+ process.exit(1);
3547
+ }
3548
+ }
3549
+ });
3550
+
3551
+ // src/commands/add-port.ts
3552
+ import { defineCommand as defineCommand6 } from "citty";
3553
+ import { consola as consola7 } from "consola";
3554
+ var addPortCommand = defineCommand6({
3555
+ meta: {
3556
+ name: "add-port",
3557
+ group: "edit",
3558
+ 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)."
3559
+ },
3560
+ args: {
3561
+ name: {
3562
+ type: "positional",
3563
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3564
+ required: true
3565
+ },
3566
+ yes: {
3567
+ type: "boolean",
3568
+ description: "Skip the interactive confirmation and apply the diff.",
3569
+ alias: ["y"],
3570
+ default: false
3571
+ },
3572
+ default: {
3573
+ type: "boolean",
3574
+ 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.",
3575
+ default: false
3576
+ }
3577
+ },
3578
+ async run({ args }) {
3579
+ const tokens = [...getInnerArgs()];
3580
+ if (tokens.length === 0) {
3581
+ consola7.error(
3582
+ "No ports given. Usage: `monoceros add-port <containername> [--yes] [--default] -- <port> [<port> \u2026]`."
3583
+ );
3584
+ process.exit(1);
3585
+ }
3586
+ try {
3587
+ const result = await runAddPort({
3588
+ name: args.name,
3589
+ ports: tokens.map(coerceToken),
3590
+ yes: args.yes,
3591
+ asDefault: args.default
3592
+ });
3593
+ process.exit(result.status === "aborted" ? 1 : 0);
3970
3594
  } catch (err) {
3971
3595
  consola7.error(err instanceof Error ? err.message : String(err));
3972
3596
  process.exit(1);
3973
3597
  }
3974
3598
  }
3975
- });
3599
+ });
3600
+ function coerceToken(raw) {
3601
+ const n = Number(raw);
3602
+ return Number.isFinite(n) ? n : raw;
3603
+ }
3604
+
3605
+ // src/commands/add-service.ts
3606
+ import { defineCommand as defineCommand7 } from "citty";
3607
+ import { consola as consola8 } from "consola";
3608
+ var addServiceCommand = defineCommand7({
3609
+ meta: {
3610
+ name: "add-service",
3611
+ group: "edit",
3612
+ description: "Add a compose service (postgres, mysql, redis, \u2026) to the container config. Idempotent, prints a diff before writing."
3613
+ },
3614
+ args: {
3615
+ name: {
3616
+ type: "positional",
3617
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3618
+ required: true
3619
+ },
3620
+ service: {
3621
+ type: "positional",
3622
+ description: "Service identifier (postgres, mysql, redis).",
3623
+ required: true
3624
+ },
3625
+ yes: {
3626
+ type: "boolean",
3627
+ description: "Skip the interactive confirmation and apply the diff.",
3628
+ alias: ["y"],
3629
+ default: false
3630
+ }
3631
+ },
3632
+ async run({ args }) {
3633
+ try {
3634
+ const result = await runAddService({
3635
+ name: args.name,
3636
+ service: args.service,
3637
+ yes: args.yes
3638
+ });
3639
+ process.exit(result.status === "aborted" ? 1 : 0);
3640
+ } catch (err) {
3641
+ consola8.error(err instanceof Error ? err.message : String(err));
3642
+ process.exit(1);
3643
+ }
3644
+ }
3645
+ });
3646
+
3647
+ // src/commands/apply.ts
3648
+ import { defineCommand as defineCommand8 } from "citty";
3649
+
3650
+ // src/apply/index.ts
3651
+ import { existsSync as existsSync6, promises as fs11 } from "fs";
3652
+ import { consola as consola11 } from "consola";
3653
+
3654
+ // src/config/state.ts
3655
+ import { promises as fs9 } from "fs";
3656
+ import path9 from "path";
3657
+ function buildStateFile(opts) {
3658
+ return {
3659
+ schemaVersion: CONFIG_SCHEMA_VERSION,
3660
+ origin: opts.origin,
3661
+ monocerosCliVersion: opts.cliVersion,
3662
+ materializedAt: (opts.now ?? /* @__PURE__ */ new Date()).toISOString()
3663
+ };
3664
+ }
3665
+ function stateFilePath(targetDir) {
3666
+ return path9.join(targetDir, ".monoceros", "state.json");
3667
+ }
3668
+ async function readStateFile(targetDir) {
3669
+ try {
3670
+ const content = await fs9.readFile(stateFilePath(targetDir), "utf8");
3671
+ return JSON.parse(content);
3672
+ } catch {
3673
+ return void 0;
3674
+ }
3675
+ }
3676
+ async function writeStateFile(targetDir, state) {
3677
+ const monocerosDir = path9.join(targetDir, ".monoceros");
3678
+ await fs9.mkdir(monocerosDir, { recursive: true });
3679
+ await fs9.writeFile(
3680
+ stateFilePath(targetDir),
3681
+ JSON.stringify(state, null, 2) + "\n"
3682
+ );
3683
+ }
3684
+
3685
+ // src/config/transform.ts
3686
+ function solutionConfigToCreateOptions(config, featureDefaults = {}) {
3687
+ const featureRecord = {};
3688
+ for (const entry2 of config.features) {
3689
+ const defaults = featureDefaults[entry2.ref] ?? {};
3690
+ const containerOpts = Object.fromEntries(
3691
+ Object.entries(entry2.options ?? {}).filter(([, v]) => v !== "")
3692
+ );
3693
+ featureRecord[entry2.ref] = { ...defaults, ...containerOpts };
3694
+ }
3695
+ const result = {
3696
+ name: config.name,
3697
+ languages: [...config.languages],
3698
+ services: [...config.services]
3699
+ };
3700
+ if (config.externalServices.postgres !== void 0) {
3701
+ result.postgresUrl = config.externalServices.postgres;
3702
+ }
3703
+ if (config.aptPackages.length > 0) {
3704
+ result.aptPackages = [...config.aptPackages];
3705
+ }
3706
+ if (Object.keys(featureRecord).length > 0) {
3707
+ result.features = featureRecord;
3708
+ }
3709
+ if (config.installUrls.length > 0) {
3710
+ result.installUrls = [...config.installUrls];
3711
+ }
3712
+ if (config.repos.length > 0) {
3713
+ result.repos = config.repos.map((r) => ({
3714
+ url: r.url,
3715
+ // `path` is optional in the yml; CreateOptions requires it.
3716
+ // When the yml omits `path`, fall back to the URL-derived
3717
+ // single-segment default (`https://.../foo.git` → `foo`),
3718
+ // which lands the clone at `projects/foo/`.
3719
+ path: r.path ?? deriveRepoName(r.url),
3720
+ // gitUser is forwarded only when BOTH name + email are set.
3721
+ // The relaxed GitUserSchema accepts nullable / empty strings
3722
+ // (so a yml placeholder `name:` parses without error), so we
3723
+ // re-check here before downstream code, which expects both
3724
+ // values to be non-empty.
3725
+ ...r.git?.user?.name && r.git.user.email ? { gitUser: { name: r.git.user.name, email: r.git.user.email } } : {},
3726
+ ...r.provider ? { provider: r.provider } : {}
3727
+ }));
3728
+ }
3729
+ const routingPorts = config.routing?.ports ?? [];
3730
+ if (routingPorts.length > 0) {
3731
+ const seen = /* @__PURE__ */ new Set();
3732
+ const ports = [];
3733
+ for (const entry2 of routingPorts) {
3734
+ const n = portNumber(entry2);
3735
+ if (seen.has(n)) continue;
3736
+ seen.add(n);
3737
+ ports.push(n);
3738
+ }
3739
+ result.ports = ports;
3740
+ }
3741
+ if (config.routing?.vscodeAutoForward !== void 0) {
3742
+ result.vscodeAutoForward = config.routing.vscodeAutoForward;
3743
+ }
3744
+ return result;
3745
+ }
3976
3746
 
3977
- // src/commands/add-port.ts
3978
- import { defineCommand as defineCommand6 } from "citty";
3979
- import { consola as consola8 } from "consola";
3980
- var addPortCommand = defineCommand6({
3981
- meta: {
3982
- name: "add-port",
3983
- group: "edit",
3984
- 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)."
3985
- },
3986
- args: {
3987
- name: {
3988
- type: "positional",
3989
- description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3990
- required: true
3747
+ // src/devcontainer/compose.ts
3748
+ import { spawn as spawn5 } from "child_process";
3749
+ import { existsSync as existsSync5 } from "fs";
3750
+ import path11 from "path";
3751
+ import { consola as consola9 } from "consola";
3752
+
3753
+ // src/util/mask-secrets.ts
3754
+ import { Transform } from "stream";
3755
+ var PATTERNS = [
3756
+ // Atlassian Cloud API token. Starts with literal `ATATT3xFf` plus
3757
+ // a long URL-safe-base64 tail. Tightened to that prefix to avoid
3758
+ // matching unrelated all-caps words.
3759
+ { name: "atlassian-api", re: /ATATT3xFf[A-Za-z0-9+/=_-]{20,}/g },
3760
+ // Bitbucket Cloud app password.
3761
+ { name: "bitbucket-app", re: /ATBB[A-Za-z0-9+/=_-]{20,}/g },
3762
+ // GitHub PAT (classic), OAuth, user, server, refresh — all share
3763
+ // the `gh<lower-letter>_<base62>` shape per GitHub's token format.
3764
+ { name: "github-token", re: /gh[a-z]_[A-Za-z0-9]{20,}/g },
3765
+ // GitHub fine-grained PAT.
3766
+ { name: "github-pat", re: /github_pat_[A-Za-z0-9_]{20,}/g },
3767
+ // Anthropic API key.
3768
+ { name: "anthropic-api", re: /sk-ant-[A-Za-z0-9_-]{20,}/g }
3769
+ ];
3770
+ function maskSecrets(text) {
3771
+ let result = text;
3772
+ for (const { re } of PATTERNS) {
3773
+ result = result.replace(re, maskOne);
3774
+ }
3775
+ return result;
3776
+ }
3777
+ function maskOne(token) {
3778
+ if (token.length <= 12) return token;
3779
+ return `${token.slice(0, 5)}\u2026${token.slice(-6)}`;
3780
+ }
3781
+ function createSecretMaskStream() {
3782
+ let buffer = "";
3783
+ return new Transform({
3784
+ decodeStrings: true,
3785
+ transform(chunk, _enc, cb) {
3786
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
3787
+ buffer += text;
3788
+ const lastNewline = buffer.lastIndexOf("\n");
3789
+ if (lastNewline === -1) {
3790
+ cb(null);
3791
+ return;
3792
+ }
3793
+ const flushable = buffer.slice(0, lastNewline + 1);
3794
+ buffer = buffer.slice(lastNewline + 1);
3795
+ cb(null, maskSecrets(flushable));
3991
3796
  },
3992
- yes: {
3993
- type: "boolean",
3994
- description: "Skip the interactive confirmation and apply the diff.",
3995
- alias: ["y"],
3996
- default: false
3797
+ flush(cb) {
3798
+ if (buffer.length > 0) {
3799
+ const tail = maskSecrets(buffer);
3800
+ buffer = "";
3801
+ cb(null, tail);
3802
+ return;
3803
+ }
3804
+ cb(null);
3805
+ }
3806
+ });
3807
+ }
3808
+
3809
+ // src/devcontainer/cli.ts
3810
+ import { spawn as spawn4 } from "child_process";
3811
+ import { readFileSync as readFileSync3 } from "fs";
3812
+ import { createRequire } from "module";
3813
+ import path10 from "path";
3814
+
3815
+ // src/devcontainer/runtime-pull-hint.ts
3816
+ import { Transform as Transform2 } from "stream";
3817
+ var RUNTIME_PULL_MARKER = "No manifest found for ghcr.io/getmonoceros/monoceros-runtime";
3818
+ 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...';
3819
+ function createRuntimePullHintStream(state) {
3820
+ let buffer = "";
3821
+ const appendHintIfMarker = (block) => {
3822
+ if (state.hinted || !block.includes(RUNTIME_PULL_MARKER)) return block;
3823
+ state.hinted = true;
3824
+ return `${block}${dim(`(i) ${RUNTIME_PULL_HINT}`)}
3825
+ `;
3826
+ };
3827
+ return new Transform2({
3828
+ decodeStrings: true,
3829
+ transform(chunk, _enc, cb) {
3830
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
3831
+ buffer += text;
3832
+ const lastNewline = buffer.lastIndexOf("\n");
3833
+ if (lastNewline === -1) {
3834
+ cb(null);
3835
+ return;
3836
+ }
3837
+ const flushable = buffer.slice(0, lastNewline + 1);
3838
+ buffer = buffer.slice(lastNewline + 1);
3839
+ cb(null, appendHintIfMarker(flushable));
3997
3840
  },
3998
- default: {
3999
- type: "boolean",
4000
- 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.",
4001
- default: false
3841
+ flush(cb) {
3842
+ if (buffer.length === 0) {
3843
+ cb(null);
3844
+ return;
3845
+ }
3846
+ const tail = buffer;
3847
+ buffer = "";
3848
+ cb(null, appendHintIfMarker(tail));
4002
3849
  }
4003
- },
4004
- async run({ args }) {
4005
- const tokens = [...getInnerArgs()];
4006
- if (tokens.length === 0) {
4007
- consola8.error(
4008
- "No ports given. Usage: `monoceros add-port <containername> [--yes] [--default] -- <port> [<port> \u2026]`."
4009
- );
4010
- process.exit(1);
3850
+ });
3851
+ }
3852
+
3853
+ // src/devcontainer/cli.ts
3854
+ var require_ = createRequire(import.meta.url);
3855
+ var cachedBinaryPath = null;
3856
+ function devcontainerCliPath() {
3857
+ if (cachedBinaryPath) return cachedBinaryPath;
3858
+ const pkgJsonPath = require_.resolve("@devcontainers/cli/package.json");
3859
+ const pkg = JSON.parse(readFileSync3(pkgJsonPath, "utf8"));
3860
+ const binEntry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.devcontainer ?? "";
3861
+ if (!binEntry) {
3862
+ throw new Error("Could not resolve @devcontainers/cli bin entry.");
3863
+ }
3864
+ cachedBinaryPath = path10.resolve(path10.dirname(pkgJsonPath), binEntry);
3865
+ return cachedBinaryPath;
3866
+ }
3867
+ var spawnDevcontainer = (args, cwd, options = {}) => {
3868
+ const binPath = devcontainerCliPath();
3869
+ return new Promise((resolve, reject) => {
3870
+ if (options.interactive) {
3871
+ const child2 = spawn4(process.execPath, [binPath, ...args], {
3872
+ cwd,
3873
+ stdio: "inherit"
3874
+ });
3875
+ child2.on("error", reject);
3876
+ child2.on("exit", (code) => resolve(code ?? 0));
3877
+ return;
4011
3878
  }
4012
- try {
4013
- const result = await runAddPort({
4014
- name: args.name,
4015
- ports: tokens.map(coerceToken),
4016
- yes: args.yes,
4017
- asDefault: args.default
3879
+ const child = spawn4(process.execPath, [binPath, ...args], {
3880
+ cwd,
3881
+ stdio: ["ignore", "pipe", "pipe"]
3882
+ });
3883
+ if (options.quiet) {
3884
+ const stdoutChunks = [];
3885
+ const stderrChunks = [];
3886
+ child.stdout?.on("data", (chunk) => stdoutChunks.push(chunk));
3887
+ child.stderr?.on("data", (chunk) => stderrChunks.push(chunk));
3888
+ child.on("error", reject);
3889
+ child.on("exit", (code) => {
3890
+ const exitCode = code ?? 0;
3891
+ if (exitCode !== 0) {
3892
+ process.stderr.write(
3893
+ maskSecrets(Buffer.concat(stderrChunks).toString("utf8"))
3894
+ );
3895
+ process.stderr.write(
3896
+ maskSecrets(Buffer.concat(stdoutChunks).toString("utf8"))
3897
+ );
3898
+ }
3899
+ resolve(exitCode);
4018
3900
  });
4019
- process.exit(result.status === "aborted" ? 1 : 0);
4020
- } catch (err) {
4021
- consola8.error(err instanceof Error ? err.message : String(err));
4022
- process.exit(1);
3901
+ return;
3902
+ }
3903
+ const pullHint = { hinted: false };
3904
+ child.stdout?.pipe(createSecretMaskStream()).pipe(createRuntimePullHintStream(pullHint)).pipe(process.stdout);
3905
+ child.stderr?.pipe(createSecretMaskStream()).pipe(createRuntimePullHintStream(pullHint)).pipe(process.stderr);
3906
+ child.on("error", reject);
3907
+ child.on("exit", (code) => resolve(code ?? 0));
3908
+ });
3909
+ };
3910
+
3911
+ // src/devcontainer/compose.ts
3912
+ var spawnDockerCompose = (args, cwd) => {
3913
+ return new Promise((resolve, reject) => {
3914
+ const child = spawn5("docker", ["compose", ...args], {
3915
+ cwd,
3916
+ stdio: ["inherit", "pipe", "pipe"]
3917
+ });
3918
+ child.stdout?.pipe(createSecretMaskStream()).pipe(process.stdout);
3919
+ child.stderr?.pipe(createSecretMaskStream()).pipe(process.stderr);
3920
+ child.on("error", reject);
3921
+ child.on("exit", (code) => resolve(code ?? 0));
3922
+ });
3923
+ };
3924
+ var spawnDocker = (args) => {
3925
+ return new Promise((resolve, reject) => {
3926
+ const child = spawn5("docker", args, {
3927
+ stdio: ["ignore", "pipe", "pipe"]
3928
+ });
3929
+ let stdout = "";
3930
+ let stderr = "";
3931
+ child.stdout?.on("data", (chunk) => {
3932
+ stdout += chunk.toString("utf8");
3933
+ });
3934
+ child.stderr?.on("data", (chunk) => {
3935
+ stderr += chunk.toString("utf8");
3936
+ });
3937
+ child.on("error", reject);
3938
+ child.on(
3939
+ "exit",
3940
+ (code) => resolve({ exitCode: code ?? 0, stdout, stderr })
3941
+ );
3942
+ });
3943
+ };
3944
+ async function findContainerIds(filters, exec = spawnDocker) {
3945
+ const ids = /* @__PURE__ */ new Set();
3946
+ for (const filter of filters) {
3947
+ const result = await exec(["ps", "-aq", "--filter", filter]);
3948
+ if (result.exitCode !== 0) continue;
3949
+ for (const line of result.stdout.split(/\r?\n/)) {
3950
+ const id = line.trim();
3951
+ if (id) ids.add(id);
4023
3952
  }
4024
3953
  }
4025
- });
4026
- function coerceToken(raw) {
4027
- const n = Number(raw);
4028
- return Number.isFinite(n) ? n : raw;
3954
+ return [...ids];
4029
3955
  }
4030
-
4031
- // src/commands/add-service.ts
4032
- import { defineCommand as defineCommand7 } from "citty";
4033
- import { consola as consola9 } from "consola";
4034
- var addServiceCommand = defineCommand7({
4035
- meta: {
4036
- name: "add-service",
4037
- group: "edit",
4038
- description: "Add a compose service (postgres, mysql, redis, \u2026) to the container config. Idempotent, prints a diff before writing."
4039
- },
4040
- args: {
4041
- name: {
4042
- type: "positional",
4043
- description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
4044
- required: true
4045
- },
4046
- service: {
4047
- type: "positional",
4048
- description: "Service identifier (postgres, mysql, redis).",
4049
- required: true
4050
- },
4051
- yes: {
4052
- type: "boolean",
4053
- description: "Skip the interactive confirmation and apply the diff.",
4054
- alias: ["y"],
4055
- default: false
3956
+ async function cleanupDockerObjects(opts) {
3957
+ const exec = opts.exec ?? spawnDocker;
3958
+ const tag = opts.logTag ?? "cleanup";
3959
+ opts.logger.info(`[${tag}] tearing down docker project ${opts.projectName}\u2026`);
3960
+ const ids = await findContainerIds(opts.filters, exec);
3961
+ let rmExit = 0;
3962
+ if (ids.length > 0) {
3963
+ opts.logger.info(`[${tag}] removing containers: ${ids.join(" ")}`);
3964
+ const rmResult = await exec(["rm", "-f", ...ids]);
3965
+ rmExit = rmResult.exitCode;
3966
+ if (rmExit !== 0 && rmResult.stderr.trim()) {
3967
+ opts.logger.info(`[${tag}] ${rmResult.stderr.trim()}`);
4056
3968
  }
4057
- },
4058
- async run({ args }) {
4059
- try {
4060
- const result = await runAddService({
4061
- name: args.name,
4062
- service: args.service,
4063
- yes: args.yes
4064
- });
4065
- process.exit(result.status === "aborted" ? 1 : 0);
4066
- } catch (err) {
4067
- consola9.error(err instanceof Error ? err.message : String(err));
4068
- process.exit(1);
3969
+ } else {
3970
+ opts.logger.info(`[${tag}] no containers found`);
3971
+ }
3972
+ if (opts.network) {
3973
+ const netResult = await exec(["network", "rm", opts.network]);
3974
+ if (netResult.exitCode === 0) {
3975
+ opts.logger.info(`[${tag}] network ${opts.network} removed`);
4069
3976
  }
4070
3977
  }
4071
- });
4072
-
4073
- // src/commands/apply.ts
4074
- import { defineCommand as defineCommand8 } from "citty";
4075
-
4076
- // src/apply/index.ts
4077
- import { existsSync as existsSync6, promises as fs11 } from "fs";
4078
- import { consola as consola11 } from "consola";
4079
-
4080
- // src/config/state.ts
4081
- import { promises as fs9 } from "fs";
4082
- import path11 from "path";
4083
- function buildStateFile(opts) {
4084
- return {
4085
- schemaVersion: CONFIG_SCHEMA_VERSION,
4086
- origin: opts.origin,
4087
- monocerosCliVersion: opts.cliVersion,
4088
- materializedAt: (opts.now ?? /* @__PURE__ */ new Date()).toISOString()
4089
- };
3978
+ opts.logger.info(`[${tag}] docker cleanup done`);
3979
+ return { exitCode: rmExit, removedIds: ids };
4090
3980
  }
4091
- function stateFilePath(targetDir) {
4092
- return path11.join(targetDir, ".monoceros", "state.json");
3981
+ function composeProjectName(root) {
3982
+ return `${path11.basename(root)}_devcontainer`;
4093
3983
  }
4094
- async function readStateFile(targetDir) {
4095
- try {
4096
- const content = await fs9.readFile(stateFilePath(targetDir), "utf8");
4097
- return JSON.parse(content);
4098
- } catch {
4099
- return void 0;
3984
+ function resolveCompose(root) {
3985
+ if (!existsSync5(path11.join(root, ".devcontainer"))) {
3986
+ throw new Error(
3987
+ `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
3988
+ );
3989
+ }
3990
+ const composeFile = path11.join(root, ".devcontainer", "compose.yaml");
3991
+ if (!existsSync5(composeFile)) {
3992
+ throw new Error(
3993
+ `No compose.yaml at ${composeFile}. \`start\` / \`stop\` / \`status\` / \`logs\` require services configured via \`monoceros add-service <name> <svc>\`. Use \`monoceros shell <name>\` to enter the container directly.`
3994
+ );
4100
3995
  }
3996
+ return { composeFile, projectName: composeProjectName(root) };
4101
3997
  }
4102
- async function writeStateFile(targetDir, state) {
4103
- const monocerosDir = path11.join(targetDir, ".monoceros");
4104
- await fs9.mkdir(monocerosDir, { recursive: true });
4105
- await fs9.writeFile(
4106
- stateFilePath(targetDir),
4107
- JSON.stringify(state, null, 2) + "\n"
3998
+ async function runComposeAction(buildSubArgs, opts) {
3999
+ const { composeFile, projectName } = resolveCompose(opts.root);
4000
+ const spawnFn = opts.spawn ?? spawnDockerCompose;
4001
+ const subArgs = buildSubArgs(opts.service);
4002
+ return spawnFn(["-f", composeFile, "-p", projectName, ...subArgs], opts.root);
4003
+ }
4004
+ async function runStart(opts) {
4005
+ resolveCompose(opts.root);
4006
+ const logger = opts.logger ?? { info: (msg) => consola9.info(msg) };
4007
+ const spawnFn = opts.spawn ?? spawnDevcontainer;
4008
+ logger.info(`Bringing devcontainer up at ${opts.root}\u2026`);
4009
+ return spawnFn(
4010
+ ["up", "--workspace-folder", opts.root, "--mount-workspace-git-root=false"],
4011
+ opts.root
4108
4012
  );
4109
4013
  }
4110
-
4111
- // src/config/transform.ts
4112
- function solutionConfigToCreateOptions(config, featureDefaults = {}) {
4113
- const featureRecord = {};
4114
- for (const entry2 of config.features) {
4115
- const defaults = featureDefaults[entry2.ref] ?? {};
4116
- const containerOpts = Object.fromEntries(
4117
- Object.entries(entry2.options ?? {}).filter(([, v]) => v !== "")
4014
+ async function runContainerCycle(root, opts) {
4015
+ const { hasCompose, logger } = opts;
4016
+ if (hasCompose) {
4017
+ const projectName = composeProjectName(root);
4018
+ logger.info(
4019
+ `Force-removing existing ${projectName} containers (volumes preserved)\u2026`
4118
4020
  );
4119
- featureRecord[entry2.ref] = { ...defaults, ...containerOpts };
4120
- }
4121
- const result = {
4122
- name: config.name,
4123
- languages: [...config.languages],
4124
- services: [...config.services]
4125
- };
4126
- if (config.externalServices.postgres !== void 0) {
4127
- result.postgresUrl = config.externalServices.postgres;
4128
- }
4129
- if (config.aptPackages.length > 0) {
4130
- result.aptPackages = [...config.aptPackages];
4131
- }
4132
- if (Object.keys(featureRecord).length > 0) {
4133
- result.features = featureRecord;
4134
- }
4135
- if (config.installUrls.length > 0) {
4136
- result.installUrls = [...config.installUrls];
4137
- }
4138
- if (config.repos.length > 0) {
4139
- result.repos = config.repos.map((r) => ({
4140
- url: r.url,
4141
- // `path` is optional in the yml; CreateOptions requires it.
4142
- // When the yml omits `path`, fall back to the URL-derived
4143
- // single-segment default (`https://.../foo.git` → `foo`),
4144
- // which lands the clone at `projects/foo/`.
4145
- path: r.path ?? deriveRepoName(r.url),
4146
- // gitUser is forwarded only when BOTH name + email are set.
4147
- // The relaxed GitUserSchema accepts nullable / empty strings
4148
- // (so a yml placeholder `name:` parses without error), so we
4149
- // re-check here before downstream code, which expects both
4150
- // values to be non-empty.
4151
- ...r.git?.user?.name && r.git.user.email ? { gitUser: { name: r.git.user.name, email: r.git.user.email } } : {},
4152
- ...r.provider ? { provider: r.provider } : {}
4153
- }));
4154
- }
4155
- const routingPorts = config.routing?.ports ?? [];
4156
- if (routingPorts.length > 0) {
4157
- const seen = /* @__PURE__ */ new Set();
4158
- const ports = [];
4159
- for (const entry2 of routingPorts) {
4160
- const n = portNumber(entry2);
4161
- if (seen.has(n)) continue;
4162
- seen.add(n);
4163
- ports.push(n);
4021
+ const exec = opts.dockerExec ?? spawnDocker;
4022
+ const filters = [
4023
+ `label=com.docker.compose.project=${projectName}`,
4024
+ `name=^${projectName}-`
4025
+ ];
4026
+ const { exitCode: rmExit } = await cleanupDockerObjects({
4027
+ projectName,
4028
+ filters,
4029
+ network: `${projectName}_default`,
4030
+ logger,
4031
+ exec
4032
+ });
4033
+ if (rmExit !== 0) return rmExit;
4034
+ const remaining = await findContainerIds(filters, exec);
4035
+ if (remaining.length > 0) {
4036
+ const warn = logger.warn ?? logger.info;
4037
+ warn(
4038
+ `ERROR: containers under project ${projectName} reappeared after removal.
4039
+ This typically means VS Code's Remote Containers extension is connected
4040
+ to this devcontainer and auto-recreated it. Close the dev container
4041
+ session in VS Code (Cmd+Shift+P \u2192 'Dev Containers: Close Remote Connection')
4042
+ and retry \`monoceros apply\`.`
4043
+ );
4044
+ return 1;
4164
4045
  }
4165
- result.ports = ports;
4166
- }
4167
- if (config.routing?.vscodeAutoForward !== void 0) {
4168
- result.vscodeAutoForward = config.routing.vscodeAutoForward;
4046
+ return runStart({
4047
+ root,
4048
+ ...opts.devcontainerSpawn ? { spawn: opts.devcontainerSpawn } : {},
4049
+ logger
4050
+ });
4169
4051
  }
4170
- return result;
4052
+ logger.info(`Recreating image-mode devcontainer at ${root}\u2026`);
4053
+ const spawnFn = opts.devcontainerSpawn ?? spawnDevcontainer;
4054
+ return spawnFn(
4055
+ [
4056
+ "up",
4057
+ "--workspace-folder",
4058
+ root,
4059
+ "--mount-workspace-git-root=false",
4060
+ "--remove-existing-container"
4061
+ ],
4062
+ root
4063
+ );
4064
+ }
4065
+ function runStop(opts) {
4066
+ return runComposeAction(
4067
+ (service) => ["stop", ...service ? [service] : []],
4068
+ opts
4069
+ );
4070
+ }
4071
+ function runStatus(opts) {
4072
+ return runComposeAction(
4073
+ (service) => ["ps", ...service ? [service] : []],
4074
+ opts
4075
+ );
4076
+ }
4077
+ function runLogs(opts) {
4078
+ const follow = opts.follow ?? true;
4079
+ return runComposeAction(
4080
+ (service) => [
4081
+ "logs",
4082
+ ...follow ? ["-f"] : [],
4083
+ ...service ? [service] : []
4084
+ ],
4085
+ opts
4086
+ );
4171
4087
  }
4172
4088
 
4173
4089
  // src/devcontainer/repo-reachability.ts
@@ -4269,7 +4185,7 @@ function formatUnreachableReposError(failures) {
4269
4185
  }
4270
4186
  lines.push("");
4271
4187
  }
4272
- lines.push(`Then re-run ${cyan("monoceros apply")}.`);
4188
+ lines.push(`Then re-run ${cyan2("monoceros apply")}.`);
4273
4189
  return lines.join("\n");
4274
4190
  }
4275
4191
  function headerForKind(kind) {
@@ -4352,14 +4268,14 @@ function formatRootlessNotSupportedError() {
4352
4268
  ``,
4353
4269
  `To fix, switch back to standard rootful Docker:`,
4354
4270
  ``,
4355
- cyan(
4271
+ cyan2(
4356
4272
  ` systemctl --user stop docker.service docker.socket 2>/dev/null || true`
4357
4273
  ),
4358
- cyan(` dockerd-rootless-setuptool.sh uninstall`),
4359
- cyan(` rootlesskit rm -rf ~/.local/share/docker`),
4360
- cyan(` unset DOCKER_HOST DOCKER_CONTEXT`),
4361
- cyan(` sudo systemctl enable --now docker`),
4362
- cyan(` sudo usermod -aG docker $USER`),
4274
+ cyan2(` dockerd-rootless-setuptool.sh uninstall`),
4275
+ cyan2(` rootlesskit rm -rf ~/.local/share/docker`),
4276
+ cyan2(` unset DOCKER_HOST DOCKER_CONTEXT`),
4277
+ cyan2(` sudo systemctl enable --now docker`),
4278
+ cyan2(` sudo usermod -aG docker $USER`),
4363
4279
  ``,
4364
4280
  `If you added DOCKER_HOST or DOCKER_CONTEXT to ~/.bashrc /`,
4365
4281
  `~/.profile (the rootless setup may have suggested it), remove`,
@@ -4664,7 +4580,7 @@ ${sectionLine(label)}
4664
4580
  section("Container");
4665
4581
  const featureRefs = parsed.config.features.map((f) => f.ref);
4666
4582
  if (featureRefs.length > 0) {
4667
- logger.info(`Features: ${featureRefs.map((r) => cyan(r)).join(", ")}`);
4583
+ logger.info(`Features: ${featureRefs.map((r) => cyan2(r)).join(", ")}`);
4668
4584
  }
4669
4585
  logger.info(
4670
4586
  dim(
@@ -4687,14 +4603,8 @@ ${sectionLine(label)}
4687
4603
  hostPort: proxyHostPort(globalConfig),
4688
4604
  logger
4689
4605
  });
4690
- await kickProxyReload({
4691
- ...opts.proxyDocker ? { docker: opts.proxyDocker } : {}
4692
- });
4693
4606
  } else {
4694
4607
  await removeDynamicConfig(opts.name, { monocerosHome: home });
4695
- await kickProxyReload({
4696
- ...opts.proxyDocker ? { docker: opts.proxyDocker } : {}
4697
- });
4698
4608
  }
4699
4609
  } catch (err) {
4700
4610
  logger.warn?.(
@@ -4709,7 +4619,7 @@ ${sectionLine(label)}
4709
4619
  });
4710
4620
  if (exitCode === 0) {
4711
4621
  section("Next steps");
4712
- logger.info(` ${cyan(`monoceros shell ${opts.name}`)}`);
4622
+ logger.info(` ${cyan2(`monoceros shell ${opts.name}`)}`);
4713
4623
  }
4714
4624
  return { targetDir, configPath: ymlPath, containerExitCode: exitCode };
4715
4625
  }
@@ -4807,7 +4717,7 @@ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
4807
4717
  }
4808
4718
 
4809
4719
  // src/version.ts
4810
- var CLI_VERSION = true ? "1.11.10" : "dev";
4720
+ var CLI_VERSION = true ? "1.12.0" : "dev";
4811
4721
 
4812
4722
  // src/commands/_dispatch.ts
4813
4723
  import { consola as consola12 } from "consola";
@@ -6269,7 +6179,7 @@ async function runRemove(opts) {
6269
6179
  projectName,
6270
6180
  filters: [
6271
6181
  `label=com.docker.compose.project=${projectName}`,
6272
- `label=devcontainer.local_folder=${dockerLocalFolderLabel(containerPath)}`,
6182
+ `label=devcontainer.local_folder=${containerPath}`,
6273
6183
  `name=^${projectName}-`,
6274
6184
  `name=^vsc-${opts.name}-`
6275
6185
  ],
@@ -6337,9 +6247,6 @@ async function runRemove(opts) {
6337
6247
  }
6338
6248
  try {
6339
6249
  await removeDynamicConfig(opts.name, { monocerosHome: home });
6340
- await kickProxyReload({
6341
- ...opts.proxyDocker ? { docker: opts.proxyDocker } : {}
6342
- });
6343
6250
  } catch (err) {
6344
6251
  logger.warn?.(
6345
6252
  `Could not remove Traefik dynamic config for ${opts.name}: ${err instanceof Error ? err.message : String(err)}. Ignored.`
@@ -7079,10 +6986,7 @@ async function lookupContainerNetwork(args) {
7079
6986
  "ps",
7080
6987
  "-q",
7081
6988
  "--filter",
7082
- // Windows-normalize: devcontainer-cli lowercases the drive letter
7083
- // when stamping the label, docker filter is byte-exact. No-op
7084
- // off Windows.
7085
- `label=devcontainer.local_folder=${dockerLocalFolderLabel(args.containerRoot)}`
6989
+ `label=devcontainer.local_folder=${args.containerRoot}`
7086
6990
  ]);
7087
6991
  if (psResult.exitCode !== 0) {
7088
6992
  throw new Error(
@@ -7404,7 +7308,6 @@ var main = defineCommand30({
7404
7308
 
7405
7309
  // src/bin.ts
7406
7310
  bootstrapDockerGroup();
7407
- bootstrapWslBackend();
7408
7311
  consumeInnerArgsFromProcessArgv();
7409
7312
  async function entry() {
7410
7313
  if (await maybeRenderHelp(process.argv.slice(2), main)) {