@getmonoceros/workbench 1.11.11 → 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 +1231 -1336
  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"], {
@@ -719,20 +663,14 @@ var BREW_INSTALL_COMMAND = '/bin/bash -c "$(curl -fsSL https://raw.githubusercon
719
663
  function installCommandForOS(opts) {
720
664
  const withBrewBootstrap = (cmd) => [
721
665
  "",
722
- cyan(BREW_INSTALL_COMMAND),
723
- cyan(cmd),
666
+ cyan2(BREW_INSTALL_COMMAND),
667
+ cyan2(cmd),
724
668
  "",
725
669
  dim("(Skip the first line if you already have Homebrew.)")
726
670
  ].join("\n");
727
- switch (process.platform) {
728
- case "darwin":
729
- return withBrewBootstrap(opts.brew);
730
- case "win32":
731
- return ["", cyan(opts.winget)].join("\n");
732
- default:
733
- if (opts.linuxBrew) return withBrewBootstrap(opts.linuxBrew);
734
- return `See ${opts.linuxDocsUrl} for package instructions.`;
735
- }
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.`;
736
674
  }
737
675
  function providerSetupHint(host, provider) {
738
676
  if (provider === "github") {
@@ -740,7 +678,6 @@ function providerSetupHint(host, provider) {
740
678
  const hostArg = isSaas ? "" : ` --hostname ${host}`;
741
679
  const install = installCommandForOS({
742
680
  brew: "brew install gh",
743
- winget: "winget install --id GitHub.cli",
744
681
  linuxBrew: "brew install gh",
745
682
  linuxDocsUrl: "https://github.com/cli/cli#installation"
746
683
  });
@@ -751,8 +688,8 @@ function providerSetupHint(host, provider) {
751
688
  install,
752
689
  "",
753
690
  "Then run once:",
754
- cyan(`gh auth login${hostArg}`),
755
- cyan(`gh auth setup-git${hostArg}`),
691
+ cyan2(`gh auth login${hostArg}`),
692
+ cyan2(`gh auth setup-git${hostArg}`),
756
693
  "",
757
694
  "`gh auth login` walks through OAuth in your browser.",
758
695
  "`gh auth setup-git` wires gh into git as a credential helper."
@@ -764,7 +701,6 @@ function providerSetupHint(host, provider) {
764
701
  const hostArg = isSaas ? "" : ` --hostname ${host}`;
765
702
  const install = installCommandForOS({
766
703
  brew: "brew install glab",
767
- winget: "winget install --id GLab.GLab",
768
704
  linuxBrew: "brew install glab",
769
705
  linuxDocsUrl: "https://gitlab.com/gitlab-org/cli#installation"
770
706
  });
@@ -775,7 +711,7 @@ function providerSetupHint(host, provider) {
775
711
  install,
776
712
  "",
777
713
  "Then run once:",
778
- cyan(`glab auth login${hostArg}`),
714
+ cyan2(`glab auth login${hostArg}`),
779
715
  "",
780
716
  "Choose `HTTPS` when asked for git-protocol, then accept",
781
717
  '"Authenticate Git with your GitLab credentials" \u2014 glab',
@@ -794,7 +730,7 @@ function providerSetupHint(host, provider) {
794
730
  "https://id.atlassian.com/manage-profile/security/api-tokens",
795
731
  "",
796
732
  "Then store it via your OS credential helper:",
797
- cyan(
733
+ cyan2(
798
734
  `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-atlassian-email>\\npassword=<token>\\n'`
799
735
  )
800
736
  ].join("\n")
@@ -810,7 +746,7 @@ function providerSetupHint(host, provider) {
810
746
  "at least repo-read + repo-write scopes for the repos you need.",
811
747
  "",
812
748
  "Then store it via your OS credential helper:",
813
- cyan(
749
+ cyan2(
814
750
  `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-bitbucket-username>\\npassword=<token>\\n'`
815
751
  )
816
752
  ].join("\n")
@@ -828,7 +764,7 @@ function providerSetupHint(host, provider) {
828
764
  "need push from the container).",
829
765
  "",
830
766
  "Then store it via your OS credential helper:",
831
- cyan(
767
+ cyan2(
832
768
  `git credential approve <<< $'protocol=https\\nhost=${host}\\nusername=<your-gitea-username>\\npassword=<token>\\n'`
833
769
  )
834
770
  ].join("\n")
@@ -941,7 +877,7 @@ function formatMissingCredentialsError(missing) {
941
877
  "",
942
878
  hint.body,
943
879
  "",
944
- `Then re-run ${cyan("monoceros apply")}.`
880
+ `Then re-run ${cyan2("monoceros apply")}.`
945
881
  ].join("\n");
946
882
  }
947
883
  const lines = [
@@ -955,7 +891,7 @@ function formatMissingCredentialsError(missing) {
955
891
  lines.push(hint.body);
956
892
  lines.push("");
957
893
  }
958
- lines.push(`Then re-run ${cyan("monoceros apply")}.`);
894
+ lines.push(`Then re-run ${cyan2("monoceros apply")}.`);
959
895
  return lines.join("\n");
960
896
  }
961
897
  function formatUnknownProviderError(hosts) {
@@ -967,32 +903,18 @@ function formatUnknownProviderError(hosts) {
967
903
  "For any other host (self-hosted GitLab, Gitea, Bitbucket Server, \u2026)",
968
904
  "declare the provider explicitly in the yml. Edit the repo entry:",
969
905
  "",
970
- cyan(" repos:"),
971
- cyan(` - url: https://${sorted[0]}/\u2026`),
972
- 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"),
973
909
  "",
974
- `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>")}.`
975
911
  ];
976
912
  return lines.join("\n");
977
913
  }
978
914
 
979
915
  // src/devcontainer/locate-running.ts
980
- import { spawn as spawn5 } from "child_process";
981
-
982
- // src/devcontainer/compose.ts
983
- import { spawn as spawn4 } from "child_process";
984
- import { existsSync as existsSync2 } from "fs";
985
- import path5 from "path";
986
- import { consola } from "consola";
987
-
988
- // src/proxy/index.ts
989
916
  import { spawn as spawn2 } from "child_process";
990
- import { promises as fs3 } from "fs";
991
- import path3 from "path";
992
- var PROXY_CONTAINER_NAME = "monoceros-proxy";
993
- var PROXY_NETWORK_NAME = "monoceros-proxy";
994
- var TRAEFIK_IMAGE = "traefik:v3.3";
995
- var defaultDockerExec = (args) => {
917
+ var realDockerLookup = (args) => {
996
918
  return new Promise((resolve, reject) => {
997
919
  const child = spawn2("docker", args, {
998
920
  stdio: ["ignore", "pipe", "pipe"]
@@ -1012,988 +934,638 @@ var defaultDockerExec = (args) => {
1012
934
  );
1013
935
  });
1014
936
  };
1015
- var realDocker = defaultDockerExec;
1016
- function proxyDynamicDir(home) {
1017
- return path3.join(home ?? monocerosHome(), "traefik", "dynamic");
1018
- }
1019
- async function kickProxyReload(opts = {}) {
1020
- if (process.platform !== "win32") return;
1021
- const docker = opts.docker ?? realDocker;
1022
- const inspect = await docker([
1023
- "inspect",
1024
- "--format",
1025
- "{{.State.Running}}",
1026
- PROXY_CONTAINER_NAME
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"
1027
946
  ]);
1028
- if (inspect.exitCode !== 0) return;
1029
- if (inspect.stdout.trim() !== "true") return;
1030
- 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;
1031
950
  }
1032
- async function ensureProxy(opts = {}) {
1033
- const docker = opts.docker ?? realDocker;
1034
- const dyn = proxyDynamicDir(opts.monocerosHome);
1035
- await fs3.mkdir(dyn, { recursive: true });
1036
- const netInspect = await docker(["network", "inspect", PROXY_NETWORK_NAME]);
1037
- if (netInspect.exitCode !== 0) {
1038
- const create = await docker(["network", "create", PROXY_NETWORK_NAME]);
1039
- if (create.exitCode !== 0) {
1040
- throw new Error(
1041
- `Could not create docker network ${PROXY_NETWORK_NAME}: ${create.stderr.trim() || `exit ${create.exitCode}`}`
1042
- );
1043
- }
1044
- }
1045
- const state = await docker([
1046
- "inspect",
1047
- "--format",
1048
- "{{.State.Running}}",
1049
- PROXY_CONTAINER_NAME
1050
- ]);
1051
- if (state.exitCode === 0) {
1052
- if (state.stdout.trim() === "true") return;
1053
- const start = await docker(["start", PROXY_CONTAINER_NAME]);
1054
- if (start.exitCode !== 0) {
1055
- throw new Error(
1056
- `Could not start existing ${PROXY_CONTAINER_NAME} container: ${start.stderr.trim() || `exit ${start.exitCode}`}`
1057
- );
1058
- }
1059
- return;
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;
1060
1009
  }
1061
- const hostPort = opts.hostPort ?? 80;
1062
- const run = await docker([
1063
- "run",
1064
- "-d",
1065
- "--name",
1066
- PROXY_CONTAINER_NAME,
1067
- "--network",
1068
- PROXY_NETWORK_NAME,
1069
- "-p",
1070
- `${hostPort}:80`,
1071
- "-v",
1072
- `${dyn}:/etc/traefik/dynamic:ro`,
1073
- "--label",
1074
- "monoceros.role=proxy",
1075
- TRAEFIK_IMAGE,
1076
- "--entrypoints.web.address=:80",
1077
- "--providers.file.directory=/etc/traefik/dynamic",
1078
- "--providers.file.watch=true",
1079
- "--providers.docker=false",
1080
- "--api.dashboard=false",
1081
- "--log.level=INFO"
1082
- ]);
1083
- if (run.exitCode !== 0) {
1010
+ const doc = parseDocument2(text, { prettyErrors: true });
1011
+ if (doc.errors.length > 0) {
1084
1012
  throw new Error(
1085
- `Could not start ${PROXY_CONTAINER_NAME}: ${run.stderr.trim() || `exit ${run.exitCode}`}`
1013
+ `yaml parse error in ${filePath}: ${doc.errors[0].message}`
1086
1014
  );
1087
1015
  }
1088
- opts.logger?.info(
1089
- `Started ${PROXY_CONTAINER_NAME} (Traefik on :${hostPort}).`
1090
- );
1091
- }
1092
- async function maybeStopProxy(opts = {}) {
1093
- const docker = opts.docker ?? realDocker;
1094
- const logger = opts.logger;
1095
- const inspect = await docker([
1096
- "network",
1097
- "inspect",
1098
- PROXY_NETWORK_NAME,
1099
- "--format",
1100
- "{{range $k, $v := .Containers}}{{$v.Name}}\n{{end}}"
1101
- ]);
1102
- if (inspect.exitCode !== 0) {
1103
- return;
1104
- }
1105
- const others = inspect.stdout.split("\n").map((n) => n.trim()).filter((n) => n.length > 0 && n !== PROXY_CONTAINER_NAME);
1106
- if (others.length > 0) return;
1107
- await docker(["rm", "-f", PROXY_CONTAINER_NAME]);
1108
- const netRm = await docker(["network", "rm", PROXY_NETWORK_NAME]);
1109
- if (netRm.exitCode !== 0) {
1110
- logger?.warn?.(
1111
- `Could not remove docker network ${PROXY_NETWORK_NAME}: ${netRm.stderr.trim() || `exit ${netRm.exitCode}`}`
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.`
1112
1030
  );
1113
- return;
1114
1031
  }
1115
- logger?.info(
1116
- `Stopped ${PROXY_CONTAINER_NAME} (no dev-containers with ports left).`
1117
- );
1032
+ return result.data;
1118
1033
  }
1119
-
1120
- // src/util/mask-secrets.ts
1121
- import { Transform } from "stream";
1122
- var PATTERNS = [
1123
- // Atlassian Cloud API token. Starts with literal `ATATT3xFf` plus
1124
- // a long URL-safe-base64 tail. Tightened to that prefix to avoid
1125
- // matching unrelated all-caps words.
1126
- { name: "atlassian-api", re: /ATATT3xFf[A-Za-z0-9+/=_-]{20,}/g },
1127
- // Bitbucket Cloud app password.
1128
- { name: "bitbucket-app", re: /ATBB[A-Za-z0-9+/=_-]{20,}/g },
1129
- // GitHub PAT (classic), OAuth, user, server, refresh — all share
1130
- // the `gh<lower-letter>_<base62>` shape per GitHub's token format.
1131
- { name: "github-token", re: /gh[a-z]_[A-Za-z0-9]{20,}/g },
1132
- // GitHub fine-grained PAT.
1133
- { name: "github-pat", re: /github_pat_[A-Za-z0-9_]{20,}/g },
1134
- // Anthropic API key.
1135
- { name: "anthropic-api", re: /sk-ant-[A-Za-z0-9_-]{20,}/g }
1136
- ];
1137
- function maskSecrets(text) {
1138
- let result = text;
1139
- for (const { re } of PATTERNS) {
1140
- result = result.replace(re, maskOne);
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;
1141
1046
  }
1142
- 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 };
1143
1084
  }
1144
- function maskOne(token) {
1145
- if (token.length <= 12) return token;
1146
- 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;
1147
1091
  }
1148
- function createSecretMaskStream() {
1149
- let buffer = "";
1150
- return new Transform({
1151
- decodeStrings: true,
1152
- transform(chunk, _enc, cb) {
1153
- const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
1154
- buffer += text;
1155
- const lastNewline = buffer.lastIndexOf("\n");
1156
- if (lastNewline === -1) {
1157
- cb(null);
1158
- return;
1159
- }
1160
- const flushable = buffer.slice(0, lastNewline + 1);
1161
- buffer = buffer.slice(lastNewline + 1);
1162
- cb(null, maskSecrets(flushable));
1163
- },
1164
- flush(cb) {
1165
- if (buffer.length > 0) {
1166
- const tail = maskSecrets(buffer);
1167
- buffer = "";
1168
- cb(null, tail);
1169
- return;
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;
1170
1127
  }
1171
- cb(null);
1172
1128
  }
1173
- });
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;
1174
1136
  }
1175
-
1176
- // src/devcontainer/cli.ts
1177
- import { spawn as spawn3 } from "child_process";
1178
- import { readFileSync } from "fs";
1179
- import { createRequire } from "module";
1180
- import path4 from "path";
1181
-
1182
- // src/devcontainer/runtime-pull-hint.ts
1183
- import { Transform as Transform2 } from "stream";
1184
- var RUNTIME_PULL_MARKER = "No manifest found for ghcr.io/getmonoceros/monoceros-runtime";
1185
- var RUNTIME_PULL_HINT = 'Downloading the Monoceros runtime image now -- expected on first apply, takes ~1-2 min (Docker pulls the multi-arch base with no progress output). The "No manifest found" line above is harmless. Please wait...';
1186
- function createRuntimePullHintStream(state) {
1187
- let buffer = "";
1188
- const appendHintIfMarker = (block) => {
1189
- if (state.hinted || !block.includes(RUNTIME_PULL_MARKER)) return block;
1190
- state.hinted = true;
1191
- return `${block}${dim(`(i) ${RUNTIME_PULL_HINT}`)}
1192
- `;
1193
- };
1194
- return new Transform2({
1195
- decodeStrings: true,
1196
- transform(chunk, _enc, cb) {
1197
- const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
1198
- buffer += text;
1199
- const lastNewline = buffer.lastIndexOf("\n");
1200
- if (lastNewline === -1) {
1201
- cb(null);
1202
- return;
1203
- }
1204
- const flushable = buffer.slice(0, lastNewline + 1);
1205
- buffer = buffer.slice(lastNewline + 1);
1206
- cb(null, appendHintIfMarker(flushable));
1207
- },
1208
- flush(cb) {
1209
- if (buffer.length === 0) {
1210
- cb(null);
1211
- return;
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++;
1212
1148
  }
1213
- const tail = buffer;
1214
- buffer = "";
1215
- cb(null, appendHintIfMarker(tail));
1149
+ continue;
1216
1150
  }
1217
- });
1218
- }
1219
-
1220
- // src/devcontainer/cli.ts
1221
- var require_ = createRequire(import.meta.url);
1222
- var cachedBinaryPath = null;
1223
- function devcontainerCliPath() {
1224
- if (cachedBinaryPath) return cachedBinaryPath;
1225
- const pkgJsonPath = require_.resolve("@devcontainers/cli/package.json");
1226
- const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
1227
- const binEntry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.devcontainer ?? "";
1228
- if (!binEntry) {
1229
- throw new Error("Could not resolve @devcontainers/cli bin entry.");
1151
+ out.push(line);
1152
+ i++;
1230
1153
  }
1231
- cachedBinaryPath = path4.resolve(path4.dirname(pkgJsonPath), binEntry);
1232
- return cachedBinaryPath;
1154
+ return out.join("\n");
1233
1155
  }
1234
- var spawnDevcontainer = (args, cwd, options = {}) => {
1235
- const binPath = devcontainerCliPath();
1236
- return new Promise((resolve, reject) => {
1237
- if (options.interactive) {
1238
- const child2 = spawn3(process.execPath, [binPath, ...args], {
1239
- cwd,
1240
- stdio: "inherit"
1241
- });
1242
- child2.on("error", reject);
1243
- child2.on("exit", (code) => resolve(code ?? 0));
1244
- return;
1245
- }
1246
- const child = spawn3(process.execPath, [binPath, ...args], {
1247
- cwd,
1248
- stdio: ["ignore", "pipe", "pipe"]
1249
- });
1250
- if (options.quiet) {
1251
- const stdoutChunks = [];
1252
- const stderrChunks = [];
1253
- child.stdout?.on("data", (chunk) => stdoutChunks.push(chunk));
1254
- child.stderr?.on("data", (chunk) => stderrChunks.push(chunk));
1255
- child.on("error", reject);
1256
- child.on("exit", (code) => {
1257
- const exitCode = code ?? 0;
1258
- if (exitCode !== 0) {
1259
- process.stderr.write(
1260
- maskSecrets(Buffer.concat(stderrChunks).toString("utf8"))
1261
- );
1262
- process.stderr.write(
1263
- maskSecrets(Buffer.concat(stdoutChunks).toString("utf8"))
1264
- );
1265
- }
1266
- resolve(exitCode);
1267
- });
1268
- return;
1269
- }
1270
- const pullHint = { hinted: false };
1271
- child.stdout?.pipe(createSecretMaskStream()).pipe(createRuntimePullHintStream(pullHint)).pipe(process.stdout);
1272
- child.stderr?.pipe(createSecretMaskStream()).pipe(createRuntimePullHintStream(pullHint)).pipe(process.stderr);
1273
- child.on("error", reject);
1274
- child.on("exit", (code) => resolve(code ?? 0));
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;
1275
1165
  });
1276
- };
1277
-
1278
- // src/devcontainer/compose.ts
1279
- var spawnDockerCompose = (args, cwd) => {
1280
- return new Promise((resolve, reject) => {
1281
- const child = spawn4("docker", ["compose", ...args], {
1282
- cwd,
1283
- stdio: ["inherit", "pipe", "pipe"]
1284
- });
1285
- child.stdout?.pipe(createSecretMaskStream()).pipe(process.stdout);
1286
- child.stderr?.pipe(createSecretMaskStream()).pipe(process.stderr);
1287
- child.on("error", reject);
1288
- child.on("exit", (code) => resolve(code ?? 0));
1289
- });
1290
- };
1291
- var spawnDocker = (args) => {
1292
- return new Promise((resolve, reject) => {
1293
- const child = spawn4("docker", args, {
1294
- stdio: ["ignore", "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"
1295
1218
  });
1296
- let stdout = "";
1297
- let stderr = "";
1298
- child.stdout?.on("data", (chunk) => {
1299
- stdout += chunk.toString("utf8");
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(", ")}`
1300
1225
  });
1301
- child.stderr?.on("data", (chunk) => {
1302
- stderr += chunk.toString("utf8");
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]}`
1303
1233
  });
1304
- child.on("error", reject);
1305
- child.on(
1306
- "exit",
1307
- (code) => resolve({ exitCode: code ?? 0, stdout, stderr })
1308
- );
1309
- });
1310
- };
1311
- async function findContainerIds(filters, exec = spawnDocker) {
1312
- const ids = /* @__PURE__ */ new Set();
1313
- for (const filter of filters) {
1314
- const result = await exec(["ps", "-aq", "--filter", filter]);
1315
- if (result.exitCode !== 0) continue;
1316
- for (const line of result.stdout.split(/\r?\n/)) {
1317
- const id = line.trim();
1318
- if (id) ids.add(id);
1319
- }
1320
1234
  }
1321
- return [...ids];
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;
1322
1243
  }
1323
- async function cleanupDockerObjects(opts) {
1324
- const exec = opts.exec ?? spawnDocker;
1325
- const tag = opts.logTag ?? "cleanup";
1326
- opts.logger.info(`[${tag}] tearing down docker project ${opts.projectName}\u2026`);
1327
- const ids = await findContainerIds(opts.filters, exec);
1328
- let rmExit = 0;
1329
- if (ids.length > 0) {
1330
- opts.logger.info(`[${tag}] removing containers: ${ids.join(" ")}`);
1331
- const rmResult = await exec(["rm", "-f", ...ids]);
1332
- rmExit = rmResult.exitCode;
1333
- if (rmExit !== 0 && rmResult.stderr.trim()) {
1334
- opts.logger.info(`[${tag}] ${rmResult.stderr.trim()}`);
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;
1335
1251
  }
1336
- } else {
1337
- opts.logger.info(`[${tag}] no containers found`);
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 });
1338
1274
  }
1339
- if (opts.network) {
1340
- const netResult = await exec(["network", "rm", opts.network]);
1341
- if (netResult.exitCode === 0) {
1342
- opts.logger.info(`[${tag}] network ${opts.network} removed`);
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 ?? {});
1343
1301
  }
1344
1302
  }
1345
- opts.logger.info(`[${tag}] docker cleanup done`);
1346
- return { exitCode: rmExit, removedIds: ids };
1303
+ return {
1304
+ languages,
1305
+ services,
1306
+ features: [...featureByRef.values()]
1307
+ };
1347
1308
  }
1348
- function dockerLocalFolderLabel(p) {
1349
- if (process.platform !== "win32") return p;
1350
- return p.replace(
1351
- /^([A-Z]):/,
1352
- (_, drive) => `${drive.toLowerCase()}:`
1353
- );
1309
+ function isResolvedComponent(x) {
1310
+ return "component" in x;
1354
1311
  }
1355
- function composeProjectName(root) {
1356
- return `${path5.basename(root)}_devcontainer`;
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;
1357
1323
  }
1358
- function resolveCompose(root) {
1359
- if (!existsSync2(path5.join(root, ".devcontainer"))) {
1360
- throw new Error(
1361
- `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
1362
- );
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 } : {} });
1363
1342
  }
1364
- const composeFile = path5.join(root, ".devcontainer", "compose.yaml");
1365
- if (!existsSync2(composeFile)) {
1343
+ if (unknown.length > 0) {
1344
+ const available = [...catalog.keys()].sort();
1366
1345
  throw new Error(
1367
- `No compose.yaml at ${composeFile}. \`start\` / \`stop\` / \`status\` / \`logs\` require services configured via \`monoceros add-service <name> <svc>\`. Use \`monoceros shell <name>\` to enter the container directly.`
1346
+ `Unknown component${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.
1347
+ Available: ${available.join(", ")}.`
1368
1348
  );
1369
1349
  }
1370
- return { composeFile, projectName: composeProjectName(root) };
1350
+ return out;
1371
1351
  }
1372
- async function runComposeAction(buildSubArgs, opts) {
1373
- const { composeFile, projectName } = resolveCompose(opts.root);
1374
- const spawnFn = opts.spawn ?? spawnDockerCompose;
1375
- const subArgs = buildSubArgs(opts.service);
1376
- return spawnFn(["-f", composeFile, "-p", projectName, ...subArgs], opts.root);
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) => {
1361
+ return new Promise((resolve, reject) => {
1362
+ const child = spawn3("docker", args, {
1363
+ stdio: ["ignore", "pipe", "pipe"]
1364
+ });
1365
+ let stdout = "";
1366
+ let stderr = "";
1367
+ child.stdout.on("data", (chunk) => {
1368
+ stdout += chunk.toString();
1369
+ });
1370
+ child.stderr.on("data", (chunk) => {
1371
+ stderr += chunk.toString();
1372
+ });
1373
+ child.on("error", reject);
1374
+ child.on(
1375
+ "exit",
1376
+ (code) => resolve({ stdout, stderr, exitCode: code ?? 0 })
1377
+ );
1378
+ });
1379
+ };
1380
+ var realDocker = defaultDockerExec;
1381
+ function proxyDynamicDir(home) {
1382
+ return path4.join(home ?? monocerosHome(), "traefik", "dynamic");
1377
1383
  }
1378
- async function runStart(opts) {
1379
- resolveCompose(opts.root);
1380
- const logger = opts.logger ?? { info: (msg) => consola.info(msg) };
1381
- const spawnFn = opts.spawn ?? spawnDevcontainer;
1382
- logger.info(`Bringing devcontainer up at ${opts.root}\u2026`);
1383
- return spawnFn(
1384
- ["up", "--workspace-folder", opts.root, "--mount-workspace-git-root=false"],
1385
- opts.root
1386
- );
1387
- }
1388
- async function runContainerCycle(root, opts) {
1389
- const { hasCompose, logger } = opts;
1390
- if (hasCompose) {
1391
- const projectName = composeProjectName(root);
1392
- logger.info(
1393
- `Force-removing existing ${projectName} containers (volumes preserved)\u2026`
1394
- );
1395
- const exec = opts.dockerExec ?? spawnDocker;
1396
- const filters = [
1397
- `label=com.docker.compose.project=${projectName}`,
1398
- `name=^${projectName}-`
1399
- ];
1400
- const { exitCode: rmExit } = await cleanupDockerObjects({
1401
- projectName,
1402
- filters,
1403
- network: `${projectName}_default`,
1404
- logger,
1405
- exec
1406
- });
1407
- if (rmExit !== 0) return rmExit;
1408
- const remaining = await findContainerIds(filters, exec);
1409
- if (remaining.length > 0) {
1410
- const warn = logger.warn ?? logger.info;
1411
- warn(
1412
- `ERROR: containers under project ${projectName} reappeared after removal.
1413
- This typically means VS Code's Remote Containers extension is connected
1414
- to this devcontainer and auto-recreated it. Close the dev container
1415
- session in VS Code (Cmd+Shift+P \u2192 'Dev Containers: Close Remote Connection')
1416
- and retry \`monoceros apply\`.`
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}`}`
1417
1394
  );
1418
- return 1;
1419
1395
  }
1420
- return runStart({
1421
- root,
1422
- ...opts.devcontainerSpawn ? { spawn: opts.devcontainerSpawn } : {},
1423
- logger
1424
- });
1425
1396
  }
1426
- logger.info(`Recreating image-mode devcontainer at ${root}\u2026`);
1427
- const spawnFn = opts.devcontainerSpawn ?? spawnDevcontainer;
1428
- return spawnFn(
1429
- [
1430
- "up",
1431
- "--workspace-folder",
1432
- root,
1433
- "--mount-workspace-git-root=false",
1434
- "--remove-existing-container"
1435
- ],
1436
- root
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
+ );
1410
+ }
1411
+ return;
1412
+ }
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
+ );
1439
+ }
1440
+ opts.logger?.info(
1441
+ `Started ${PROXY_CONTAINER_NAME} (Traefik on :${hostPort}).`
1437
1442
  );
1438
1443
  }
1439
- function runStop(opts) {
1440
- return runComposeAction(
1441
- (service) => ["stop", ...service ? [service] : []],
1442
- opts
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;
1456
+ }
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}`}`
1464
+ );
1465
+ return;
1466
+ }
1467
+ logger?.info(
1468
+ `Stopped ${PROXY_CONTAINER_NAME} (no dev-containers with ports left).`
1443
1469
  );
1444
1470
  }
1445
- function runStatus(opts) {
1446
- return runComposeAction(
1447
- (service) => ["ps", ...service ? [service] : []],
1448
- opts
1449
- );
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)}).`
1479
+ );
1480
+ }
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;
1450
1486
  }
1451
- function runLogs(opts) {
1452
- const follow = opts.follow ?? true;
1453
- return runComposeAction(
1454
- (service) => [
1455
- "logs",
1456
- ...follow ? ["-f"] : [],
1457
- ...service ? [service] : []
1458
- ],
1459
- opts
1487
+ async function removeDynamicConfig(name, opts = {}) {
1488
+ const file = path5.join(proxyDynamicDir(opts.monocerosHome), `${name}.yml`);
1489
+ await fs6.rm(file, { force: true });
1490
+ }
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"
1460
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";
1522
+ }
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
+ }));
1461
1530
  }
1462
1531
 
1463
- // src/devcontainer/locate-running.ts
1464
- var realDockerLookup = (args) => {
1465
- return new Promise((resolve, reject) => {
1466
- const child = spawn5("docker", args, {
1467
- 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
+ });
1468
1552
  });
1469
- let stdout = "";
1470
- let stderr = "";
1471
- child.stdout.on("data", (chunk) => {
1472
- stdout += chunk.toString();
1553
+ socket.once("timeout", () => {
1554
+ settle({ ok: true });
1473
1555
  });
1474
- child.stderr.on("data", (chunk) => {
1475
- 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
+ }
1476
1567
  });
1477
- child.on("error", reject);
1478
- child.on(
1479
- "exit",
1480
- (code) => resolve({ stdout, stderr, exitCode: code ?? 0 })
1481
- );
1482
- });
1483
- };
1484
- async function findRunningContainerByLocalFolder(containerPath, opts = {}) {
1485
- const docker = opts.docker ?? realDockerLookup;
1486
- const result = await docker([
1487
- "ps",
1488
- "-q",
1489
- "--filter",
1490
- `label=devcontainer.local_folder=${dockerLocalFolderLabel(containerPath)}`,
1491
- "--filter",
1492
- "status=running"
1493
- ]);
1494
- if (result.exitCode !== 0) return null;
1495
- const ids = result.stdout.split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
1496
- return ids[0] ?? null;
1497
- }
1498
- var realContainerExec = (containerId, argv) => {
1499
- return new Promise((resolve, reject) => {
1500
- const child = spawn5("docker", ["exec", containerId, ...argv], {
1501
- // Inherit stdio so live git output reaches the user.
1502
- stdio: ["ignore", "inherit", "inherit"]
1503
- });
1504
- child.on("error", reject);
1505
- child.on("exit", (code) => resolve({ exitCode: code ?? 0 }));
1506
- });
1507
- };
1508
-
1509
- // src/config/global.ts
1510
- import { promises as fs4 } from "fs";
1511
- import { z as z2 } from "zod";
1512
- import { isMap, Pair, parseDocument as parseDocument2, Scalar, YAMLMap } from "yaml";
1513
- var SCHEMA_VERSION = 1;
1514
- var MonocerosConfigSchema = z2.object({
1515
- schemaVersion: z2.literal(SCHEMA_VERSION),
1516
- // .nullish() (= .optional().nullable()) on defaults so the shipped
1517
- // sample yml — where `defaults:` is uncommented but every sub-block
1518
- // is commented out — parses cleanly. YAML produces `defaults: null`
1519
- // in that case; without .nullish() the schema would reject it and
1520
- // we'd be back to forcing builders to comment-juggle three lines.
1521
- defaults: z2.object({
1522
- // .nullish() (not just .optional()) so the sample yml can leave
1523
- // `git:` uncommented as a category marker — YAML produces
1524
- // `git: null` for an empty mapping, which zod's plain
1525
- // `.optional()` would reject.
1526
- git: z2.object({
1527
- user: GitUserSchema.optional()
1528
- }).nullish(),
1529
- // .nullish() for the same reason as `git` — the sample keeps
1530
- // `features:` uncommented as a category marker.
1531
- features: z2.record(
1532
- z2.string().regex(
1533
- REGEX.featureRef,
1534
- "Invalid feature ref. Expected an OCI-image-style ref like 'ghcr.io/getmonoceros/monoceros-features/<name>:<tag>'."
1535
- ),
1536
- z2.record(z2.string(), FeatureOptionValueSchema)
1537
- ).nullish()
1538
- }).nullish(),
1539
- // Machine-global routing settings — one Traefik per builder, so
1540
- // host-port and similar live here rather than in any container yml.
1541
- // See ADR 0007.
1542
- routing: z2.object({
1543
- hostPort: z2.number().int().min(1).max(65535).optional().describe(
1544
- "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>/."
1545
- )
1546
- }).nullish()
1547
- });
1548
- async function readMonocerosConfig(opts = {}) {
1549
- const home = opts.monocerosHome ?? monocerosHome();
1550
- const filePath = monocerosConfigPath(home);
1551
- let text;
1552
- try {
1553
- text = await fs4.readFile(filePath, "utf8");
1554
- } catch {
1555
- return void 0;
1556
- }
1557
- const doc = parseDocument2(text, { prettyErrors: true });
1558
- if (doc.errors.length > 0) {
1559
- throw new Error(
1560
- `yaml parse error in ${filePath}: ${doc.errors[0].message}`
1561
- );
1562
- }
1563
- const result = MonocerosConfigSchema.safeParse(doc.toJS());
1564
- if (!result.success) {
1565
- const issues = result.error.issues.map((issue) => {
1566
- const where = issue.path.length > 0 ? issue.path.join(".") : "(root)";
1567
- return ` - ${where}: ${issue.message}`;
1568
- }).join("\n");
1569
- throw new Error(
1570
- `Invalid ${filePath}:
1571
- ${issues}
1572
-
1573
- See ${filePath.replace(
1574
- /\.yml$/,
1575
- ".sample.yml"
1576
- )} for a valid example.`
1577
- );
1578
- }
1579
- return result.data;
1580
- }
1581
- var DEFAULT_PROXY_HOST_PORT = 80;
1582
- function proxyHostPort(config) {
1583
- return config?.routing?.hostPort ?? DEFAULT_PROXY_HOST_PORT;
1584
- }
1585
- async function writeGlobalDefaultGitUser(user, opts = {}) {
1586
- const home = opts.monocerosHome ?? monocerosHome();
1587
- const filePath = monocerosConfigPath(home);
1588
- let text;
1589
- try {
1590
- text = await fs4.readFile(filePath, "utf8");
1591
- } catch {
1592
- text = void 0;
1593
- }
1594
- if (text === void 0) {
1595
- const fresh = [
1596
- "# Optional \u2014 global defaults for monoceros containers.",
1597
- "",
1598
- "schemaVersion: 1",
1599
- "",
1600
- "defaults:",
1601
- " git:",
1602
- " user:",
1603
- ` name: ${user.name}`,
1604
- ` email: ${user.email}`,
1605
- ""
1606
- ].join("\n");
1607
- await fs4.mkdir(home, { recursive: true });
1608
- await fs4.writeFile(filePath, fresh, "utf8");
1609
- return { filePath, created: true, alreadySet: false };
1610
- }
1611
- const doc = parseDocument2(text, { prettyErrors: true });
1612
- if (doc.errors.length > 0) {
1613
- throw new Error(
1614
- `yaml parse error in ${filePath}: ${doc.errors[0].message}`
1615
- );
1616
- }
1617
- const defaultsMap = ensureMap(doc, "defaults");
1618
- const gitMap = ensureSubMapAtTop(defaultsMap, "git");
1619
- const userMap = ensureSubMap(gitMap, "user");
1620
- const existingName = userMap.get("name");
1621
- const existingEmail = userMap.get("email");
1622
- if (typeof existingName === "string" && existingName.length > 0 && typeof existingEmail === "string" && existingEmail.length > 0) {
1623
- return { filePath, created: false, alreadySet: true };
1624
- }
1625
- relocateLeakedLeafComments(userMap, defaultsMap, "git");
1626
- userMap.set("name", user.name);
1627
- userMap.set("email", user.email);
1628
- const newText = String(doc);
1629
- await fs4.writeFile(filePath, newText, "utf8");
1630
- return { filePath, created: false, alreadySet: false };
1631
- }
1632
- function ensureMap(doc, key) {
1633
- const node = doc.get(key, true);
1634
- if (node && isMap(node)) return node;
1635
- const m = new YAMLMap();
1636
- doc.set(key, m);
1637
- return m;
1638
- }
1639
- function ensureSubMap(parent, key) {
1640
- const node = parent.get(key, true);
1641
- if (node && isMap(node)) return node;
1642
- const m = new YAMLMap();
1643
- parent.set(key, m);
1644
- return m;
1645
- }
1646
- function ensureSubMapAtTop(parent, key) {
1647
- const node = parent.get(key, true);
1648
- if (node && isMap(node)) return node;
1649
- const parentMaybe = parent;
1650
- const newKey = new Scalar(key);
1651
- if (parent.items.length > 0 && typeof parentMaybe.commentBefore === "string" && parentMaybe.commentBefore.length > 0) {
1652
- const cleaned = stripCommentedKeySkeleton(parentMaybe.commentBefore, key);
1653
- const blankMatch = cleaned.match(/\n[ \t]*\n/);
1654
- let head;
1655
- let tail;
1656
- if (blankMatch && blankMatch.index !== void 0) {
1657
- head = cleaned.slice(0, blankMatch.index);
1658
- tail = cleaned.slice(blankMatch.index + blankMatch[0].length);
1659
- } else {
1660
- head = cleaned;
1661
- tail = "";
1662
- }
1663
- if (head.length > 0) {
1664
- newKey.commentBefore = head;
1665
- if (parentMaybe.spaceBefore) newKey.spaceBefore = true;
1666
- }
1667
- if (tail.length > 0) {
1668
- const firstKey = parent.items[0].key;
1669
- if (firstKey && typeof firstKey === "object") {
1670
- const existing = firstKey.commentBefore ?? "";
1671
- firstKey.commentBefore = existing ? `${tail}
1672
- ${existing}` : tail;
1673
- firstKey.spaceBefore = true;
1674
- }
1675
- }
1676
- parentMaybe.commentBefore = null;
1677
- parentMaybe.spaceBefore = false;
1678
- }
1679
- const m = new YAMLMap();
1680
- const pair = new Pair(newKey, m);
1681
- parent.items.unshift(pair);
1682
- return m;
1683
- }
1684
- function stripCommentedKeySkeleton(commentBody, key) {
1685
- const lines = commentBody.split("\n");
1686
- const headRe = new RegExp(`^ ${escapeRegExp(key)}:\\s*$`);
1687
- const out = [];
1688
- let i = 0;
1689
- while (i < lines.length) {
1690
- const line = lines[i];
1691
- if (headRe.test(line)) {
1692
- i++;
1693
- while (i < lines.length && /^ {2,}\S/.test(lines[i])) {
1694
- i++;
1695
- }
1696
- continue;
1697
- }
1698
- out.push(line);
1699
- i++;
1700
- }
1701
- return out.join("\n");
1702
- }
1703
- function escapeRegExp(s) {
1704
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1705
- }
1706
- function relocateLeakedLeafComments(leafMap, parent, ancestorKey) {
1707
- const items = parent.items;
1708
- const ancestorIdx = items.findIndex((p) => {
1709
- const k = p.key;
1710
- const v = typeof k === "string" ? k : k?.value ?? null;
1711
- return v === ancestorKey;
1712
- });
1713
- if (ancestorIdx < 0 || ancestorIdx + 1 >= items.length) return;
1714
- const target = items[ancestorIdx + 1];
1715
- for (const pair of leafMap.items) {
1716
- const value = pair.value;
1717
- if (!value || typeof value !== "object") continue;
1718
- const leakedComment = value.comment;
1719
- const leakedSpace = value.spaceBefore;
1720
- if (!leakedComment && !leakedSpace) continue;
1721
- if (leakedComment) {
1722
- const targetKey = target.key;
1723
- if (targetKey && typeof targetKey === "object") {
1724
- const existing = targetKey.commentBefore ?? "";
1725
- targetKey.commentBefore = existing ? `${leakedComment}
1726
- ${existing}` : leakedComment;
1727
- if (leakedSpace) targetKey.spaceBefore = true;
1728
- }
1729
- value.comment = null;
1730
- }
1731
- if (leakedSpace) value.spaceBefore = false;
1732
- }
1733
- }
1734
-
1735
- // src/init/components.ts
1736
- import { existsSync as existsSync3, promises as fs5 } from "fs";
1737
- import path6 from "path";
1738
- import { z as z3 } from "zod";
1739
- import { parse as parseYaml } from "yaml";
1740
- var CategorySchema = z3.enum(["language", "service", "feature"]);
1741
- var FeatureContributionSchema = z3.object({
1742
- ref: z3.string().regex(REGEX.featureRef),
1743
- options: z3.record(z3.string(), FeatureOptionValueSchema).optional()
1744
- });
1745
- var ComponentFileSchema = z3.object({
1746
- displayName: z3.string().min(1),
1747
- description: z3.string().min(1),
1748
- category: CategorySchema,
1749
- contributes: z3.object({
1750
- languages: z3.array(z3.string().min(1)).optional(),
1751
- services: z3.array(z3.string().min(1)).optional(),
1752
- features: z3.array(FeatureContributionSchema).optional()
1753
- })
1754
- }).superRefine((data, ctx) => {
1755
- const c = data.contributes;
1756
- const filled = [
1757
- c.languages && c.languages.length > 0 ? "languages" : null,
1758
- c.services && c.services.length > 0 ? "services" : null,
1759
- c.features && c.features.length > 0 ? "features" : null
1760
- ].filter((x) => x !== null);
1761
- if (filled.length === 0) {
1762
- ctx.addIssue({
1763
- code: z3.ZodIssueCode.custom,
1764
- message: "contributes must set at least one of languages/services/features"
1765
- });
1766
- return;
1767
- }
1768
- if (filled.length > 1) {
1769
- ctx.addIssue({
1770
- code: z3.ZodIssueCode.custom,
1771
- message: `contributes must set exactly one of languages/services/features, got: ${filled.join(", ")}`
1772
- });
1773
- return;
1774
- }
1775
- const expected = data.category === "language" ? "languages" : data.category === "service" ? "services" : "features";
1776
- if (filled[0] !== expected) {
1777
- ctx.addIssue({
1778
- code: z3.ZodIssueCode.custom,
1779
- message: `category '${data.category}' requires contributes.${expected}, got contributes.${filled[0]}`
1780
- });
1781
- }
1782
- });
1783
- async function loadComponentCatalog(rootDir = componentsDir()) {
1784
- if (!existsSync3(rootDir)) {
1785
- return /* @__PURE__ */ new Map();
1786
- }
1787
- const out = /* @__PURE__ */ new Map();
1788
- await walk(rootDir, rootDir, out);
1789
- return out;
1790
- }
1791
- async function walk(baseDir, currentDir, out) {
1792
- const entries = await fs5.readdir(currentDir, { withFileTypes: true });
1793
- for (const entry2 of entries) {
1794
- const full = path6.join(currentDir, entry2.name);
1795
- if (entry2.isDirectory()) {
1796
- await walk(baseDir, full, out);
1797
- continue;
1798
- }
1799
- if (!entry2.isFile() || !entry2.name.endsWith(".yml")) continue;
1800
- const relative = path6.relative(baseDir, full);
1801
- const name = relative.replace(/\.yml$/, "").split(path6.sep).join("/");
1802
- const text = await fs5.readFile(full, "utf8");
1803
- let raw;
1804
- try {
1805
- raw = parseYaml(text);
1806
- } catch (err) {
1807
- throw new Error(
1808
- `Failed to parse component ${name} (${full}): ${err.message}`
1809
- );
1810
- }
1811
- const parsed = ComponentFileSchema.safeParse(raw);
1812
- if (!parsed.success) {
1813
- const issues = parsed.error.issues.map((issue) => {
1814
- const where = issue.path.length > 0 ? issue.path.join(".") : "(root)";
1815
- return ` - ${where}: ${issue.message}`;
1816
- }).join("\n");
1817
- throw new Error(`Invalid component ${name} (${full}):
1818
- ${issues}`);
1819
- }
1820
- out.set(name, { name, sourcePath: full, file: parsed.data });
1821
- }
1822
- }
1823
- function mergeComponents(resolved) {
1824
- const languages = [];
1825
- const services = [];
1826
- const featureByRef = /* @__PURE__ */ new Map();
1827
- for (const entry2 of resolved) {
1828
- const c = isResolvedComponent(entry2) ? entry2.component : entry2;
1829
- const version = isResolvedComponent(entry2) ? entry2.version : void 0;
1830
- const ct = c.file.contributes;
1831
- for (const lang of ct.languages ?? []) {
1832
- const value = version !== void 0 ? `${lang}:${version}` : lang;
1833
- if (!languages.includes(value)) languages.push(value);
1834
- }
1835
- for (const svc of ct.services ?? []) {
1836
- if (!services.includes(svc)) services.push(svc);
1837
- }
1838
- for (const f of ct.features ?? []) {
1839
- const existing = featureByRef.get(f.ref);
1840
- if (!existing) {
1841
- featureByRef.set(f.ref, {
1842
- ref: f.ref,
1843
- options: { ...f.options ?? {} }
1844
- });
1845
- continue;
1846
- }
1847
- existing.options = mergeFeatureOptions(existing.options, f.options ?? {});
1848
- }
1849
- }
1850
- return {
1851
- languages,
1852
- services,
1853
- features: [...featureByRef.values()]
1854
- };
1855
- }
1856
- function isResolvedComponent(x) {
1857
- return "component" in x;
1858
- }
1859
- function mergeFeatureOptions(a, b) {
1860
- const result = { ...a };
1861
- for (const [key, valueB] of Object.entries(b)) {
1862
- const valueA = result[key];
1863
- if (typeof valueA === "boolean" && typeof valueB === "boolean") {
1864
- result[key] = valueA || valueB;
1865
- continue;
1866
- }
1867
- result[key] = valueB;
1868
- }
1869
- return result;
1870
- }
1871
- function resolveComponents(catalog, names) {
1872
- const unknown = [];
1873
- const out = [];
1874
- for (const raw of names) {
1875
- const colon = raw.indexOf(":");
1876
- const name = colon === -1 ? raw : raw.slice(0, colon);
1877
- const version = colon === -1 ? void 0 : raw.slice(colon + 1);
1878
- const c = catalog.get(name);
1879
- if (!c) {
1880
- unknown.push(raw);
1881
- continue;
1882
- }
1883
- if (version !== void 0 && c.file.category !== "language") {
1884
- throw new Error(
1885
- `Component '${name}' is a ${c.file.category}, not a language \u2014 a ':${version}' suffix has no meaning here.`
1886
- );
1887
- }
1888
- out.push({ component: c, ...version !== void 0 ? { version } : {} });
1889
- }
1890
- if (unknown.length > 0) {
1891
- const available = [...catalog.keys()].sort();
1892
- throw new Error(
1893
- `Unknown component${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.
1894
- Available: ${available.join(", ")}.`
1895
- );
1896
- }
1897
- return out;
1898
- }
1899
-
1900
- // src/proxy/dynamic.ts
1901
- import { promises as fs6 } from "fs";
1902
- import path7 from "path";
1903
- async function writeDynamicConfig(name, ports, opts = {}) {
1904
- if (ports.length === 0) {
1905
- throw new Error(
1906
- `writeDynamicConfig requires at least one port. For empty port lists, call removeDynamicConfig(${JSON.stringify(name)}).`
1907
- );
1908
- }
1909
- const dir = proxyDynamicDir(opts.monocerosHome);
1910
- await fs6.mkdir(dir, { recursive: true });
1911
- const file = path7.join(dir, `${name}.yml`);
1912
- await fs6.writeFile(file, renderDynamicConfig(name, ports), "utf8");
1913
- return file;
1914
- }
1915
- async function removeDynamicConfig(name, opts = {}) {
1916
- const file = path7.join(proxyDynamicDir(opts.monocerosHome), `${name}.yml`);
1917
- await fs6.rm(file, { force: true });
1918
- }
1919
- function renderDynamicConfig(name, ports) {
1920
- const lines = [];
1921
- lines.push("# Generated by Monoceros \u2014 do not edit by hand.");
1922
- lines.push(`# Container: ${name}`);
1923
- lines.push(`# Ports: ${ports.join(", ")}`);
1924
- lines.push("# Traefik file-provider re-reads this on change (~100 ms);");
1925
- lines.push(
1926
- "# to change routing, edit container-configs/" + name + ".yml or use"
1927
- );
1928
- lines.push("# `monoceros add-port` / `monoceros remove-port`.");
1929
- lines.push("http:");
1930
- lines.push(" routers:");
1931
- ports.forEach((port, idx) => {
1932
- const router = `${name}-${port}`;
1933
- const hostExplicit = `${name}-${port}.localhost`;
1934
- const rule = idx === 0 ? `"Host(\`${name}.localhost\`) || Host(\`${hostExplicit}\`)"` : `"Host(\`${hostExplicit}\`)"`;
1935
- lines.push(` ${router}:`);
1936
- lines.push(` rule: ${rule}`);
1937
- lines.push(` service: ${router}`);
1938
- lines.push(" entryPoints:");
1939
- lines.push(" - web");
1940
- });
1941
- lines.push(" services:");
1942
- for (const port of ports) {
1943
- const svc = `${name}-${port}`;
1944
- lines.push(` ${svc}:`);
1945
- lines.push(" loadBalancer:");
1946
- lines.push(" servers:");
1947
- lines.push(` - url: "http://${name}:${port}"`);
1948
- }
1949
- return lines.join("\n") + "\n";
1950
- }
1951
- function proxyUrlsFor(name, ports, hostPort = 80) {
1952
- const portSuffix = hostPort === 80 ? "" : `:${hostPort}`;
1953
- return ports.map((port, idx) => ({
1954
- port,
1955
- url: `http://${name}-${port}.localhost${portSuffix}`,
1956
- isDefault: idx === 0
1957
- }));
1958
- }
1959
-
1960
- // src/proxy/port-check.ts
1961
- import { Socket } from "net";
1962
- var CONNECT_TIMEOUT_MS = 750;
1963
- var realPortProbe = (port) => {
1964
- return new Promise((resolve) => {
1965
- const socket = new Socket();
1966
- let settled = false;
1967
- const settle = (result) => {
1968
- if (settled) return;
1969
- settled = true;
1970
- socket.destroy();
1971
- resolve(result);
1972
- };
1973
- socket.setTimeout(CONNECT_TIMEOUT_MS);
1974
- socket.once("connect", () => {
1975
- settle({
1976
- ok: false,
1977
- code: "EADDRINUSE",
1978
- message: `another process is listening on ${port}`
1979
- });
1980
- });
1981
- socket.once("timeout", () => {
1982
- settle({ ok: true });
1983
- });
1984
- socket.once("error", (err) => {
1985
- const code = err.code ?? "UNKNOWN";
1986
- if (code === "ECONNREFUSED") {
1987
- settle({ ok: true });
1988
- } else {
1989
- settle({
1990
- ok: false,
1991
- code,
1992
- message: err.message
1993
- });
1994
- }
1995
- });
1996
- socket.connect(port, "127.0.0.1");
1568
+ socket.connect(port, "127.0.0.1");
1997
1569
  });
1998
1570
  };
1999
1571
  async function preflightHostPort(hostPort, opts = {}) {
@@ -2118,8 +1690,8 @@ function knownServices() {
2118
1690
  }
2119
1691
 
2120
1692
  // src/create/scaffold.ts
2121
- import { existsSync as existsSync4, readFileSync as readFileSync2, promises as fs7 } from "fs";
2122
- import path8 from "path";
1693
+ import { existsSync as existsSync3, readFileSync, promises as fs7 } from "fs";
1694
+ import path6 from "path";
2123
1695
 
2124
1696
  // src/util/ref.ts
2125
1697
  var FEATURE_NAME_CHARSET = "[a-z0-9._-]+";
@@ -2290,8 +1862,8 @@ function resolveFeatures(opts) {
2290
1862
  if (match) {
2291
1863
  const name = match.name;
2292
1864
  const checkout = workbenchCheckoutRoot();
2293
- const localSourceDir = checkout ? path8.join(checkout, "images", "features", name) : null;
2294
- if (localSourceDir && existsSync4(localSourceDir)) {
1865
+ const localSourceDir = checkout ? path6.join(checkout, "images", "features", name) : null;
1866
+ if (localSourceDir && existsSync3(localSourceDir)) {
2295
1867
  const { paths, files } = readPersistentHomeEntries(localSourceDir);
2296
1868
  resolved.push({
2297
1869
  devcontainerKey: `./features/${name}`,
@@ -2315,9 +1887,9 @@ function resolveFeatures(opts) {
2315
1887
  return resolved;
2316
1888
  }
2317
1889
  function readPersistentHomeEntries(localSourceDir) {
2318
- const manifestPath = path8.join(localSourceDir, "devcontainer-feature.json");
1890
+ const manifestPath = path6.join(localSourceDir, "devcontainer-feature.json");
2319
1891
  try {
2320
- const text = readFileSync2(manifestPath, "utf8");
1892
+ const text = readFileSync(manifestPath, "utf8");
2321
1893
  const parsed = JSON.parse(text);
2322
1894
  return {
2323
1895
  paths: filterSubpaths(parsed["x-monoceros"]?.persistentHomePaths),
@@ -2600,17 +2172,17 @@ function buildPostCreateScript(opts) {
2600
2172
  return lines.join("\n") + "\n";
2601
2173
  }
2602
2174
  async function writePostCreateScript(devcontainerDir, opts) {
2603
- const dest = path8.join(devcontainerDir, "post-create.sh");
2175
+ const dest = path6.join(devcontainerDir, "post-create.sh");
2604
2176
  await fs7.writeFile(dest, buildPostCreateScript(opts));
2605
2177
  await fs7.chmod(dest, 493);
2606
2178
  }
2607
2179
  async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2608
2180
  const dockerMode = scaffoldOpts.dockerMode ?? "rootful";
2609
- const devcontainerDir = path8.join(targetDir, ".devcontainer");
2610
- const monocerosDir = path8.join(targetDir, ".monoceros");
2611
- const projectsDir = path8.join(targetDir, "projects");
2612
- const homeDir = path8.join(targetDir, "home");
2613
- const dataDir = path8.join(targetDir, "data");
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");
2614
2186
  await fs7.mkdir(devcontainerDir, { recursive: true });
2615
2187
  await fs7.mkdir(monocerosDir, { recursive: true });
2616
2188
  await fs7.mkdir(projectsDir, { recursive: true });
@@ -2620,56 +2192,56 @@ async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
2620
2192
  for (const svcId of opts.services) {
2621
2193
  const def = SERVICE_CATALOG[svcId];
2622
2194
  if (def?.dataMount) {
2623
- await fs7.mkdir(path8.join(dataDir, def.id), { recursive: true });
2195
+ await fs7.mkdir(path6.join(dataDir, def.id), { recursive: true });
2624
2196
  }
2625
2197
  }
2626
2198
  }
2627
- const containerGitignore = path8.join(targetDir, ".gitignore");
2199
+ const containerGitignore = path6.join(targetDir, ".gitignore");
2628
2200
  await fs7.writeFile(containerGitignore, "/home/\n/.monoceros/\n/data/\n");
2629
- const gitkeep = path8.join(projectsDir, ".gitkeep");
2630
- if (!existsSync4(gitkeep)) {
2201
+ const gitkeep = path6.join(projectsDir, ".gitkeep");
2202
+ if (!existsSync3(gitkeep)) {
2631
2203
  await fs7.writeFile(gitkeep, "");
2632
2204
  }
2633
2205
  await fs7.writeFile(
2634
- path8.join(monocerosDir, ".gitignore"),
2206
+ path6.join(monocerosDir, ".gitignore"),
2635
2207
  "git-credentials*\ngitconfig\n"
2636
2208
  );
2637
2209
  const devcontainerJson = buildDevcontainerJson(opts, dockerMode);
2638
2210
  await fs7.writeFile(
2639
- path8.join(devcontainerDir, "devcontainer.json"),
2211
+ path6.join(devcontainerDir, "devcontainer.json"),
2640
2212
  JSON.stringify(devcontainerJson, null, 2) + "\n"
2641
2213
  );
2642
- const featuresDir = path8.join(devcontainerDir, "features");
2643
- if (existsSync4(featuresDir)) {
2214
+ const featuresDir = path6.join(devcontainerDir, "features");
2215
+ if (existsSync3(featuresDir)) {
2644
2216
  await fs7.rm(featuresDir, { recursive: true, force: true });
2645
2217
  }
2646
2218
  const resolvedFeatures = resolveFeatures(opts);
2647
2219
  for (const f of resolvedFeatures) {
2648
2220
  if (!f.localSourceDir || !f.localName) continue;
2649
- const dest = path8.join(featuresDir, f.localName);
2221
+ const dest = path6.join(featuresDir, f.localName);
2650
2222
  await fs7.mkdir(dest, { recursive: true });
2651
2223
  await fs7.cp(f.localSourceDir, dest, { recursive: true });
2652
2224
  }
2653
2225
  for (const f of resolvedFeatures) {
2654
2226
  for (const sub of f.persistentHomePaths) {
2655
- await fs7.mkdir(path8.join(homeDir, sub), { recursive: true });
2227
+ await fs7.mkdir(path6.join(homeDir, sub), { recursive: true });
2656
2228
  }
2657
2229
  for (const entry2 of f.persistentHomeFiles) {
2658
- const filePath = path8.join(homeDir, entry2.path);
2659
- await fs7.mkdir(path8.dirname(filePath), { recursive: true });
2660
- if (!existsSync4(filePath)) {
2230
+ const filePath = path6.join(homeDir, entry2.path);
2231
+ await fs7.mkdir(path6.dirname(filePath), { recursive: true });
2232
+ if (!existsSync3(filePath)) {
2661
2233
  await fs7.writeFile(filePath, entry2.initialContent);
2662
2234
  }
2663
2235
  }
2664
2236
  }
2665
2237
  await writePostCreateScript(devcontainerDir, opts);
2666
- const composePath = path8.join(devcontainerDir, "compose.yaml");
2238
+ const composePath = path6.join(devcontainerDir, "compose.yaml");
2667
2239
  if (needsCompose(opts)) {
2668
2240
  await fs7.writeFile(composePath, buildComposeYaml(opts, dockerMode));
2669
- } else if (existsSync4(composePath)) {
2241
+ } else if (existsSync3(composePath)) {
2670
2242
  await fs7.rm(composePath);
2671
2243
  }
2672
- const workspacePath = path8.join(targetDir, `${opts.name}.code-workspace`);
2244
+ const workspacePath = path6.join(targetDir, `${opts.name}.code-workspace`);
2673
2245
  let existingWorkspace;
2674
2246
  try {
2675
2247
  const raw = await fs7.readFile(workspacePath, "utf8");
@@ -2694,25 +2266,25 @@ import {
2694
2266
  } from "yaml";
2695
2267
 
2696
2268
  // src/init/manifest.ts
2697
- import { existsSync as existsSync5, readFileSync as readFileSync3 } from "fs";
2698
- import path9 from "path";
2269
+ import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
2270
+ import path7 from "path";
2699
2271
  function resolveManifestPath(name, checkoutRoot) {
2700
2272
  if (checkoutRoot) {
2701
- const checkoutPath = path9.join(
2273
+ const checkoutPath = path7.join(
2702
2274
  checkoutRoot,
2703
2275
  "images",
2704
2276
  "features",
2705
2277
  name,
2706
2278
  "devcontainer-feature.json"
2707
2279
  );
2708
- if (existsSync5(checkoutPath)) return checkoutPath;
2280
+ if (existsSync4(checkoutPath)) return checkoutPath;
2709
2281
  }
2710
- const bundlePath = path9.join(
2282
+ const bundlePath = path7.join(
2711
2283
  bundledFeaturesDir(),
2712
2284
  name,
2713
2285
  "devcontainer-feature.json"
2714
2286
  );
2715
- if (existsSync5(bundlePath)) return bundlePath;
2287
+ if (existsSync4(bundlePath)) return bundlePath;
2716
2288
  return null;
2717
2289
  }
2718
2290
  function loadFeatureManifestSummary(ref, checkoutRoot = workbenchCheckoutRoot()) {
@@ -2721,7 +2293,7 @@ function loadFeatureManifestSummary(ref, checkoutRoot = workbenchCheckoutRoot())
2721
2293
  const manifestPath = resolveManifestPath(match.name, checkoutRoot);
2722
2294
  if (!manifestPath) return void 0;
2723
2295
  try {
2724
- const text = readFileSync3(manifestPath, "utf8");
2296
+ const text = readFileSync2(manifestPath, "utf8");
2725
2297
  const parsed = JSON.parse(text);
2726
2298
  const rawHints = parsed["x-monoceros"]?.optionHints;
2727
2299
  const optionHints = Array.isArray(rawHints) ? rawHints.filter(
@@ -3397,7 +2969,7 @@ async function tryCloneInRunningContainer(input, entry2) {
3397
2969
  logger.info(
3398
2970
  `Cloned ${entry2.url} into /workspaces/${containerName2}/${targetRel} inside the running container.`
3399
2971
  );
3400
- void path10;
2972
+ void path8;
3401
2973
  }
3402
2974
  function shquote(value) {
3403
2975
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -3616,13 +3188,13 @@ async function mutate(opts, apply) {
3616
3188
  }
3617
3189
  function defaultLogger() {
3618
3190
  return {
3619
- info: (m) => consola2.info(m),
3620
- success: (m) => consola2.success(m),
3621
- warn: (m) => consola2.warn(m)
3191
+ info: (m) => consola.info(m),
3192
+ success: (m) => consola.success(m),
3193
+ warn: (m) => consola.warn(m)
3622
3194
  };
3623
3195
  }
3624
3196
  var defaultConfirm = async (message) => {
3625
- const result = await consola2.prompt(message, {
3197
+ const result = await consola.prompt(message, {
3626
3198
  type: "confirm",
3627
3199
  initial: false
3628
3200
  });
@@ -3662,9 +3234,6 @@ async function syncPortsToProxy(input) {
3662
3234
  ...input.proxyDocker ? { docker: input.proxyDocker } : {},
3663
3235
  logger: { info: (m) => logger.info(m), warn: (m) => logger.warn(m) }
3664
3236
  });
3665
- await kickProxyReload({
3666
- ...input.proxyDocker ? { docker: input.proxyDocker } : {}
3667
- });
3668
3237
  const urls = proxyUrlsFor(input.name, allPorts, hostPort);
3669
3238
  const lines = urls.map((u) => {
3670
3239
  const tag = u.isDefault ? " (default)" : "";
@@ -3674,9 +3243,6 @@ async function syncPortsToProxy(input) {
3674
3243
  ${lines.join("\n")}`);
3675
3244
  } else {
3676
3245
  await removeDynamicConfig(input.name, { monocerosHome: home });
3677
- await kickProxyReload({
3678
- ...input.proxyDocker ? { docker: input.proxyDocker } : {}
3679
- });
3680
3246
  await maybeStopProxy({
3681
3247
  monocerosHome: home,
3682
3248
  ...input.proxyDocker ? { docker: input.proxyDocker } : {},
@@ -3713,7 +3279,7 @@ var addAptPackagesCommand = defineCommand({
3713
3279
  async run({ args }) {
3714
3280
  const packages = [...getInnerArgs()];
3715
3281
  if (packages.length === 0) {
3716
- consola3.error(
3282
+ consola2.error(
3717
3283
  "No package names given. Usage: `monoceros add-apt-packages <containername> [--yes] -- <pkg> [<pkg> \u2026]`."
3718
3284
  );
3719
3285
  process.exit(1);
@@ -3726,7 +3292,7 @@ var addAptPackagesCommand = defineCommand({
3726
3292
  });
3727
3293
  process.exit(result.status === "aborted" ? 1 : 0);
3728
3294
  } catch (err) {
3729
- consola3.error(err instanceof Error ? err.message : String(err));
3295
+ consola2.error(err instanceof Error ? err.message : String(err));
3730
3296
  process.exit(1);
3731
3297
  }
3732
3298
  }
@@ -3734,7 +3300,7 @@ var addAptPackagesCommand = defineCommand({
3734
3300
 
3735
3301
  // src/commands/add-feature.ts
3736
3302
  import { defineCommand as defineCommand2 } from "citty";
3737
- import { consola as consola4 } from "consola";
3303
+ import { consola as consola3 } from "consola";
3738
3304
  var addFeatureCommand = defineCommand2({
3739
3305
  meta: {
3740
3306
  name: "add-feature",
@@ -3764,7 +3330,7 @@ var addFeatureCommand = defineCommand2({
3764
3330
  try {
3765
3331
  options = parseOptionsAfterDashes(getInnerArgs());
3766
3332
  } catch (err) {
3767
- consola4.error(err instanceof Error ? err.message : String(err));
3333
+ consola3.error(err instanceof Error ? err.message : String(err));
3768
3334
  process.exit(1);
3769
3335
  }
3770
3336
  try {
@@ -3776,7 +3342,7 @@ var addFeatureCommand = defineCommand2({
3776
3342
  });
3777
3343
  process.exit(result.status === "aborted" ? 1 : 0);
3778
3344
  } catch (err) {
3779
- consola4.error(err instanceof Error ? err.message : String(err));
3345
+ consola3.error(err instanceof Error ? err.message : String(err));
3780
3346
  process.exit(1);
3781
3347
  }
3782
3348
  }
@@ -3808,7 +3374,7 @@ function coerce(value) {
3808
3374
 
3809
3375
  // src/commands/add-from-url.ts
3810
3376
  import { defineCommand as defineCommand3 } from "citty";
3811
- import { consola as consola5 } from "consola";
3377
+ import { consola as consola4 } from "consola";
3812
3378
  var addFromUrlCommand = defineCommand3({
3813
3379
  meta: {
3814
3380
  name: "add-from-url",
@@ -3845,7 +3411,7 @@ var addFromUrlCommand = defineCommand3({
3845
3411
  });
3846
3412
  process.exit(result.status === "aborted" ? 1 : 0);
3847
3413
  } catch (err) {
3848
- consola5.error(err instanceof Error ? err.message : String(err));
3414
+ consola4.error(err instanceof Error ? err.message : String(err));
3849
3415
  process.exit(1);
3850
3416
  }
3851
3417
  }
@@ -3880,7 +3446,7 @@ function printSecurityWarning(url) {
3880
3446
 
3881
3447
  // src/commands/add-repo.ts
3882
3448
  import { defineCommand as defineCommand4 } from "citty";
3883
- import { consola as consola6 } from "consola";
3449
+ import { consola as consola5 } from "consola";
3884
3450
  var addRepoCommand = defineCommand4({
3885
3451
  meta: {
3886
3452
  name: "add-repo",
@@ -3934,7 +3500,7 @@ var addRepoCommand = defineCommand4({
3934
3500
  });
3935
3501
  process.exit(result.status === "aborted" ? 1 : 0);
3936
3502
  } catch (err) {
3937
- consola6.error(err instanceof Error ? err.message : String(err));
3503
+ consola5.error(err instanceof Error ? err.message : String(err));
3938
3504
  process.exit(1);
3939
3505
  }
3940
3506
  }
@@ -3942,7 +3508,7 @@ var addRepoCommand = defineCommand4({
3942
3508
 
3943
3509
  // src/commands/add-language.ts
3944
3510
  import { defineCommand as defineCommand5 } from "citty";
3945
- import { consola as consola7 } from "consola";
3511
+ import { consola as consola6 } from "consola";
3946
3512
  var addLanguageCommand = defineCommand5({
3947
3513
  meta: {
3948
3514
  name: "add-language",
@@ -3975,207 +3541,549 @@ var addLanguageCommand = defineCommand5({
3975
3541
  yes: args.yes
3976
3542
  });
3977
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);
3978
3594
  } catch (err) {
3979
3595
  consola7.error(err instanceof Error ? err.message : String(err));
3980
3596
  process.exit(1);
3981
3597
  }
3982
3598
  }
3983
- });
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
+ }
3984
3746
 
3985
- // src/commands/add-port.ts
3986
- import { defineCommand as defineCommand6 } from "citty";
3987
- import { consola as consola8 } from "consola";
3988
- var addPortCommand = defineCommand6({
3989
- meta: {
3990
- name: "add-port",
3991
- group: "edit",
3992
- description: "Add one or more ports to the container config so they become reachable from the host via Traefik (`<container>.localhost` / `<container>-<port>.localhost`). Pass port numbers after `--` (e.g. `monoceros add-port sandbox -- 3000 5173 6006`). Idempotent. Persisted in the yml so later `monoceros apply` runs restore the routes. Pass `--default` together with a single port to make it the bare `<container>.localhost` route \u2014 the port is inserted at position 0 (or moved there if it already exists)."
3993
- },
3994
- args: {
3995
- name: {
3996
- type: "positional",
3997
- description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
3998
- required: true
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));
3999
3796
  },
4000
- yes: {
4001
- type: "boolean",
4002
- description: "Skip the interactive confirmation and apply the diff.",
4003
- alias: ["y"],
4004
- 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));
4005
3840
  },
4006
- default: {
4007
- type: "boolean",
4008
- description: "Make the (single) port the new default route at `<container>.localhost`. Inserts the port at position 0 of `routing.ports`, or moves it there if it already exists. Errors when more than one port is passed.",
4009
- default: false
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));
4010
3849
  }
4011
- },
4012
- async run({ args }) {
4013
- const tokens = [...getInnerArgs()];
4014
- if (tokens.length === 0) {
4015
- consola8.error(
4016
- "No ports given. Usage: `monoceros add-port <containername> [--yes] [--default] -- <port> [<port> \u2026]`."
4017
- );
4018
- process.exit(1);
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;
4019
3878
  }
4020
- try {
4021
- const result = await runAddPort({
4022
- name: args.name,
4023
- ports: tokens.map(coerceToken),
4024
- yes: args.yes,
4025
- 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);
4026
3900
  });
4027
- process.exit(result.status === "aborted" ? 1 : 0);
4028
- } catch (err) {
4029
- consola8.error(err instanceof Error ? err.message : String(err));
4030
- process.exit(1);
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);
4031
3952
  }
4032
3953
  }
4033
- });
4034
- function coerceToken(raw) {
4035
- const n = Number(raw);
4036
- return Number.isFinite(n) ? n : raw;
3954
+ return [...ids];
4037
3955
  }
4038
-
4039
- // src/commands/add-service.ts
4040
- import { defineCommand as defineCommand7 } from "citty";
4041
- import { consola as consola9 } from "consola";
4042
- var addServiceCommand = defineCommand7({
4043
- meta: {
4044
- name: "add-service",
4045
- group: "edit",
4046
- description: "Add a compose service (postgres, mysql, redis, \u2026) to the container config. Idempotent, prints a diff before writing."
4047
- },
4048
- args: {
4049
- name: {
4050
- type: "positional",
4051
- description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
4052
- required: true
4053
- },
4054
- service: {
4055
- type: "positional",
4056
- description: "Service identifier (postgres, mysql, redis).",
4057
- required: true
4058
- },
4059
- yes: {
4060
- type: "boolean",
4061
- description: "Skip the interactive confirmation and apply the diff.",
4062
- alias: ["y"],
4063
- default: false
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()}`);
4064
3968
  }
4065
- },
4066
- async run({ args }) {
4067
- try {
4068
- const result = await runAddService({
4069
- name: args.name,
4070
- service: args.service,
4071
- yes: args.yes
4072
- });
4073
- process.exit(result.status === "aborted" ? 1 : 0);
4074
- } catch (err) {
4075
- consola9.error(err instanceof Error ? err.message : String(err));
4076
- process.exit(1);
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`);
4077
3976
  }
4078
3977
  }
4079
- });
4080
-
4081
- // src/commands/apply.ts
4082
- import { defineCommand as defineCommand8 } from "citty";
4083
-
4084
- // src/apply/index.ts
4085
- import { existsSync as existsSync6, promises as fs11 } from "fs";
4086
- import { consola as consola11 } from "consola";
4087
-
4088
- // src/config/state.ts
4089
- import { promises as fs9 } from "fs";
4090
- import path11 from "path";
4091
- function buildStateFile(opts) {
4092
- return {
4093
- schemaVersion: CONFIG_SCHEMA_VERSION,
4094
- origin: opts.origin,
4095
- monocerosCliVersion: opts.cliVersion,
4096
- materializedAt: (opts.now ?? /* @__PURE__ */ new Date()).toISOString()
4097
- };
3978
+ opts.logger.info(`[${tag}] docker cleanup done`);
3979
+ return { exitCode: rmExit, removedIds: ids };
4098
3980
  }
4099
- function stateFilePath(targetDir) {
4100
- return path11.join(targetDir, ".monoceros", "state.json");
3981
+ function composeProjectName(root) {
3982
+ return `${path11.basename(root)}_devcontainer`;
4101
3983
  }
4102
- async function readStateFile(targetDir) {
4103
- try {
4104
- const content = await fs9.readFile(stateFilePath(targetDir), "utf8");
4105
- return JSON.parse(content);
4106
- } catch {
4107
- return void 0;
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
+ );
4108
3995
  }
3996
+ return { composeFile, projectName: composeProjectName(root) };
4109
3997
  }
4110
- async function writeStateFile(targetDir, state) {
4111
- const monocerosDir = path11.join(targetDir, ".monoceros");
4112
- await fs9.mkdir(monocerosDir, { recursive: true });
4113
- await fs9.writeFile(
4114
- stateFilePath(targetDir),
4115
- JSON.stringify(state, null, 2) + "\n"
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
4116
4012
  );
4117
4013
  }
4118
-
4119
- // src/config/transform.ts
4120
- function solutionConfigToCreateOptions(config, featureDefaults = {}) {
4121
- const featureRecord = {};
4122
- for (const entry2 of config.features) {
4123
- const defaults = featureDefaults[entry2.ref] ?? {};
4124
- const containerOpts = Object.fromEntries(
4125
- Object.entries(entry2.options ?? {}).filter(([, v]) => v !== "")
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`
4126
4020
  );
4127
- featureRecord[entry2.ref] = { ...defaults, ...containerOpts };
4128
- }
4129
- const result = {
4130
- name: config.name,
4131
- languages: [...config.languages],
4132
- services: [...config.services]
4133
- };
4134
- if (config.externalServices.postgres !== void 0) {
4135
- result.postgresUrl = config.externalServices.postgres;
4136
- }
4137
- if (config.aptPackages.length > 0) {
4138
- result.aptPackages = [...config.aptPackages];
4139
- }
4140
- if (Object.keys(featureRecord).length > 0) {
4141
- result.features = featureRecord;
4142
- }
4143
- if (config.installUrls.length > 0) {
4144
- result.installUrls = [...config.installUrls];
4145
- }
4146
- if (config.repos.length > 0) {
4147
- result.repos = config.repos.map((r) => ({
4148
- url: r.url,
4149
- // `path` is optional in the yml; CreateOptions requires it.
4150
- // When the yml omits `path`, fall back to the URL-derived
4151
- // single-segment default (`https://.../foo.git` → `foo`),
4152
- // which lands the clone at `projects/foo/`.
4153
- path: r.path ?? deriveRepoName(r.url),
4154
- // gitUser is forwarded only when BOTH name + email are set.
4155
- // The relaxed GitUserSchema accepts nullable / empty strings
4156
- // (so a yml placeholder `name:` parses without error), so we
4157
- // re-check here before downstream code, which expects both
4158
- // values to be non-empty.
4159
- ...r.git?.user?.name && r.git.user.email ? { gitUser: { name: r.git.user.name, email: r.git.user.email } } : {},
4160
- ...r.provider ? { provider: r.provider } : {}
4161
- }));
4162
- }
4163
- const routingPorts = config.routing?.ports ?? [];
4164
- if (routingPorts.length > 0) {
4165
- const seen = /* @__PURE__ */ new Set();
4166
- const ports = [];
4167
- for (const entry2 of routingPorts) {
4168
- const n = portNumber(entry2);
4169
- if (seen.has(n)) continue;
4170
- seen.add(n);
4171
- ports.push(n);
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;
4172
4045
  }
4173
- result.ports = ports;
4174
- }
4175
- if (config.routing?.vscodeAutoForward !== void 0) {
4176
- result.vscodeAutoForward = config.routing.vscodeAutoForward;
4046
+ return runStart({
4047
+ root,
4048
+ ...opts.devcontainerSpawn ? { spawn: opts.devcontainerSpawn } : {},
4049
+ logger
4050
+ });
4177
4051
  }
4178
- 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
+ );
4179
4087
  }
4180
4088
 
4181
4089
  // src/devcontainer/repo-reachability.ts
@@ -4277,7 +4185,7 @@ function formatUnreachableReposError(failures) {
4277
4185
  }
4278
4186
  lines.push("");
4279
4187
  }
4280
- lines.push(`Then re-run ${cyan("monoceros apply")}.`);
4188
+ lines.push(`Then re-run ${cyan2("monoceros apply")}.`);
4281
4189
  return lines.join("\n");
4282
4190
  }
4283
4191
  function headerForKind(kind) {
@@ -4360,14 +4268,14 @@ function formatRootlessNotSupportedError() {
4360
4268
  ``,
4361
4269
  `To fix, switch back to standard rootful Docker:`,
4362
4270
  ``,
4363
- cyan(
4271
+ cyan2(
4364
4272
  ` systemctl --user stop docker.service docker.socket 2>/dev/null || true`
4365
4273
  ),
4366
- cyan(` dockerd-rootless-setuptool.sh uninstall`),
4367
- cyan(` rootlesskit rm -rf ~/.local/share/docker`),
4368
- cyan(` unset DOCKER_HOST DOCKER_CONTEXT`),
4369
- cyan(` sudo systemctl enable --now docker`),
4370
- cyan(` sudo usermod -aG docker $USER`),
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`),
4371
4279
  ``,
4372
4280
  `If you added DOCKER_HOST or DOCKER_CONTEXT to ~/.bashrc /`,
4373
4281
  `~/.profile (the rootless setup may have suggested it), remove`,
@@ -4672,7 +4580,7 @@ ${sectionLine(label)}
4672
4580
  section("Container");
4673
4581
  const featureRefs = parsed.config.features.map((f) => f.ref);
4674
4582
  if (featureRefs.length > 0) {
4675
- logger.info(`Features: ${featureRefs.map((r) => cyan(r)).join(", ")}`);
4583
+ logger.info(`Features: ${featureRefs.map((r) => cyan2(r)).join(", ")}`);
4676
4584
  }
4677
4585
  logger.info(
4678
4586
  dim(
@@ -4695,14 +4603,8 @@ ${sectionLine(label)}
4695
4603
  hostPort: proxyHostPort(globalConfig),
4696
4604
  logger
4697
4605
  });
4698
- await kickProxyReload({
4699
- ...opts.proxyDocker ? { docker: opts.proxyDocker } : {}
4700
- });
4701
4606
  } else {
4702
4607
  await removeDynamicConfig(opts.name, { monocerosHome: home });
4703
- await kickProxyReload({
4704
- ...opts.proxyDocker ? { docker: opts.proxyDocker } : {}
4705
- });
4706
4608
  }
4707
4609
  } catch (err) {
4708
4610
  logger.warn?.(
@@ -4717,7 +4619,7 @@ ${sectionLine(label)}
4717
4619
  });
4718
4620
  if (exitCode === 0) {
4719
4621
  section("Next steps");
4720
- logger.info(` ${cyan(`monoceros shell ${opts.name}`)}`);
4622
+ logger.info(` ${cyan2(`monoceros shell ${opts.name}`)}`);
4721
4623
  }
4722
4624
  return { targetDir, configPath: ymlPath, containerExitCode: exitCode };
4723
4625
  }
@@ -4815,7 +4717,7 @@ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
4815
4717
  }
4816
4718
 
4817
4719
  // src/version.ts
4818
- var CLI_VERSION = true ? "1.11.11" : "dev";
4720
+ var CLI_VERSION = true ? "1.12.0" : "dev";
4819
4721
 
4820
4722
  // src/commands/_dispatch.ts
4821
4723
  import { consola as consola12 } from "consola";
@@ -6277,7 +6179,7 @@ async function runRemove(opts) {
6277
6179
  projectName,
6278
6180
  filters: [
6279
6181
  `label=com.docker.compose.project=${projectName}`,
6280
- `label=devcontainer.local_folder=${dockerLocalFolderLabel(containerPath)}`,
6182
+ `label=devcontainer.local_folder=${containerPath}`,
6281
6183
  `name=^${projectName}-`,
6282
6184
  `name=^vsc-${opts.name}-`
6283
6185
  ],
@@ -6345,9 +6247,6 @@ async function runRemove(opts) {
6345
6247
  }
6346
6248
  try {
6347
6249
  await removeDynamicConfig(opts.name, { monocerosHome: home });
6348
- await kickProxyReload({
6349
- ...opts.proxyDocker ? { docker: opts.proxyDocker } : {}
6350
- });
6351
6250
  } catch (err) {
6352
6251
  logger.warn?.(
6353
6252
  `Could not remove Traefik dynamic config for ${opts.name}: ${err instanceof Error ? err.message : String(err)}. Ignored.`
@@ -7087,10 +6986,7 @@ async function lookupContainerNetwork(args) {
7087
6986
  "ps",
7088
6987
  "-q",
7089
6988
  "--filter",
7090
- // Windows-normalize: devcontainer-cli lowercases the drive letter
7091
- // when stamping the label, docker filter is byte-exact. No-op
7092
- // off Windows.
7093
- `label=devcontainer.local_folder=${dockerLocalFolderLabel(args.containerRoot)}`
6989
+ `label=devcontainer.local_folder=${args.containerRoot}`
7094
6990
  ]);
7095
6991
  if (psResult.exitCode !== 0) {
7096
6992
  throw new Error(
@@ -7412,7 +7308,6 @@ var main = defineCommand30({
7412
7308
 
7413
7309
  // src/bin.ts
7414
7310
  bootstrapDockerGroup();
7415
- bootstrapWslBackend();
7416
7311
  consumeInnerArgsFromProcessArgv();
7417
7312
  async function entry() {
7418
7313
  if (await maybeRenderHelp(process.argv.slice(2), main)) {