@getmonoceros/workbench 1.6.12 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -251,25 +251,25 @@ function detectHelpRequest(argv, main2) {
251
251
  const separatorIdx = argv.indexOf("--");
252
252
  if (helpIdx === -1) return null;
253
253
  if (separatorIdx !== -1 && separatorIdx < helpIdx) return null;
254
- const path13 = [];
254
+ const path15 = [];
255
255
  const tokens = argv.slice(
256
256
  0,
257
257
  separatorIdx === -1 ? argv.length : separatorIdx
258
258
  );
259
259
  let cursor = main2;
260
260
  const mainName = (main2.meta ?? {}).name ?? "monoceros";
261
- path13.push(mainName);
261
+ path15.push(mainName);
262
262
  for (const tok of tokens) {
263
263
  if (tok.startsWith("-")) continue;
264
264
  const subs = cursor.subCommands ?? {};
265
265
  if (tok in subs) {
266
266
  cursor = subs[tok];
267
- path13.push(tok);
267
+ path15.push(tok);
268
268
  continue;
269
269
  }
270
270
  break;
271
271
  }
272
- return { path: path13, cmd: cursor };
272
+ return { path: path15, cmd: cursor };
273
273
  }
274
274
  async function maybeRenderHelp(argv, main2) {
275
275
  const hit = detectHelpRequest(argv, main2);
@@ -301,14 +301,14 @@ function getInnerArgs() {
301
301
  }
302
302
 
303
303
  // src/main.ts
304
- import { defineCommand as defineCommand25 } from "citty";
304
+ import { defineCommand as defineCommand28 } from "citty";
305
305
 
306
306
  // src/commands/add-apt-packages.ts
307
307
  import { defineCommand } from "citty";
308
308
  import { consola as consola2 } from "consola";
309
309
 
310
310
  // src/modify/index.ts
311
- import { promises as fs3 } from "fs";
311
+ import { promises as fs6 } from "fs";
312
312
  import { consola } from "consola";
313
313
  import { createPatch } from "diff";
314
314
 
@@ -392,6 +392,16 @@ var RepoEntrySchema = z.object({
392
392
  // parse time — see ADR 0006.
393
393
  provider: z.enum(PROVIDER_VALUES).optional()
394
394
  });
395
+ var PortEntrySchema = z.union([
396
+ z.number().int().min(1, "Port must be \u2265 1.").max(65535, "Port must be \u2264 65535."),
397
+ z.object({
398
+ port: z.number().int().min(1, "Port must be \u2265 1.").max(65535, "Port must be \u2264 65535.")
399
+ })
400
+ ]);
401
+ var RoutingSchema = z.object({
402
+ ports: z.array(PortEntrySchema).default([]),
403
+ vscodeAutoForward: z.boolean().optional()
404
+ });
395
405
  var ExternalServicesSchema = z.object({
396
406
  postgres: z.string().regex(
397
407
  POSTGRES_URL_RE,
@@ -420,11 +430,15 @@ var SolutionConfigSchema = z.object({
420
430
  ).default([]),
421
431
  services: z.array(z.string().min(1)).default([]),
422
432
  repos: z.array(RepoEntrySchema).default([]),
433
+ routing: RoutingSchema.optional(),
423
434
  externalServices: ExternalServicesSchema.default({}),
424
435
  git: z.object({
425
436
  user: GitUserSchema.optional()
426
437
  }).optional()
427
438
  });
439
+ function portNumber(entry2) {
440
+ return typeof entry2 === "number" ? entry2 : entry2.port;
441
+ }
428
442
  function validateConfig(input) {
429
443
  const result = SolutionConfigSchema.safeParse(input);
430
444
  if (!result.success) {
@@ -553,6 +567,343 @@ function prettyPath(p) {
553
567
  return p;
554
568
  }
555
569
 
570
+ // src/config/global.ts
571
+ import { promises as fs2 } from "fs";
572
+ import { z as z2 } from "zod";
573
+ import { parseDocument as parseDocument2 } from "yaml";
574
+ var SCHEMA_VERSION = 1;
575
+ var MonocerosConfigSchema = z2.object({
576
+ schemaVersion: z2.literal(SCHEMA_VERSION),
577
+ // .nullish() (= .optional().nullable()) on defaults so the shipped
578
+ // sample yml — where `defaults:` is uncommented but every sub-block
579
+ // is commented out — parses cleanly. YAML produces `defaults: null`
580
+ // in that case; without .nullish() the schema would reject it and
581
+ // we'd be back to forcing builders to comment-juggle three lines.
582
+ defaults: z2.object({
583
+ // .nullish() (not just .optional()) so the sample yml can leave
584
+ // `git:` uncommented as a category marker — YAML produces
585
+ // `git: null` for an empty mapping, which zod's plain
586
+ // `.optional()` would reject.
587
+ git: z2.object({
588
+ user: GitUserSchema.optional()
589
+ }).nullish(),
590
+ // .nullish() for the same reason as `git` — the sample keeps
591
+ // `features:` uncommented as a category marker.
592
+ features: z2.record(
593
+ z2.string().regex(
594
+ REGEX.featureRef,
595
+ "Invalid feature ref. Expected an OCI-image-style ref like 'ghcr.io/getmonoceros/monoceros-features/<name>:<tag>'."
596
+ ),
597
+ z2.record(z2.string(), FeatureOptionValueSchema)
598
+ ).nullish()
599
+ }).nullish(),
600
+ // Machine-global routing settings — one Traefik per builder, so
601
+ // host-port and similar live here rather than in any container yml.
602
+ // See ADR 0007.
603
+ routing: z2.object({
604
+ hostPort: z2.number().int().min(1).max(65535).optional().describe(
605
+ "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>/."
606
+ )
607
+ }).nullish()
608
+ });
609
+ async function readMonocerosConfig(opts = {}) {
610
+ const home = opts.monocerosHome ?? monocerosHome();
611
+ const filePath = monocerosConfigPath(home);
612
+ let text;
613
+ try {
614
+ text = await fs2.readFile(filePath, "utf8");
615
+ } catch {
616
+ return void 0;
617
+ }
618
+ const doc = parseDocument2(text, { prettyErrors: true });
619
+ if (doc.errors.length > 0) {
620
+ throw new Error(
621
+ `yaml parse error in ${filePath}: ${doc.errors[0].message}`
622
+ );
623
+ }
624
+ const result = MonocerosConfigSchema.safeParse(doc.toJS());
625
+ if (!result.success) {
626
+ const issues = result.error.issues.map((issue) => {
627
+ const where = issue.path.length > 0 ? issue.path.join(".") : "(root)";
628
+ return ` - ${where}: ${issue.message}`;
629
+ }).join("\n");
630
+ throw new Error(
631
+ `Invalid ${filePath}:
632
+ ${issues}
633
+
634
+ See ${filePath.replace(
635
+ /\.yml$/,
636
+ ".sample.yml"
637
+ )} for a valid example.`
638
+ );
639
+ }
640
+ return result.data;
641
+ }
642
+ var DEFAULT_PROXY_HOST_PORT = 80;
643
+ function proxyHostPort(config) {
644
+ return config?.routing?.hostPort ?? DEFAULT_PROXY_HOST_PORT;
645
+ }
646
+
647
+ // src/proxy/index.ts
648
+ import { spawn } from "child_process";
649
+ import { promises as fs3 } from "fs";
650
+ import path2 from "path";
651
+ var PROXY_CONTAINER_NAME = "monoceros-proxy";
652
+ var PROXY_NETWORK_NAME = "monoceros-proxy";
653
+ var TRAEFIK_IMAGE = "traefik:v3.3";
654
+ var defaultDockerExec = (args) => {
655
+ return new Promise((resolve, reject) => {
656
+ const child = spawn("docker", args, {
657
+ stdio: ["ignore", "pipe", "pipe"]
658
+ });
659
+ let stdout = "";
660
+ let stderr = "";
661
+ child.stdout.on("data", (chunk) => {
662
+ stdout += chunk.toString();
663
+ });
664
+ child.stderr.on("data", (chunk) => {
665
+ stderr += chunk.toString();
666
+ });
667
+ child.on("error", reject);
668
+ child.on(
669
+ "exit",
670
+ (code) => resolve({ stdout, stderr, exitCode: code ?? 0 })
671
+ );
672
+ });
673
+ };
674
+ var realDocker = defaultDockerExec;
675
+ function proxyDynamicDir(home) {
676
+ return path2.join(home ?? monocerosHome(), "traefik", "dynamic");
677
+ }
678
+ async function ensureProxy(opts = {}) {
679
+ const docker = opts.docker ?? realDocker;
680
+ const dyn = proxyDynamicDir(opts.monocerosHome);
681
+ await fs3.mkdir(dyn, { recursive: true });
682
+ const netInspect = await docker(["network", "inspect", PROXY_NETWORK_NAME]);
683
+ if (netInspect.exitCode !== 0) {
684
+ const create = await docker(["network", "create", PROXY_NETWORK_NAME]);
685
+ if (create.exitCode !== 0) {
686
+ throw new Error(
687
+ `Could not create docker network ${PROXY_NETWORK_NAME}: ${create.stderr.trim() || `exit ${create.exitCode}`}`
688
+ );
689
+ }
690
+ }
691
+ const state = await docker([
692
+ "inspect",
693
+ "--format",
694
+ "{{.State.Running}}",
695
+ PROXY_CONTAINER_NAME
696
+ ]);
697
+ if (state.exitCode === 0) {
698
+ if (state.stdout.trim() === "true") return;
699
+ const start = await docker(["start", PROXY_CONTAINER_NAME]);
700
+ if (start.exitCode !== 0) {
701
+ throw new Error(
702
+ `Could not start existing ${PROXY_CONTAINER_NAME} container: ${start.stderr.trim() || `exit ${start.exitCode}`}`
703
+ );
704
+ }
705
+ return;
706
+ }
707
+ const hostPort = opts.hostPort ?? 80;
708
+ const run = await docker([
709
+ "run",
710
+ "-d",
711
+ "--name",
712
+ PROXY_CONTAINER_NAME,
713
+ "--network",
714
+ PROXY_NETWORK_NAME,
715
+ "-p",
716
+ `${hostPort}:80`,
717
+ "-v",
718
+ `${dyn}:/etc/traefik/dynamic:ro`,
719
+ "--label",
720
+ "monoceros.role=proxy",
721
+ TRAEFIK_IMAGE,
722
+ "--entrypoints.web.address=:80",
723
+ "--providers.file.directory=/etc/traefik/dynamic",
724
+ "--providers.file.watch=true",
725
+ "--providers.docker=false",
726
+ "--api.dashboard=false",
727
+ "--log.level=INFO"
728
+ ]);
729
+ if (run.exitCode !== 0) {
730
+ throw new Error(
731
+ `Could not start ${PROXY_CONTAINER_NAME}: ${run.stderr.trim() || `exit ${run.exitCode}`}`
732
+ );
733
+ }
734
+ opts.logger?.info(
735
+ `Started ${PROXY_CONTAINER_NAME} (Traefik on :${hostPort}).`
736
+ );
737
+ }
738
+ async function maybeStopProxy(opts = {}) {
739
+ const docker = opts.docker ?? realDocker;
740
+ const logger = opts.logger;
741
+ const inspect = await docker([
742
+ "network",
743
+ "inspect",
744
+ PROXY_NETWORK_NAME,
745
+ "--format",
746
+ "{{range $k, $v := .Containers}}{{$v.Name}}\n{{end}}"
747
+ ]);
748
+ if (inspect.exitCode !== 0) {
749
+ return;
750
+ }
751
+ const others = inspect.stdout.split("\n").map((n) => n.trim()).filter((n) => n.length > 0 && n !== PROXY_CONTAINER_NAME);
752
+ if (others.length > 0) return;
753
+ await docker(["rm", "-f", PROXY_CONTAINER_NAME]);
754
+ const netRm = await docker(["network", "rm", PROXY_NETWORK_NAME]);
755
+ if (netRm.exitCode !== 0) {
756
+ logger?.warn?.(
757
+ `Could not remove docker network ${PROXY_NETWORK_NAME}: ${netRm.stderr.trim() || `exit ${netRm.exitCode}`}`
758
+ );
759
+ return;
760
+ }
761
+ logger?.info(
762
+ `Stopped ${PROXY_CONTAINER_NAME} (no dev-containers with ports left).`
763
+ );
764
+ }
765
+
766
+ // src/proxy/dynamic.ts
767
+ import { promises as fs4 } from "fs";
768
+ import path3 from "path";
769
+ async function writeDynamicConfig(name, ports, opts = {}) {
770
+ if (ports.length === 0) {
771
+ throw new Error(
772
+ `writeDynamicConfig requires at least one port. For empty port lists, call removeDynamicConfig(${JSON.stringify(name)}).`
773
+ );
774
+ }
775
+ const dir = proxyDynamicDir(opts.monocerosHome);
776
+ await fs4.mkdir(dir, { recursive: true });
777
+ const file = path3.join(dir, `${name}.yml`);
778
+ await fs4.writeFile(file, renderDynamicConfig(name, ports), "utf8");
779
+ return file;
780
+ }
781
+ async function removeDynamicConfig(name, opts = {}) {
782
+ const file = path3.join(proxyDynamicDir(opts.monocerosHome), `${name}.yml`);
783
+ await fs4.rm(file, { force: true });
784
+ }
785
+ function renderDynamicConfig(name, ports) {
786
+ const lines = [];
787
+ lines.push("# Generated by Monoceros \u2014 do not edit by hand.");
788
+ lines.push(`# Container: ${name}`);
789
+ lines.push(`# Ports: ${ports.join(", ")}`);
790
+ lines.push("# Traefik file-provider re-reads this on change (~100 ms);");
791
+ lines.push(
792
+ "# to change routing, edit container-configs/" + name + ".yml or use"
793
+ );
794
+ lines.push("# `monoceros add-port` / `monoceros remove-port`.");
795
+ lines.push("http:");
796
+ lines.push(" routers:");
797
+ ports.forEach((port, idx) => {
798
+ const router = `${name}-${port}`;
799
+ const hostExplicit = `${name}-${port}.localhost`;
800
+ const rule = idx === 0 ? `"Host(\`${name}.localhost\`) || Host(\`${hostExplicit}\`)"` : `"Host(\`${hostExplicit}\`)"`;
801
+ lines.push(` ${router}:`);
802
+ lines.push(` rule: ${rule}`);
803
+ lines.push(` service: ${router}`);
804
+ lines.push(" entryPoints:");
805
+ lines.push(" - web");
806
+ });
807
+ lines.push(" services:");
808
+ for (const port of ports) {
809
+ const svc = `${name}-${port}`;
810
+ lines.push(` ${svc}:`);
811
+ lines.push(" loadBalancer:");
812
+ lines.push(" servers:");
813
+ lines.push(` - url: "http://${name}:${port}"`);
814
+ }
815
+ return lines.join("\n") + "\n";
816
+ }
817
+ function proxyUrlsFor(name, ports, hostPort = 80) {
818
+ const portSuffix = hostPort === 80 ? "" : `:${hostPort}`;
819
+ return ports.map((port, idx) => ({
820
+ port,
821
+ url: `http://${name}-${port}.localhost${portSuffix}`,
822
+ isDefault: idx === 0
823
+ }));
824
+ }
825
+
826
+ // src/proxy/port-check.ts
827
+ import { createServer } from "net";
828
+ var realPortProbe = (port) => {
829
+ return new Promise((resolve) => {
830
+ const server = createServer();
831
+ server.unref();
832
+ server.once("error", (err) => {
833
+ resolve({
834
+ ok: false,
835
+ code: err.code ?? "UNKNOWN",
836
+ message: err.message
837
+ });
838
+ });
839
+ server.once("listening", () => {
840
+ server.close(() => resolve({ ok: true }));
841
+ });
842
+ server.listen(port, "0.0.0.0");
843
+ });
844
+ };
845
+ async function preflightHostPort(hostPort, opts = {}) {
846
+ const docker = opts.docker ?? defaultDockerExec;
847
+ const inspect = await docker([
848
+ "inspect",
849
+ "--format",
850
+ "{{.State.Running}}",
851
+ PROXY_CONTAINER_NAME
852
+ ]);
853
+ if (inspect.exitCode === 0 && inspect.stdout.trim() === "true") {
854
+ return;
855
+ }
856
+ const probe = opts.portProbe ?? realPortProbe;
857
+ const result = await probe(hostPort);
858
+ if (result.ok) return;
859
+ throw new Error(
860
+ formatHostPortHeldError(hostPort, result.code, result.message)
861
+ );
862
+ }
863
+ function formatHostPortHeldError(hostPort, code, systemMessage) {
864
+ const isInUse = code === "EADDRINUSE";
865
+ const lines = [];
866
+ if (isInUse) {
867
+ lines.push(`Host port ${hostPort} is already in use by another process.`);
868
+ lines.push("");
869
+ lines.push(`Monoceros needs that port for its Traefik proxy (the thing`);
870
+ lines.push(`that routes <name>.localhost / <name>-<port>.localhost to`);
871
+ lines.push(`your dev-container). Two ways out:`);
872
+ lines.push("");
873
+ lines.push(" 1) Recommended: free the port.");
874
+ lines.push(" Identify the process holding it:");
875
+ lines.push(` sudo lsof -iTCP:${hostPort} -sTCP:LISTEN -n -P`);
876
+ lines.push(` # or: sudo ss -tlnp | grep ":${hostPort}\\b"`);
877
+ lines.push(" Then stop or reconfigure that service.");
878
+ lines.push("");
879
+ lines.push(" 2) Move Monoceros off port 80. Edit (or create)");
880
+ lines.push(" ~/.monoceros/monoceros-config.yml and add:");
881
+ lines.push("");
882
+ lines.push(" schemaVersion: 1");
883
+ lines.push(" routing:");
884
+ lines.push(" hostPort: 8080 # any free port");
885
+ lines.push("");
886
+ lines.push(" URLs will become http://<name>.localhost:8080/.");
887
+ lines.push("");
888
+ lines.push(`Aborting \u2014 re-run after the conflict is resolved.`);
889
+ } else {
890
+ lines.push(`Cannot bind host port ${hostPort}: ${systemMessage}`);
891
+ lines.push("");
892
+ if (code === "EACCES") {
893
+ lines.push(`Port ${hostPort} is a privileged port (<1024) and your`);
894
+ lines.push(`current Docker setup can't bind it. For rootful Docker`);
895
+ lines.push(`(what Monoceros requires) this should normally work \u2014`);
896
+ lines.push(`check that the docker daemon is running as root.`);
897
+ lines.push("");
898
+ }
899
+ lines.push("You can also move Monoceros off this port by setting");
900
+ lines.push("`routing.hostPort` in ~/.monoceros/monoceros-config.yml.");
901
+ lines.push("");
902
+ lines.push(`Aborting \u2014 re-run after the issue is resolved.`);
903
+ }
904
+ return lines.join("\n");
905
+ }
906
+
556
907
  // src/create/catalog.ts
557
908
  var DEFAULT_BASE_IMAGE = "ghcr.io/getmonoceros/monoceros-runtime:1";
558
909
  var override = process.env.MONOCEROS_BASE_IMAGE_OVERRIDE?.trim();
@@ -610,8 +961,8 @@ function knownServices() {
610
961
  }
611
962
 
612
963
  // src/create/scaffold.ts
613
- import { existsSync as existsSync2, readFileSync, promises as fs2 } from "fs";
614
- import path2 from "path";
964
+ import { existsSync as existsSync2, readFileSync, promises as fs5 } from "fs";
965
+ import path4 from "path";
615
966
 
616
967
  // src/util/ref.ts
617
968
  var FEATURE_NAME_CHARSET = "[a-z0-9._-]+";
@@ -732,6 +1083,7 @@ function normalizeOptions(opts) {
732
1083
  const repos = opts.repos ? Array.from(
733
1084
  new Map(opts.repos.map((r) => [`${r.url}${r.path}`, r])).values()
734
1085
  ) : void 0;
1086
+ const ports = opts.ports ? [...new Set(opts.ports)] : void 0;
735
1087
  return {
736
1088
  name: opts.name,
737
1089
  languages,
@@ -740,7 +1092,9 @@ function normalizeOptions(opts) {
740
1092
  ...aptPackages.length > 0 ? { aptPackages } : {},
741
1093
  ...features && Object.keys(features).length > 0 ? { features } : {},
742
1094
  ...installUrls && installUrls.length > 0 ? { installUrls } : {},
743
- ...repos && repos.length > 0 ? { repos } : {}
1095
+ ...repos && repos.length > 0 ? { repos } : {},
1096
+ ...ports && ports.length > 0 ? { ports } : {},
1097
+ ...opts.vscodeAutoForward !== void 0 ? { vscodeAutoForward: opts.vscodeAutoForward } : {}
744
1098
  };
745
1099
  }
746
1100
  function needsCompose(opts) {
@@ -779,7 +1133,7 @@ function resolveFeatures(opts) {
779
1133
  if (match) {
780
1134
  const name = match.name;
781
1135
  const checkout = workbenchCheckoutRoot();
782
- const localSourceDir = checkout ? path2.join(checkout, "images", "features", name) : null;
1136
+ const localSourceDir = checkout ? path4.join(checkout, "images", "features", name) : null;
783
1137
  if (localSourceDir && existsSync2(localSourceDir)) {
784
1138
  const { paths, files } = readPersistentHomeEntries(localSourceDir);
785
1139
  resolved.push({
@@ -804,7 +1158,7 @@ function resolveFeatures(opts) {
804
1158
  return resolved;
805
1159
  }
806
1160
  function readPersistentHomeEntries(localSourceDir) {
807
- const manifestPath = path2.join(localSourceDir, "devcontainer-feature.json");
1161
+ const manifestPath = path4.join(localSourceDir, "devcontainer-feature.json");
808
1162
  try {
809
1163
  const text = readFileSync(manifestPath, "utf8");
810
1164
  const parsed = JSON.parse(text);
@@ -866,6 +1220,16 @@ function buildDevcontainerJson(opts, dockerMode = "rootful") {
866
1220
  );
867
1221
  }
868
1222
  }
1223
+ const ports = opts.ports ?? [];
1224
+ const customizationsField = ports.length > 0 ? {
1225
+ customizations: {
1226
+ vscode: {
1227
+ settings: {
1228
+ "remote.autoForwardPorts": opts.vscodeAutoForward ?? false
1229
+ }
1230
+ }
1231
+ }
1232
+ } : void 0;
869
1233
  if (needsCompose(opts)) {
870
1234
  return {
871
1235
  name: opts.name,
@@ -874,34 +1238,49 @@ function buildDevcontainerJson(opts, dockerMode = "rootful") {
874
1238
  ...opts.services.length > 0 ? { runServices: opts.services } : {},
875
1239
  workspaceFolder: `/workspaces/${opts.name}`,
876
1240
  remoteUser: "node",
877
- forwardPorts: [3e3, 4e3],
1241
+ forwardPorts: ports,
878
1242
  postCreateCommand: ".devcontainer/post-create.sh",
879
- ...featuresField ?? {}
1243
+ ...featuresField ?? {},
1244
+ ...customizationsField ?? {}
880
1245
  };
881
1246
  }
882
1247
  const mounts = [...homeMounts];
883
1248
  const mountsField = mounts.length > 0 ? { mounts } : {};
884
1249
  const workspaceMountField = {};
1250
+ const runArgs = ["--cap-add=NET_ADMIN"];
1251
+ if (ports.length > 0) {
1252
+ runArgs.push("--network=monoceros-proxy");
1253
+ runArgs.push(`--network-alias=${opts.name}`);
1254
+ }
885
1255
  return {
886
1256
  name: opts.name,
887
1257
  image: BASE_IMAGE,
888
1258
  remoteUser: "node",
889
1259
  ...workspaceMountField,
890
1260
  ...mountsField,
891
- runArgs: ["--cap-add=NET_ADMIN"],
892
- forwardPorts: [3e3, 4e3],
1261
+ runArgs,
1262
+ forwardPorts: ports,
893
1263
  postCreateCommand: ".devcontainer/post-create.sh",
894
- ...featuresField ?? {}
1264
+ ...featuresField ?? {},
1265
+ ...customizationsField ?? {}
895
1266
  };
896
1267
  }
897
1268
  function buildComposeYaml(opts, dockerMode = "rootful") {
898
1269
  void dockerMode;
1270
+ const hasPorts = (opts.ports?.length ?? 0) > 0;
899
1271
  const lines = ["services:"];
900
1272
  lines.push(" workspace:");
901
1273
  lines.push(` image: ${BASE_IMAGE}`);
902
1274
  lines.push(" command: 'sleep infinity'");
903
1275
  lines.push(" cap_add:");
904
1276
  lines.push(" - NET_ADMIN");
1277
+ if (hasPorts) {
1278
+ lines.push(" networks:");
1279
+ lines.push(" default: {}");
1280
+ lines.push(" monoceros-proxy:");
1281
+ lines.push(" aliases:");
1282
+ lines.push(` - ${opts.name}`);
1283
+ }
905
1284
  lines.push(" volumes:");
906
1285
  lines.push(` - ..:/workspaces/${opts.name}:cached`);
907
1286
  const resolvedFeatures = resolveFeatures(opts);
@@ -930,6 +1309,11 @@ function buildComposeYaml(opts, dockerMode = "rootful") {
930
1309
  lines.push(` - ../data/${def.id}:${def.dataMount}`);
931
1310
  }
932
1311
  }
1312
+ if (hasPorts) {
1313
+ lines.push("networks:");
1314
+ lines.push(" monoceros-proxy:");
1315
+ lines.push(" external: true");
1316
+ }
933
1317
  return lines.join("\n") + "\n";
934
1318
  }
935
1319
  function buildCodeWorkspaceJson(opts) {
@@ -1039,77 +1423,77 @@ function buildPostCreateScript(opts) {
1039
1423
  return lines.join("\n") + "\n";
1040
1424
  }
1041
1425
  async function writePostCreateScript(devcontainerDir, opts) {
1042
- const dest = path2.join(devcontainerDir, "post-create.sh");
1043
- await fs2.writeFile(dest, buildPostCreateScript(opts));
1044
- await fs2.chmod(dest, 493);
1426
+ const dest = path4.join(devcontainerDir, "post-create.sh");
1427
+ await fs5.writeFile(dest, buildPostCreateScript(opts));
1428
+ await fs5.chmod(dest, 493);
1045
1429
  }
1046
1430
  async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
1047
1431
  const dockerMode = scaffoldOpts.dockerMode ?? "rootful";
1048
- const devcontainerDir = path2.join(targetDir, ".devcontainer");
1049
- const monocerosDir = path2.join(targetDir, ".monoceros");
1050
- const projectsDir = path2.join(targetDir, "projects");
1051
- const homeDir = path2.join(targetDir, "home");
1052
- const dataDir = path2.join(targetDir, "data");
1053
- await fs2.mkdir(devcontainerDir, { recursive: true });
1054
- await fs2.mkdir(monocerosDir, { recursive: true });
1055
- await fs2.mkdir(projectsDir, { recursive: true });
1056
- await fs2.mkdir(homeDir, { recursive: true });
1432
+ const devcontainerDir = path4.join(targetDir, ".devcontainer");
1433
+ const monocerosDir = path4.join(targetDir, ".monoceros");
1434
+ const projectsDir = path4.join(targetDir, "projects");
1435
+ const homeDir = path4.join(targetDir, "home");
1436
+ const dataDir = path4.join(targetDir, "data");
1437
+ await fs5.mkdir(devcontainerDir, { recursive: true });
1438
+ await fs5.mkdir(monocerosDir, { recursive: true });
1439
+ await fs5.mkdir(projectsDir, { recursive: true });
1440
+ await fs5.mkdir(homeDir, { recursive: true });
1057
1441
  if (needsCompose(opts)) {
1058
- await fs2.mkdir(dataDir, { recursive: true });
1442
+ await fs5.mkdir(dataDir, { recursive: true });
1059
1443
  for (const svcId of opts.services) {
1060
1444
  const def = SERVICE_CATALOG[svcId];
1061
1445
  if (def?.dataMount) {
1062
- await fs2.mkdir(path2.join(dataDir, def.id), { recursive: true });
1446
+ await fs5.mkdir(path4.join(dataDir, def.id), { recursive: true });
1063
1447
  }
1064
1448
  }
1065
1449
  }
1066
- const containerGitignore = path2.join(targetDir, ".gitignore");
1067
- await fs2.writeFile(containerGitignore, "/home/\n/.monoceros/\n/data/\n");
1068
- const gitkeep = path2.join(projectsDir, ".gitkeep");
1450
+ const containerGitignore = path4.join(targetDir, ".gitignore");
1451
+ await fs5.writeFile(containerGitignore, "/home/\n/.monoceros/\n/data/\n");
1452
+ const gitkeep = path4.join(projectsDir, ".gitkeep");
1069
1453
  if (!existsSync2(gitkeep)) {
1070
- await fs2.writeFile(gitkeep, "");
1454
+ await fs5.writeFile(gitkeep, "");
1071
1455
  }
1072
- await fs2.writeFile(
1073
- path2.join(monocerosDir, ".gitignore"),
1456
+ await fs5.writeFile(
1457
+ path4.join(monocerosDir, ".gitignore"),
1074
1458
  "git-credentials*\ngitconfig\n"
1075
1459
  );
1076
1460
  const devcontainerJson = buildDevcontainerJson(opts, dockerMode);
1077
- await fs2.writeFile(
1078
- path2.join(devcontainerDir, "devcontainer.json"),
1461
+ await fs5.writeFile(
1462
+ path4.join(devcontainerDir, "devcontainer.json"),
1079
1463
  JSON.stringify(devcontainerJson, null, 2) + "\n"
1080
1464
  );
1081
- const featuresDir = path2.join(devcontainerDir, "features");
1465
+ const featuresDir = path4.join(devcontainerDir, "features");
1082
1466
  if (existsSync2(featuresDir)) {
1083
- await fs2.rm(featuresDir, { recursive: true, force: true });
1467
+ await fs5.rm(featuresDir, { recursive: true, force: true });
1084
1468
  }
1085
1469
  const resolvedFeatures = resolveFeatures(opts);
1086
1470
  for (const f of resolvedFeatures) {
1087
1471
  if (!f.localSourceDir || !f.localName) continue;
1088
- const dest = path2.join(featuresDir, f.localName);
1089
- await fs2.mkdir(dest, { recursive: true });
1090
- await fs2.cp(f.localSourceDir, dest, { recursive: true });
1472
+ const dest = path4.join(featuresDir, f.localName);
1473
+ await fs5.mkdir(dest, { recursive: true });
1474
+ await fs5.cp(f.localSourceDir, dest, { recursive: true });
1091
1475
  }
1092
1476
  for (const f of resolvedFeatures) {
1093
1477
  for (const sub of f.persistentHomePaths) {
1094
- await fs2.mkdir(path2.join(homeDir, sub), { recursive: true });
1478
+ await fs5.mkdir(path4.join(homeDir, sub), { recursive: true });
1095
1479
  }
1096
1480
  for (const entry2 of f.persistentHomeFiles) {
1097
- const filePath = path2.join(homeDir, entry2.path);
1098
- await fs2.mkdir(path2.dirname(filePath), { recursive: true });
1481
+ const filePath = path4.join(homeDir, entry2.path);
1482
+ await fs5.mkdir(path4.dirname(filePath), { recursive: true });
1099
1483
  if (!existsSync2(filePath)) {
1100
- await fs2.writeFile(filePath, entry2.initialContent);
1484
+ await fs5.writeFile(filePath, entry2.initialContent);
1101
1485
  }
1102
1486
  }
1103
1487
  }
1104
1488
  await writePostCreateScript(devcontainerDir, opts);
1105
- const composePath = path2.join(devcontainerDir, "compose.yaml");
1489
+ const composePath = path4.join(devcontainerDir, "compose.yaml");
1106
1490
  if (needsCompose(opts)) {
1107
- await fs2.writeFile(composePath, buildComposeYaml(opts, dockerMode));
1491
+ await fs5.writeFile(composePath, buildComposeYaml(opts, dockerMode));
1108
1492
  } else if (existsSync2(composePath)) {
1109
- await fs2.rm(composePath);
1493
+ await fs5.rm(composePath);
1110
1494
  }
1111
- await fs2.writeFile(
1112
- path2.join(targetDir, `${opts.name}.code-workspace`),
1495
+ await fs5.writeFile(
1496
+ path4.join(targetDir, `${opts.name}.code-workspace`),
1113
1497
  JSON.stringify(buildCodeWorkspaceJson(opts), null, 2) + "\n"
1114
1498
  );
1115
1499
  }
@@ -1154,6 +1538,82 @@ function addAptPackagesToDoc(doc, packages) {
1154
1538
  }
1155
1539
  return changed;
1156
1540
  }
1541
+ function portOfItem(item) {
1542
+ const scalar = scalarValue(item);
1543
+ if (typeof scalar === "number" && Number.isInteger(scalar)) {
1544
+ return scalar;
1545
+ }
1546
+ if (isMap(item)) {
1547
+ const p = item.get("port");
1548
+ if (typeof p === "number" && Number.isInteger(p)) return p;
1549
+ }
1550
+ return null;
1551
+ }
1552
+ function ensureRoutingMap(doc) {
1553
+ const existing = doc.get("routing", true);
1554
+ if (existing && isMap(existing)) return existing;
1555
+ const map = new YAMLMap();
1556
+ doc.set("routing", map);
1557
+ return map;
1558
+ }
1559
+ function setDefaultPortInDoc(doc, port) {
1560
+ const routing = ensureRoutingMap(doc);
1561
+ const existing = routing.get("ports", true);
1562
+ let seq;
1563
+ if (existing && isSeq(existing)) {
1564
+ seq = existing;
1565
+ } else {
1566
+ seq = new YAMLSeq();
1567
+ routing.set("ports", seq);
1568
+ }
1569
+ const currentIdx = seq.items.findIndex((i) => portOfItem(i) === port);
1570
+ if (currentIdx === 0) return false;
1571
+ if (currentIdx > 0) {
1572
+ const [item] = seq.items.splice(currentIdx, 1);
1573
+ seq.items.unshift(item);
1574
+ return true;
1575
+ }
1576
+ seq.items.unshift(port);
1577
+ return true;
1578
+ }
1579
+ function addPortsToDoc(doc, ports) {
1580
+ const routing = ensureRoutingMap(doc);
1581
+ const existing = routing.get("ports", true);
1582
+ let seq;
1583
+ if (existing && isSeq(existing)) {
1584
+ seq = existing;
1585
+ } else {
1586
+ seq = new YAMLSeq();
1587
+ routing.set("ports", seq);
1588
+ }
1589
+ let changed = false;
1590
+ for (const port of ports) {
1591
+ if (seq.items.some((i) => portOfItem(i) === port)) continue;
1592
+ seq.add(port);
1593
+ changed = true;
1594
+ }
1595
+ return changed;
1596
+ }
1597
+ function removePortsFromDoc(doc, ports) {
1598
+ const routing = doc.get("routing", true);
1599
+ if (!routing || !isMap(routing)) return false;
1600
+ const seq = routing.get("ports", true);
1601
+ if (!seq || !isSeq(seq)) return false;
1602
+ const targets = new Set(ports);
1603
+ let changed = false;
1604
+ for (let i = seq.items.length - 1; i >= 0; i--) {
1605
+ const p = portOfItem(seq.items[i]);
1606
+ if (p !== null && targets.has(p)) {
1607
+ seq.items.splice(i, 1);
1608
+ changed = true;
1609
+ }
1610
+ }
1611
+ if (changed) {
1612
+ if (seq.items.length === 0) routing.delete("ports");
1613
+ if (routing.items.length === 0) doc.delete("routing");
1614
+ }
1615
+ return changed;
1616
+ }
1157
1617
  function addInstallUrlToDoc(doc, url) {
1158
1618
  const seq = ensureSeq(doc, "installUrls");
1159
1619
  if (seq.items.some((i) => scalarValue(i) === url)) return false;
@@ -1274,8 +1734,8 @@ function removeRepoFromDoc(doc, urlOrPath) {
1274
1734
  if (!isMap(item)) return false;
1275
1735
  const url = item.get("url");
1276
1736
  if (url === urlOrPath) return true;
1277
- const path13 = item.get("path");
1278
- const effectivePath = typeof path13 === "string" ? path13 : typeof url === "string" ? deriveRepoName(url) : void 0;
1737
+ const path15 = item.get("path");
1738
+ const effectivePath = typeof path15 === "string" ? path15 : typeof url === "string" ? deriveRepoName(url) : void 0;
1279
1739
  return effectivePath === urlOrPath;
1280
1740
  });
1281
1741
  if (idx < 0) return false;
@@ -1325,7 +1785,7 @@ async function runAddRepo(input) {
1325
1785
  "Missing repo URL. Usage: monoceros add-repo <containername> <url>."
1326
1786
  );
1327
1787
  }
1328
- const path13 = (input.path ?? deriveRepoName(url)).trim();
1788
+ const path15 = (input.path ?? deriveRepoName(url)).trim();
1329
1789
  const hasName = typeof input.gitName === "string" && input.gitName.trim().length > 0;
1330
1790
  const hasEmail = typeof input.gitEmail === "string" && input.gitEmail.trim().length > 0;
1331
1791
  if (hasName !== hasEmail) {
@@ -1354,7 +1814,7 @@ async function runAddRepo(input) {
1354
1814
  const providerToWrite = !canonical && explicitProvider ? explicitProvider : void 0;
1355
1815
  const entry2 = {
1356
1816
  url,
1357
- path: path13,
1817
+ path: path15,
1358
1818
  ...hasName && hasEmail ? {
1359
1819
  gitUser: {
1360
1820
  name: input.gitName.trim(),
@@ -1386,6 +1846,45 @@ function runAddFromUrl(input) {
1386
1846
  }
1387
1847
  return mutate(input, (doc) => addInstallUrlToDoc(doc, url));
1388
1848
  }
1849
+ async function runAddPort(input) {
1850
+ if (input.ports.length === 0) {
1851
+ throw new Error(
1852
+ "No ports given. Usage: monoceros add-port <containername> -- <port> [<port> \u2026]."
1853
+ );
1854
+ }
1855
+ const ports = normalizePorts(input.ports);
1856
+ if (input.asDefault && ports.length > 1) {
1857
+ throw new Error(
1858
+ `--default takes exactly one port. Got: ${ports.join(", ")}. Run add-port once with --default for the new default, then again (without --default) for the rest.`
1859
+ );
1860
+ }
1861
+ const result = await mutate(input, (doc) => {
1862
+ if (input.asDefault) {
1863
+ return setDefaultPortInDoc(doc, ports[0]);
1864
+ }
1865
+ return addPortsToDoc(doc, ports);
1866
+ });
1867
+ if (result.status === "updated") {
1868
+ await syncPortsToProxy(input);
1869
+ }
1870
+ return result;
1871
+ }
1872
+ function normalizePorts(raw) {
1873
+ const result = [];
1874
+ const seen = /* @__PURE__ */ new Set();
1875
+ for (const item of raw) {
1876
+ const n = typeof item === "number" ? item : Number(item);
1877
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
1878
+ throw new Error(
1879
+ `Invalid port: ${JSON.stringify(item)}. Expected an integer between 1 and 65535.`
1880
+ );
1881
+ }
1882
+ if (seen.has(n)) continue;
1883
+ seen.add(n);
1884
+ result.push(n);
1885
+ }
1886
+ return result;
1887
+ }
1389
1888
  function runAddFeature(input) {
1390
1889
  const ref = input.ref.trim();
1391
1890
  if (ref.length === 0) {
@@ -1427,6 +1926,19 @@ function runRemoveFromUrl(input) {
1427
1926
  }
1428
1927
  return mutate(input, (doc) => removeInstallUrlFromDoc(doc, url));
1429
1928
  }
1929
+ async function runRemovePort(input) {
1930
+ if (input.ports.length === 0) {
1931
+ throw new Error(
1932
+ "No ports given. Usage: monoceros remove-port <containername> -- <port> [<port> \u2026]."
1933
+ );
1934
+ }
1935
+ const ports = normalizePorts(input.ports);
1936
+ const result = await mutate(input, (doc) => removePortsFromDoc(doc, ports));
1937
+ if (result.status === "updated") {
1938
+ await syncPortsToProxy(input);
1939
+ }
1940
+ return result;
1941
+ }
1430
1942
  function runRemoveRepo(input) {
1431
1943
  const target = input.target.trim();
1432
1944
  if (target.length === 0) {
@@ -1447,7 +1959,7 @@ async function mutate(opts, apply) {
1447
1959
  const logger = opts.logger ?? defaultLogger();
1448
1960
  let oldText;
1449
1961
  try {
1450
- oldText = await fs3.readFile(ymlPath, "utf8");
1962
+ oldText = await fs6.readFile(ymlPath, "utf8");
1451
1963
  } catch {
1452
1964
  throw new Error(
1453
1965
  `No such config: ${ymlPath}. Run \`monoceros init <template> ${opts.name}\` first.`
@@ -1471,7 +1983,7 @@ async function mutate(opts, apply) {
1471
1983
  return { status: "aborted" };
1472
1984
  }
1473
1985
  }
1474
- await fs3.writeFile(ymlPath, newText, "utf8");
1986
+ await fs6.writeFile(ymlPath, newText, "utf8");
1475
1987
  logger.success(`Updated ${ymlPath}.`);
1476
1988
  logger.info(
1477
1989
  `Run \`monoceros apply ${opts.name}\` to rebuild the dev-container and pick up the change.`
@@ -1492,6 +2004,61 @@ var defaultConfirm = async (message) => {
1492
2004
  });
1493
2005
  return result === true;
1494
2006
  };
2007
+ async function syncPortsToProxy(input) {
2008
+ const home = input.monocerosHome ?? monocerosHome();
2009
+ const ymlPath = containerConfigPath(input.name, home);
2010
+ const logger = input.logger ?? defaultLogger();
2011
+ let allPorts;
2012
+ try {
2013
+ const parsed = await readConfig(ymlPath);
2014
+ allPorts = (parsed.config.routing?.ports ?? []).map(portNumber);
2015
+ } catch (err) {
2016
+ logger.warn(
2017
+ `Could not re-read yml after edit to sync Traefik routes: ${err instanceof Error ? err.message : String(err)}. The yml is correct; \`monoceros apply ${input.name}\` will rebuild the routes.`
2018
+ );
2019
+ return;
2020
+ }
2021
+ let hostPort = 80;
2022
+ try {
2023
+ const globalConfig = await readMonocerosConfig({ monocerosHome: home });
2024
+ hostPort = proxyHostPort(globalConfig);
2025
+ } catch {
2026
+ }
2027
+ if (allPorts.length > 0) {
2028
+ await preflightHostPort(hostPort, {
2029
+ ...input.proxyDocker ? { docker: input.proxyDocker } : {}
2030
+ });
2031
+ }
2032
+ try {
2033
+ if (allPorts.length > 0) {
2034
+ await writeDynamicConfig(input.name, allPorts, { monocerosHome: home });
2035
+ await ensureProxy({
2036
+ monocerosHome: home,
2037
+ hostPort,
2038
+ ...input.proxyDocker ? { docker: input.proxyDocker } : {},
2039
+ logger: { info: (m) => logger.info(m), warn: (m) => logger.warn(m) }
2040
+ });
2041
+ const urls = proxyUrlsFor(input.name, allPorts, hostPort);
2042
+ const lines = urls.map((u) => {
2043
+ const tag = u.isDefault ? " (default)" : "";
2044
+ return ` ${u.url}${tag}`;
2045
+ });
2046
+ logger.info(`Traefik routes refreshed:
2047
+ ${lines.join("\n")}`);
2048
+ } else {
2049
+ await removeDynamicConfig(input.name, { monocerosHome: home });
2050
+ await maybeStopProxy({
2051
+ monocerosHome: home,
2052
+ ...input.proxyDocker ? { docker: input.proxyDocker } : {},
2053
+ logger: { info: (m) => logger.info(m), warn: (m) => logger.warn(m) }
2054
+ });
2055
+ }
2056
+ } catch (err) {
2057
+ logger.warn(
2058
+ `Could not sync Traefik routes after yml edit: ${err instanceof Error ? err.message : String(err)}. The yml is correct; \`monoceros apply ${input.name}\` will rebuild the routes.`
2059
+ );
2060
+ }
2061
+ }
1495
2062
 
1496
2063
  // src/commands/add-apt-packages.ts
1497
2064
  var addAptPackagesCommand = defineCommand({
@@ -1785,10 +2352,64 @@ var addLanguageCommand = defineCommand5({
1785
2352
  }
1786
2353
  });
1787
2354
 
1788
- // src/commands/add-service.ts
2355
+ // src/commands/add-port.ts
1789
2356
  import { defineCommand as defineCommand6 } from "citty";
1790
2357
  import { consola as consola7 } from "consola";
1791
- var addServiceCommand = defineCommand6({
2358
+ var addPortCommand = defineCommand6({
2359
+ meta: {
2360
+ name: "add-port",
2361
+ group: "edit",
2362
+ 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)."
2363
+ },
2364
+ args: {
2365
+ name: {
2366
+ type: "positional",
2367
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
2368
+ required: true
2369
+ },
2370
+ yes: {
2371
+ type: "boolean",
2372
+ description: "Skip the interactive confirmation and apply the diff.",
2373
+ alias: ["y"],
2374
+ default: false
2375
+ },
2376
+ default: {
2377
+ type: "boolean",
2378
+ 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.",
2379
+ default: false
2380
+ }
2381
+ },
2382
+ async run({ args }) {
2383
+ const tokens = [...getInnerArgs()];
2384
+ if (tokens.length === 0) {
2385
+ consola7.error(
2386
+ "No ports given. Usage: `monoceros add-port <containername> [--yes] [--default] -- <port> [<port> \u2026]`."
2387
+ );
2388
+ process.exit(1);
2389
+ }
2390
+ try {
2391
+ const result = await runAddPort({
2392
+ name: args.name,
2393
+ ports: tokens.map(coerceToken),
2394
+ yes: args.yes,
2395
+ asDefault: args.default
2396
+ });
2397
+ process.exit(result.status === "aborted" ? 1 : 0);
2398
+ } catch (err) {
2399
+ consola7.error(err instanceof Error ? err.message : String(err));
2400
+ process.exit(1);
2401
+ }
2402
+ }
2403
+ });
2404
+ function coerceToken(raw) {
2405
+ const n = Number(raw);
2406
+ return Number.isFinite(n) ? n : raw;
2407
+ }
2408
+
2409
+ // src/commands/add-service.ts
2410
+ import { defineCommand as defineCommand7 } from "citty";
2411
+ import { consola as consola8 } from "consola";
2412
+ var addServiceCommand = defineCommand7({
1792
2413
  meta: {
1793
2414
  name: "add-service",
1794
2415
  group: "edit",
@@ -1821,81 +2442,22 @@ var addServiceCommand = defineCommand6({
1821
2442
  });
1822
2443
  process.exit(result.status === "aborted" ? 1 : 0);
1823
2444
  } catch (err) {
1824
- consola7.error(err instanceof Error ? err.message : String(err));
2445
+ consola8.error(err instanceof Error ? err.message : String(err));
1825
2446
  process.exit(1);
1826
2447
  }
1827
2448
  }
1828
2449
  });
1829
2450
 
1830
2451
  // src/commands/apply.ts
1831
- import { defineCommand as defineCommand7 } from "citty";
2452
+ import { defineCommand as defineCommand8 } from "citty";
1832
2453
 
1833
2454
  // src/apply/index.ts
1834
- import { existsSync as existsSync4, promises as fs8 } from "fs";
1835
- import { consola as consola10 } from "consola";
1836
-
1837
- // src/config/global.ts
1838
- import { promises as fs4 } from "fs";
1839
- import { z as z2 } from "zod";
1840
- import { parseDocument as parseDocument2 } from "yaml";
1841
- var SCHEMA_VERSION = 1;
1842
- var MonocerosConfigSchema = z2.object({
1843
- schemaVersion: z2.literal(SCHEMA_VERSION),
1844
- // .nullish() (= .optional().nullable()) on defaults so the shipped
1845
- // sample yml — where `defaults:` is uncommented but every sub-block
1846
- // is commented out — parses cleanly. YAML produces `defaults: null`
1847
- // in that case; without .nullish() the schema would reject it and
1848
- // we'd be back to forcing builders to comment-juggle three lines.
1849
- defaults: z2.object({
1850
- git: z2.object({
1851
- user: GitUserSchema.optional()
1852
- }).optional(),
1853
- features: z2.record(
1854
- z2.string().regex(
1855
- REGEX.featureRef,
1856
- "Invalid feature ref. Expected an OCI-image-style ref like 'ghcr.io/getmonoceros/monoceros-features/<name>:<tag>'."
1857
- ),
1858
- z2.record(z2.string(), FeatureOptionValueSchema)
1859
- ).optional()
1860
- }).nullish()
1861
- });
1862
- async function readMonocerosConfig(opts = {}) {
1863
- const home = opts.monocerosHome ?? monocerosHome();
1864
- const filePath = monocerosConfigPath(home);
1865
- let text;
1866
- try {
1867
- text = await fs4.readFile(filePath, "utf8");
1868
- } catch {
1869
- return void 0;
1870
- }
1871
- const doc = parseDocument2(text, { prettyErrors: true });
1872
- if (doc.errors.length > 0) {
1873
- throw new Error(
1874
- `yaml parse error in ${filePath}: ${doc.errors[0].message}`
1875
- );
1876
- }
1877
- const result = MonocerosConfigSchema.safeParse(doc.toJS());
1878
- if (!result.success) {
1879
- const issues = result.error.issues.map((issue) => {
1880
- const where = issue.path.length > 0 ? issue.path.join(".") : "(root)";
1881
- return ` - ${where}: ${issue.message}`;
1882
- }).join("\n");
1883
- throw new Error(
1884
- `Invalid ${filePath}:
1885
- ${issues}
1886
-
1887
- See ${filePath.replace(
1888
- /\.yml$/,
1889
- ".sample.yml"
1890
- )} for a valid example.`
1891
- );
1892
- }
1893
- return result.data;
1894
- }
2455
+ import { existsSync as existsSync4, promises as fs10 } from "fs";
2456
+ import { consola as consola11 } from "consola";
1895
2457
 
1896
2458
  // src/config/state.ts
1897
- import { promises as fs5 } from "fs";
1898
- import path3 from "path";
2459
+ import { promises as fs7 } from "fs";
2460
+ import path5 from "path";
1899
2461
  function buildStateFile(opts) {
1900
2462
  return {
1901
2463
  schemaVersion: CONFIG_SCHEMA_VERSION,
@@ -1905,20 +2467,20 @@ function buildStateFile(opts) {
1905
2467
  };
1906
2468
  }
1907
2469
  function stateFilePath(targetDir) {
1908
- return path3.join(targetDir, ".monoceros", "state.json");
2470
+ return path5.join(targetDir, ".monoceros", "state.json");
1909
2471
  }
1910
2472
  async function readStateFile(targetDir) {
1911
2473
  try {
1912
- const content = await fs5.readFile(stateFilePath(targetDir), "utf8");
2474
+ const content = await fs7.readFile(stateFilePath(targetDir), "utf8");
1913
2475
  return JSON.parse(content);
1914
2476
  } catch {
1915
2477
  return void 0;
1916
2478
  }
1917
2479
  }
1918
2480
  async function writeStateFile(targetDir, state) {
1919
- const monocerosDir = path3.join(targetDir, ".monoceros");
1920
- await fs5.mkdir(monocerosDir, { recursive: true });
1921
- await fs5.writeFile(
2481
+ const monocerosDir = path5.join(targetDir, ".monoceros");
2482
+ await fs7.mkdir(monocerosDir, { recursive: true });
2483
+ await fs7.writeFile(
1922
2484
  stateFilePath(targetDir),
1923
2485
  JSON.stringify(state, null, 2) + "\n"
1924
2486
  );
@@ -1960,6 +2522,21 @@ function solutionConfigToCreateOptions(config, featureDefaults = {}) {
1960
2522
  ...r.provider ? { provider: r.provider } : {}
1961
2523
  }));
1962
2524
  }
2525
+ const routingPorts = config.routing?.ports ?? [];
2526
+ if (routingPorts.length > 0) {
2527
+ const seen = /* @__PURE__ */ new Set();
2528
+ const ports = [];
2529
+ for (const entry2 of routingPorts) {
2530
+ const n = portNumber(entry2);
2531
+ if (seen.has(n)) continue;
2532
+ seen.add(n);
2533
+ ports.push(n);
2534
+ }
2535
+ result.ports = ports;
2536
+ }
2537
+ if (config.routing?.vscodeAutoForward !== void 0) {
2538
+ result.vscodeAutoForward = config.routing.vscodeAutoForward;
2539
+ }
1963
2540
  return result;
1964
2541
  }
1965
2542
 
@@ -1994,10 +2571,10 @@ var dim = stderrPalette.dim;
1994
2571
  var sectionLine = stderrPalette.sectionLine;
1995
2572
 
1996
2573
  // src/devcontainer/compose.ts
1997
- import { spawn as spawn2 } from "child_process";
2574
+ import { spawn as spawn3 } from "child_process";
1998
2575
  import { existsSync as existsSync3 } from "fs";
1999
- import path5 from "path";
2000
- import { consola as consola8 } from "consola";
2576
+ import path7 from "path";
2577
+ import { consola as consola9 } from "consola";
2001
2578
 
2002
2579
  // src/util/mask-secrets.ts
2003
2580
  import { Transform } from "stream";
@@ -2056,10 +2633,10 @@ function createSecretMaskStream() {
2056
2633
  }
2057
2634
 
2058
2635
  // src/devcontainer/cli.ts
2059
- import { spawn } from "child_process";
2636
+ import { spawn as spawn2 } from "child_process";
2060
2637
  import { readFileSync as readFileSync2 } from "fs";
2061
2638
  import { createRequire } from "module";
2062
- import path4 from "path";
2639
+ import path6 from "path";
2063
2640
  var require_ = createRequire(import.meta.url);
2064
2641
  var cachedBinaryPath = null;
2065
2642
  function devcontainerCliPath() {
@@ -2070,14 +2647,14 @@ function devcontainerCliPath() {
2070
2647
  if (!binEntry) {
2071
2648
  throw new Error("Could not resolve @devcontainers/cli bin entry.");
2072
2649
  }
2073
- cachedBinaryPath = path4.resolve(path4.dirname(pkgJsonPath), binEntry);
2650
+ cachedBinaryPath = path6.resolve(path6.dirname(pkgJsonPath), binEntry);
2074
2651
  return cachedBinaryPath;
2075
2652
  }
2076
2653
  var spawnDevcontainer = (args, cwd, options = {}) => {
2077
2654
  const binPath = devcontainerCliPath();
2078
2655
  return new Promise((resolve, reject) => {
2079
2656
  if (options.interactive) {
2080
- const child2 = spawn(process.execPath, [binPath, ...args], {
2657
+ const child2 = spawn2(process.execPath, [binPath, ...args], {
2081
2658
  cwd,
2082
2659
  stdio: "inherit"
2083
2660
  });
@@ -2085,7 +2662,7 @@ var spawnDevcontainer = (args, cwd, options = {}) => {
2085
2662
  child2.on("exit", (code) => resolve(code ?? 0));
2086
2663
  return;
2087
2664
  }
2088
- const child = spawn(process.execPath, [binPath, ...args], {
2665
+ const child = spawn2(process.execPath, [binPath, ...args], {
2089
2666
  cwd,
2090
2667
  stdio: ["ignore", "pipe", "pipe"]
2091
2668
  });
@@ -2119,7 +2696,7 @@ var spawnDevcontainer = (args, cwd, options = {}) => {
2119
2696
  // src/devcontainer/compose.ts
2120
2697
  var spawnDockerCompose = (args, cwd) => {
2121
2698
  return new Promise((resolve, reject) => {
2122
- const child = spawn2("docker", ["compose", ...args], {
2699
+ const child = spawn3("docker", ["compose", ...args], {
2123
2700
  cwd,
2124
2701
  stdio: ["inherit", "pipe", "pipe"]
2125
2702
  });
@@ -2131,7 +2708,7 @@ var spawnDockerCompose = (args, cwd) => {
2131
2708
  };
2132
2709
  var spawnBash = (args, cwd) => {
2133
2710
  return new Promise((resolve, reject) => {
2134
- const child = spawn2("bash", args, {
2711
+ const child = spawn3("bash", args, {
2135
2712
  cwd,
2136
2713
  stdio: ["inherit", "pipe", "pipe"]
2137
2714
  });
@@ -2142,15 +2719,15 @@ var spawnBash = (args, cwd) => {
2142
2719
  });
2143
2720
  };
2144
2721
  function composeProjectName(root) {
2145
- return `${path5.basename(root)}_devcontainer`;
2722
+ return `${path7.basename(root)}_devcontainer`;
2146
2723
  }
2147
2724
  function resolveCompose(root) {
2148
- if (!existsSync3(path5.join(root, ".devcontainer"))) {
2725
+ if (!existsSync3(path7.join(root, ".devcontainer"))) {
2149
2726
  throw new Error(
2150
2727
  `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
2151
2728
  );
2152
2729
  }
2153
- const composeFile = path5.join(root, ".devcontainer", "compose.yaml");
2730
+ const composeFile = path7.join(root, ".devcontainer", "compose.yaml");
2154
2731
  if (!existsSync3(composeFile)) {
2155
2732
  throw new Error(
2156
2733
  `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.`
@@ -2166,7 +2743,7 @@ async function runComposeAction(buildSubArgs, opts) {
2166
2743
  }
2167
2744
  async function runStart(opts) {
2168
2745
  resolveCompose(opts.root);
2169
- const logger = opts.logger ?? { info: (msg) => consola8.info(msg) };
2746
+ const logger = opts.logger ?? { info: (msg) => consola9.info(msg) };
2170
2747
  const spawnFn = opts.spawn ?? spawnDevcontainer;
2171
2748
  logger.info(`Bringing devcontainer up at ${opts.root}\u2026`);
2172
2749
  return spawnFn(
@@ -2241,12 +2818,12 @@ function runLogs(opts) {
2241
2818
  }
2242
2819
 
2243
2820
  // src/devcontainer/credentials.ts
2244
- import { spawn as spawn3 } from "child_process";
2245
- import { promises as fs6 } from "fs";
2246
- import path6 from "path";
2821
+ import { spawn as spawn4 } from "child_process";
2822
+ import { promises as fs8 } from "fs";
2823
+ import path8 from "path";
2247
2824
  var realGitCredentialFill = (input) => {
2248
2825
  return new Promise((resolve, reject) => {
2249
- const child = spawn3("git", ["credential", "fill"], {
2826
+ const child = spawn4("git", ["credential", "fill"], {
2250
2827
  stdio: ["pipe", "pipe", "inherit"],
2251
2828
  env: {
2252
2829
  ...process.env,
@@ -2414,8 +2991,8 @@ function formatCredentialLine(host, username, password) {
2414
2991
  return `https://${encUser}:${encPass}@${host}`;
2415
2992
  }
2416
2993
  async function collectGitCredentials(devContainerRoot, hosts, options = {}) {
2417
- const credsDir = path6.join(devContainerRoot, ".monoceros");
2418
- const credentialsPath = path6.join(credsDir, "git-credentials");
2994
+ const credsDir = path8.join(devContainerRoot, ".monoceros");
2995
+ const credentialsPath = path8.join(credsDir, "git-credentials");
2419
2996
  const spawnFn = options.spawn ?? realGitCredentialFill;
2420
2997
  const logger = options.logger ?? { info: () => {
2421
2998
  }, warn: () => {
@@ -2468,8 +3045,8 @@ host=${host}
2468
3045
  lines.push(formatCredentialLine(host, username, password));
2469
3046
  perHost.push({ host, provider, status: "ok", detail: "" });
2470
3047
  }
2471
- await fs6.mkdir(credsDir, { recursive: true });
2472
- await fs6.writeFile(
3048
+ await fs8.mkdir(credsDir, { recursive: true });
3049
+ await fs8.writeFile(
2473
3050
  credentialsPath,
2474
3051
  lines.join("\n") + (lines.length > 0 ? "\n" : ""),
2475
3052
  {
@@ -2528,10 +3105,10 @@ function formatUnknownProviderError(hosts) {
2528
3105
  }
2529
3106
 
2530
3107
  // src/devcontainer/repo-reachability.ts
2531
- import { spawn as spawn4 } from "child_process";
3108
+ import { spawn as spawn5 } from "child_process";
2532
3109
  var realGitLsRemote = (url) => {
2533
3110
  return new Promise((resolve, reject) => {
2534
- const child = spawn4("git", ["ls-remote", "--heads", "--", url], {
3111
+ const child = spawn5("git", ["ls-remote", "--heads", "--", url], {
2535
3112
  stdio: ["ignore", "pipe", "pipe"],
2536
3113
  env: {
2537
3114
  ...process.env,
@@ -2669,10 +3246,10 @@ function adviceForKind(kind) {
2669
3246
  }
2670
3247
 
2671
3248
  // src/devcontainer/docker-mode.ts
2672
- import { spawn as spawn5 } from "child_process";
3249
+ import { spawn as spawn6 } from "child_process";
2673
3250
  var realDockerInfo = () => {
2674
3251
  return new Promise((resolve, reject) => {
2675
- const child = spawn5(
3252
+ const child = spawn6(
2676
3253
  "docker",
2677
3254
  ["info", "--format", "{{json .SecurityOptions}}"],
2678
3255
  {
@@ -2732,13 +3309,13 @@ function formatRootlessNotSupportedError() {
2732
3309
  }
2733
3310
 
2734
3311
  // src/devcontainer/identity.ts
2735
- import { spawn as spawn6 } from "child_process";
2736
- import { promises as fs7 } from "fs";
2737
- import path7 from "path";
2738
- import { consola as consola9 } from "consola";
3312
+ import { spawn as spawn7 } from "child_process";
3313
+ import { promises as fs9 } from "fs";
3314
+ import path9 from "path";
3315
+ import { consola as consola10 } from "consola";
2739
3316
  var realGitConfigGet = (key) => {
2740
3317
  return new Promise((resolve, reject) => {
2741
- const child = spawn6("git", ["config", "--global", "--get", key], {
3318
+ const child = spawn7("git", ["config", "--global", "--get", key], {
2742
3319
  stdio: ["ignore", "pipe", "inherit"]
2743
3320
  });
2744
3321
  let stdout = "";
@@ -2757,14 +3334,14 @@ var realIdentityPrompt = async (key) => {
2757
3334
  return void 0;
2758
3335
  }
2759
3336
  const label = key === "user.name" ? "Git user.name for this dev container (full name)" : "Git user.email for this dev container";
2760
- const value = await consola9.prompt(`${label}:`, { type: "text" });
3337
+ const value = await consola10.prompt(`${label}:`, { type: "text" });
2761
3338
  if (typeof value !== "string") return void 0;
2762
3339
  const trimmed = value.trim();
2763
3340
  return trimmed.length > 0 ? trimmed : void 0;
2764
3341
  };
2765
3342
  async function collectGitIdentity(devContainerRoot, options = {}) {
2766
- const gitconfigDir = path7.join(devContainerRoot, ".monoceros");
2767
- const gitconfigPath = path7.join(gitconfigDir, "gitconfig");
3343
+ const gitconfigDir = path9.join(devContainerRoot, ".monoceros");
3344
+ const gitconfigPath = path9.join(gitconfigDir, "gitconfig");
2768
3345
  const spawnFn = options.spawn ?? realGitConfigGet;
2769
3346
  const promptFn = options.prompt ?? realIdentityPrompt;
2770
3347
  const logger = options.logger ?? { info: () => {
@@ -2790,8 +3367,8 @@ async function collectGitIdentity(devContainerRoot, options = {}) {
2790
3367
  const lines = ["[user]"];
2791
3368
  if (name !== void 0) lines.push(` name = ${name}`);
2792
3369
  if (email !== void 0) lines.push(` email = ${email}`);
2793
- await fs7.mkdir(gitconfigDir, { recursive: true });
2794
- await fs7.writeFile(gitconfigPath, lines.join("\n") + "\n");
3370
+ await fs9.mkdir(gitconfigDir, { recursive: true });
3371
+ await fs9.writeFile(gitconfigPath, lines.join("\n") + "\n");
2795
3372
  return {
2796
3373
  ...name !== void 0 ? { name } : {},
2797
3374
  ...email !== void 0 ? { email } : {},
@@ -2833,7 +3410,7 @@ async function readKeyFromHost(spawnFn, key, logger) {
2833
3410
  }
2834
3411
  async function readExistingGitconfig(filePath) {
2835
3412
  try {
2836
- const content = await fs7.readFile(filePath, "utf8");
3413
+ const content = await fs9.readFile(filePath, "utf8");
2837
3414
  const result = {};
2838
3415
  const nameMatch = /^\s*name\s*=\s*(.+?)\s*$/m.exec(content);
2839
3416
  const emailMatch = /^\s*email\s*=\s*(.+?)\s*$/m.exec(content);
@@ -2849,9 +3426,9 @@ async function readExistingGitconfig(filePath) {
2849
3426
  async function runApply(opts) {
2850
3427
  const home = opts.monocerosHome ?? monocerosHome();
2851
3428
  const logger = opts.logger ?? {
2852
- info: (msg) => consola10.info(msg),
2853
- success: (msg) => consola10.success(msg),
2854
- warn: (msg) => consola10.warn(msg),
3429
+ info: (msg) => consola11.info(msg),
3430
+ success: (msg) => consola11.success(msg),
3431
+ warn: (msg) => consola11.warn(msg),
2855
3432
  // Default section renderer: empty line, bold-underlined "▸ Label",
2856
3433
  // empty line. Mirrors install.sh's section visuals.
2857
3434
  section: (label) => process.stderr.write(`
@@ -2933,7 +3510,7 @@ ${sectionLine(label)}
2933
3510
  if (dockerMode === "rootless") {
2934
3511
  throw new Error(formatRootlessNotSupportedError());
2935
3512
  }
2936
- await fs8.mkdir(targetDir, { recursive: true });
3513
+ await fs10.mkdir(targetDir, { recursive: true });
2937
3514
  await writeScaffold(createOpts, targetDir, { dockerMode });
2938
3515
  await writeStateFile(
2939
3516
  targetDir,
@@ -2954,6 +3531,30 @@ ${sectionLine(label)}
2954
3531
  'Pulling runtime image and building feature layers. First apply takes ~1\u20132 min (Docker downloads the multi-arch base); subsequent applies are cached and fast. devcontainer-cli may log a "No manifest found" line \u2014 harmless, the pull continues.'
2955
3532
  )
2956
3533
  );
3534
+ const ports = createOpts.ports ?? [];
3535
+ const hasPorts = ports.length > 0;
3536
+ if (hasPorts) {
3537
+ await preflightHostPort(proxyHostPort(globalConfig), {
3538
+ ...opts.proxyDocker ? { docker: opts.proxyDocker } : {}
3539
+ });
3540
+ }
3541
+ try {
3542
+ if (hasPorts) {
3543
+ await writeDynamicConfig(opts.name, ports, { monocerosHome: home });
3544
+ await ensureProxy({
3545
+ ...opts.proxyDocker ? { docker: opts.proxyDocker } : {},
3546
+ monocerosHome: home,
3547
+ hostPort: proxyHostPort(globalConfig),
3548
+ logger
3549
+ });
3550
+ } else {
3551
+ await removeDynamicConfig(opts.name, { monocerosHome: home });
3552
+ }
3553
+ } catch (err) {
3554
+ logger.warn?.(
3555
+ `Could not sync Traefik routes: ${err instanceof Error ? err.message : String(err)}. The container will start, but \`<name>.localhost\` routing may not work until the next \`monoceros apply\`.`
3556
+ );
3557
+ }
2957
3558
  const exitCode = await runContainerCycle(targetDir, {
2958
3559
  hasCompose: needsCompose(createOpts),
2959
3560
  ...opts.cleanupSpawn !== void 0 ? { cleanupSpawn: opts.cleanupSpawn } : {},
@@ -2968,7 +3569,7 @@ ${sectionLine(label)}
2968
3569
  }
2969
3570
  async function assertSafeTargetDir(targetDir, expectedOrigin) {
2970
3571
  if (!existsSync4(targetDir)) return;
2971
- const entries = await fs8.readdir(targetDir);
3572
+ const entries = await fs10.readdir(targetDir);
2972
3573
  if (entries.length === 0) return;
2973
3574
  const state = await readStateFile(targetDir);
2974
3575
  if (state) {
@@ -3010,22 +3611,22 @@ function warnOnDeprecatedFeatureRefs(containerFeatures, globalConfig, logger) {
3010
3611
  }
3011
3612
 
3012
3613
  // src/version.ts
3013
- var CLI_VERSION = true ? "1.6.12" : "dev";
3614
+ var CLI_VERSION = true ? "1.7.1" : "dev";
3014
3615
 
3015
3616
  // src/commands/_dispatch.ts
3016
- import { consola as consola11 } from "consola";
3617
+ import { consola as consola12 } from "consola";
3017
3618
  async function dispatch(runner) {
3018
3619
  try {
3019
3620
  const exitCode = await runner();
3020
3621
  process.exit(exitCode);
3021
3622
  } catch (err) {
3022
- consola11.error(err instanceof Error ? err.message : String(err));
3623
+ consola12.error(err instanceof Error ? err.message : String(err));
3023
3624
  process.exit(1);
3024
3625
  }
3025
3626
  }
3026
3627
 
3027
3628
  // src/commands/apply.ts
3028
- var applyCommand = defineCommand7({
3629
+ var applyCommand = defineCommand8({
3029
3630
  meta: {
3030
3631
  name: "apply",
3031
3632
  group: "lifecycle",
@@ -3050,7 +3651,7 @@ var applyCommand = defineCommand7({
3050
3651
  });
3051
3652
 
3052
3653
  // src/commands/completion.ts
3053
- import { defineCommand as defineCommand8 } from "citty";
3654
+ import { defineCommand as defineCommand9 } from "citty";
3054
3655
  var ALL_COMMANDS = [
3055
3656
  "init",
3056
3657
  "list-components",
@@ -3069,12 +3670,15 @@ var ALL_COMMANDS = [
3069
3670
  "add-feature",
3070
3671
  "add-from-url",
3071
3672
  "add-repo",
3673
+ "add-port",
3072
3674
  "remove-service",
3073
3675
  "remove-language",
3074
3676
  "remove-apt-packages",
3075
3677
  "remove-feature",
3076
3678
  "remove-from-url",
3077
3679
  "remove-repo",
3680
+ "remove-port",
3681
+ "port",
3078
3682
  "completion"
3079
3683
  ];
3080
3684
  var COMMANDS_WITH_CONTAINER_ARG = [
@@ -3092,12 +3696,15 @@ var COMMANDS_WITH_CONTAINER_ARG = [
3092
3696
  "add-feature",
3093
3697
  "add-from-url",
3094
3698
  "add-repo",
3699
+ "add-port",
3095
3700
  "remove-service",
3096
3701
  "remove-language",
3097
3702
  "remove-apt-packages",
3098
3703
  "remove-feature",
3099
3704
  "remove-from-url",
3100
- "remove-repo"
3705
+ "remove-repo",
3706
+ "remove-port",
3707
+ "port"
3101
3708
  ];
3102
3709
  var SHELLS = ["bash", "zsh", "pwsh"];
3103
3710
  function renderCompletionScript(shell) {
@@ -3230,7 +3837,7 @@ function renderCompletionScript(shell) {
3230
3837
  ""
3231
3838
  ].join("\n");
3232
3839
  }
3233
- var completionCommand = defineCommand8({
3840
+ var completionCommand = defineCommand9({
3234
3841
  meta: {
3235
3842
  name: "completion",
3236
3843
  group: "tooling",
@@ -3257,16 +3864,16 @@ var completionCommand = defineCommand8({
3257
3864
  });
3258
3865
 
3259
3866
  // src/commands/init.ts
3260
- import { defineCommand as defineCommand9 } from "citty";
3261
- import { consola as consola13 } from "consola";
3867
+ import { defineCommand as defineCommand10 } from "citty";
3868
+ import { consola as consola14 } from "consola";
3262
3869
 
3263
3870
  // src/init/index.ts
3264
- import { existsSync as existsSync7, promises as fs10 } from "fs";
3265
- import { consola as consola12 } from "consola";
3871
+ import { existsSync as existsSync7, promises as fs12 } from "fs";
3872
+ import { consola as consola13 } from "consola";
3266
3873
 
3267
3874
  // src/init/components.ts
3268
- import { existsSync as existsSync5, promises as fs9 } from "fs";
3269
- import path8 from "path";
3875
+ import { existsSync as existsSync5, promises as fs11 } from "fs";
3876
+ import path10 from "path";
3270
3877
  import { z as z3 } from "zod";
3271
3878
  import { parse as parseYaml } from "yaml";
3272
3879
  var CategorySchema = z3.enum(["language", "service", "feature"]);
@@ -3321,17 +3928,17 @@ async function loadComponentCatalog(rootDir = componentsDir()) {
3321
3928
  return out;
3322
3929
  }
3323
3930
  async function walk(baseDir, currentDir, out) {
3324
- const entries = await fs9.readdir(currentDir, { withFileTypes: true });
3931
+ const entries = await fs11.readdir(currentDir, { withFileTypes: true });
3325
3932
  for (const entry2 of entries) {
3326
- const full = path8.join(currentDir, entry2.name);
3933
+ const full = path10.join(currentDir, entry2.name);
3327
3934
  if (entry2.isDirectory()) {
3328
3935
  await walk(baseDir, full, out);
3329
3936
  continue;
3330
3937
  }
3331
3938
  if (!entry2.isFile() || !entry2.name.endsWith(".yml")) continue;
3332
- const relative = path8.relative(baseDir, full);
3333
- const name = relative.replace(/\.yml$/, "").split(path8.sep).join("/");
3334
- const text = await fs9.readFile(full, "utf8");
3939
+ const relative = path10.relative(baseDir, full);
3940
+ const name = relative.replace(/\.yml$/, "").split(path10.sep).join("/");
3941
+ const text = await fs11.readFile(full, "utf8");
3335
3942
  let raw;
3336
3943
  try {
3337
3944
  raw = parseYaml(text);
@@ -3439,7 +4046,7 @@ var SCHEMA_HEADER = [
3439
4046
  "# under `features:` also accepts options not shown here \u2014 check",
3440
4047
  "# the feature's `devcontainer-feature.json` for the full list."
3441
4048
  ];
3442
- function generateComposedYml(name, components, lookupManifest, repoUrls = []) {
4049
+ function generateComposedYml(name, components, lookupManifest, repoUrls = [], ports = []) {
3443
4050
  const merged = mergeComponents(components);
3444
4051
  const lines = [];
3445
4052
  for (const h of SCHEMA_HEADER) lines.push(h);
@@ -3478,9 +4085,12 @@ function generateComposedYml(name, components, lookupManifest, repoUrls = []) {
3478
4085
  false
3479
4086
  );
3480
4087
  }
4088
+ if (ports.length > 0) {
4089
+ renderActiveRoutingBlock(lines, ports);
4090
+ }
3481
4091
  return ensureTrailingNewline(lines.join("\n"));
3482
4092
  }
3483
- function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = []) {
4093
+ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = [], ports = []) {
3484
4094
  const byCategory = groupByCategory(catalog);
3485
4095
  const lines = [];
3486
4096
  for (const h of SCHEMA_HEADER) lines.push(h);
@@ -3587,6 +4197,11 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = []) {
3587
4197
  /* commented */
3588
4198
  repoUrls.length === 0
3589
4199
  );
4200
+ if (ports.length > 0) {
4201
+ renderActiveRoutingBlock(lines, ports);
4202
+ } else {
4203
+ renderRoutingHintBlock(lines);
4204
+ }
3590
4205
  return ensureTrailingNewline(lines.join("\n"));
3591
4206
  }
3592
4207
  var COMMENT_WIDTH = 72;
@@ -3686,6 +4301,47 @@ function renderReposBlock(out, urls, commented) {
3686
4301
  }
3687
4302
  out.push("");
3688
4303
  }
4304
+ function renderRoutingHintBlock(out) {
4305
+ out.push("# Routing \u2014 expose container ports to the host through the");
4306
+ out.push("# shared Traefik singleton. Once any port is declared the");
4307
+ out.push("# container joins the monoceros-proxy network and the proxy");
4308
+ out.push("# routes <name>.localhost (default port) and");
4309
+ out.push("# <name>-<port>.localhost (explicit). `monoceros add-port`");
4310
+ out.push("# manages the list; the block appears on first add. You can");
4311
+ out.push("# also pre-seed at init time via `--with-ports=3000,5173,\u2026`.");
4312
+ out.push("#");
4313
+ out.push("# routing:");
4314
+ out.push("# ports: # internal container ports");
4315
+ out.push(
4316
+ "# - 3000 # first entry doubles as <name>.localhost"
4317
+ );
4318
+ out.push("# - 5173");
4319
+ out.push(
4320
+ "# vscodeAutoForward: false # default: false. Traefik is the single"
4321
+ );
4322
+ out.push(
4323
+ "# # source of truth \u2014 set true only if you"
4324
+ );
4325
+ out.push(
4326
+ "# # want VS Code's port panel as primary."
4327
+ );
4328
+ out.push("");
4329
+ }
4330
+ function renderActiveRoutingBlock(out, ports) {
4331
+ out.push("# Routing \u2014 expose these container ports to the host through");
4332
+ out.push("# the shared Traefik singleton. First entry doubles as");
4333
+ out.push("# http://<name>.localhost (the default route). See ADR 0007.");
4334
+ out.push("routing:");
4335
+ out.push(" ports:");
4336
+ ports.forEach((port, idx) => {
4337
+ if (idx === 0) {
4338
+ out.push(` - ${port} # default \u2192 http://<name>.localhost`);
4339
+ } else {
4340
+ out.push(` - ${port}`);
4341
+ }
4342
+ });
4343
+ out.push("");
4344
+ }
3689
4345
  function deriveDefaultPath(url) {
3690
4346
  let last = url;
3691
4347
  const slash = url.lastIndexOf("/");
@@ -3740,10 +4396,10 @@ function ensureTrailingNewline(s) {
3740
4396
 
3741
4397
  // src/init/manifest.ts
3742
4398
  import { existsSync as existsSync6, readFileSync as readFileSync3 } from "fs";
3743
- import path9 from "path";
4399
+ import path11 from "path";
3744
4400
  function resolveManifestPath(name, checkoutRoot) {
3745
4401
  if (checkoutRoot) {
3746
- const checkoutPath = path9.join(
4402
+ const checkoutPath = path11.join(
3747
4403
  checkoutRoot,
3748
4404
  "images",
3749
4405
  "features",
@@ -3752,7 +4408,7 @@ function resolveManifestPath(name, checkoutRoot) {
3752
4408
  );
3753
4409
  if (existsSync6(checkoutPath)) return checkoutPath;
3754
4410
  }
3755
- const bundlePath = path9.join(
4411
+ const bundlePath = path11.join(
3756
4412
  bundledFeaturesDir(),
3757
4413
  name,
3758
4414
  "devcontainer-feature.json"
@@ -3795,8 +4451,8 @@ async function runInit(opts) {
3795
4451
  const workbench = opts.workbenchRoot ?? workbenchRoot();
3796
4452
  const home = opts.monocerosHome ?? monocerosHome();
3797
4453
  const logger = opts.logger ?? {
3798
- success: (msg) => consola12.success(msg),
3799
- info: (msg) => consola12.info(msg)
4454
+ success: (msg) => consola13.success(msg),
4455
+ info: (msg) => consola13.info(msg)
3800
4456
  };
3801
4457
  if (!REGEX.solutionName.test(opts.name)) {
3802
4458
  throw new Error(
@@ -3849,16 +4505,29 @@ async function runInit(opts) {
3849
4505
  );
3850
4506
  }
3851
4507
  }
4508
+ const portsRaw = opts.withPorts ?? [];
4509
+ const ports = [];
4510
+ const seenPorts = /* @__PURE__ */ new Set();
4511
+ for (const raw of portsRaw) {
4512
+ if (!Number.isInteger(raw) || raw < 1 || raw > 65535) {
4513
+ throw new Error(
4514
+ `Invalid port in --with-ports: ${JSON.stringify(raw)}. Expected integers between 1 and 65535.`
4515
+ );
4516
+ }
4517
+ if (seenPorts.has(raw)) continue;
4518
+ seenPorts.add(raw);
4519
+ ports.push(raw);
4520
+ }
3852
4521
  let text;
3853
4522
  const requested = opts.with ?? [];
3854
4523
  if (requested.length === 0) {
3855
- text = generateDocumentedYml(opts.name, catalog, lookup, repos);
4524
+ text = generateDocumentedYml(opts.name, catalog, lookup, repos, ports);
3856
4525
  } else {
3857
4526
  const components = resolveComponents(catalog, requested);
3858
- text = generateComposedYml(opts.name, components, lookup, repos);
4527
+ text = generateComposedYml(opts.name, components, lookup, repos, ports);
3859
4528
  }
3860
- await fs10.mkdir(containerConfigsDir(home), { recursive: true });
3861
- await fs10.writeFile(dest, text, "utf8");
4529
+ await fs12.mkdir(containerConfigsDir(home), { recursive: true });
4530
+ await fs12.writeFile(dest, text, "utf8");
3862
4531
  const documented = requested.length === 0;
3863
4532
  const displayPath = prettyPath(dest);
3864
4533
  if (documented) {
@@ -3877,7 +4546,7 @@ async function runInit(opts) {
3877
4546
  }
3878
4547
 
3879
4548
  // src/commands/init.ts
3880
- var initCommand = defineCommand9({
4549
+ var initCommand = defineCommand10({
3881
4550
  meta: {
3882
4551
  name: "init",
3883
4552
  group: "lifecycle",
@@ -3898,23 +4567,65 @@ var initCommand = defineCommand9({
3898
4567
  type: "string",
3899
4568
  description: "Git URL of a repo to clone into projects/ on first apply. Repeatable: pass --with-repo=URL1 --with-repo=URL2 for multiple repos. Folder name derived from URL (foo.git \u2192 projects/foo/); use `monoceros add-repo --path=...` post-init for subfolder paths.",
3900
4569
  required: false
4570
+ },
4571
+ "with-ports": {
4572
+ type: "string",
4573
+ description: "Comma-separated list of container-internal ports to expose via Traefik, e.g. --with-ports=3000,5173,6006. First entry doubles as http://<name>.localhost (default route). Equivalent to `monoceros add-port` after init. Each must be an integer in 1\u201365535.",
4574
+ required: false
3901
4575
  }
3902
4576
  },
3903
4577
  async run({ args, rawArgs }) {
3904
4578
  try {
3905
4579
  const withList = collectWithList(args.with, rawArgs);
3906
4580
  const withRepoList = collectWithRepoList(rawArgs);
4581
+ const withPortsList = collectWithPortsList(args["with-ports"], rawArgs);
3907
4582
  await runInit({
3908
4583
  name: args.name,
3909
4584
  ...withList ? { with: withList } : {},
3910
- ...withRepoList.length > 0 ? { withRepo: withRepoList } : {}
4585
+ ...withRepoList.length > 0 ? { withRepo: withRepoList } : {},
4586
+ ...withPortsList && withPortsList.length > 0 ? { withPorts: withPortsList } : {}
3911
4587
  });
3912
4588
  } catch (err) {
3913
- consola13.error(err instanceof Error ? err.message : String(err));
4589
+ consola14.error(err instanceof Error ? err.message : String(err));
3914
4590
  process.exit(1);
3915
4591
  }
3916
4592
  }
3917
4593
  });
4594
+ function collectWithPortsList(_withPortsArg, rawArgs) {
4595
+ const pieces = [];
4596
+ for (let i = 0; i < rawArgs.length; i += 1) {
4597
+ const t = rawArgs[i];
4598
+ let scanStart = -1;
4599
+ if (t === "--with-ports") {
4600
+ scanStart = i + 1;
4601
+ } else if (t.startsWith("--with-ports=")) {
4602
+ pieces.push(t.slice("--with-ports=".length));
4603
+ scanStart = i + 1;
4604
+ }
4605
+ if (scanStart < 0) continue;
4606
+ let j = scanStart;
4607
+ while (j < rawArgs.length) {
4608
+ const u = rawArgs[j];
4609
+ if (u.startsWith("-")) break;
4610
+ pieces.push(u);
4611
+ j += 1;
4612
+ }
4613
+ i = j - 1;
4614
+ }
4615
+ const parts = pieces.flatMap((s) => s.split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
4616
+ if (parts.length === 0) return void 0;
4617
+ const out = [];
4618
+ for (const p of parts) {
4619
+ const n = Number(p);
4620
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
4621
+ throw new Error(
4622
+ `Invalid port in --with-ports: ${JSON.stringify(p)}. Expected integers between 1 and 65535, comma-separated.`
4623
+ );
4624
+ }
4625
+ out.push(n);
4626
+ }
4627
+ return out;
4628
+ }
3918
4629
  function collectWithRepoList(rawArgs) {
3919
4630
  const urls = [];
3920
4631
  for (let i = 0; i < rawArgs.length; i += 1) {
@@ -3954,8 +4665,8 @@ function collectWithList(withArg, rawArgs) {
3954
4665
  }
3955
4666
 
3956
4667
  // src/commands/list-components.ts
3957
- import { defineCommand as defineCommand10 } from "citty";
3958
- import { consola as consola14 } from "consola";
4668
+ import { defineCommand as defineCommand11 } from "citty";
4669
+ import { consola as consola15 } from "consola";
3959
4670
  var CATEGORY_LABELS = {
3960
4671
  language: "Languages",
3961
4672
  service: "Services",
@@ -3966,7 +4677,7 @@ var CATEGORY_ORDER = [
3966
4677
  "service",
3967
4678
  "feature"
3968
4679
  ];
3969
- var listComponentsCommand = defineCommand10({
4680
+ var listComponentsCommand = defineCommand11({
3970
4681
  meta: {
3971
4682
  name: "list-components",
3972
4683
  group: "discovery",
@@ -3977,7 +4688,7 @@ var listComponentsCommand = defineCommand10({
3977
4688
  try {
3978
4689
  const catalog = await loadComponentCatalog();
3979
4690
  if (catalog.size === 0) {
3980
- consola14.warn(
4691
+ consola15.warn(
3981
4692
  "No components found. The workbench checkout looks incomplete."
3982
4693
  );
3983
4694
  process.exit(0);
@@ -4028,15 +4739,15 @@ var listComponentsCommand = defineCommand10({
4028
4739
  }
4029
4740
  process.exit(0);
4030
4741
  } catch (err) {
4031
- consola14.error(err instanceof Error ? err.message : String(err));
4742
+ consola15.error(err instanceof Error ? err.message : String(err));
4032
4743
  process.exit(1);
4033
4744
  }
4034
4745
  }
4035
4746
  });
4036
4747
 
4037
4748
  // src/commands/logs.ts
4038
- import { defineCommand as defineCommand11 } from "citty";
4039
- var logsCommand = defineCommand11({
4749
+ import { defineCommand as defineCommand12 } from "citty";
4750
+ var logsCommand = defineCommand12({
4040
4751
  meta: {
4041
4752
  name: "logs",
4042
4753
  group: "run",
@@ -4070,10 +4781,87 @@ var logsCommand = defineCommand11({
4070
4781
  }
4071
4782
  });
4072
4783
 
4784
+ // src/commands/port.ts
4785
+ import { defineCommand as defineCommand13 } from "citty";
4786
+ import { consola as consola16 } from "consola";
4787
+ async function runPortListing(opts) {
4788
+ const out = opts.out ?? process.stdout;
4789
+ const info = opts.info ?? ((m) => consola16.info(m));
4790
+ const parsed = await readConfig(
4791
+ containerConfigPath(opts.name, opts.monocerosHome)
4792
+ );
4793
+ const portEntries = parsed.config.routing?.ports ?? [];
4794
+ if (portEntries.length === 0) {
4795
+ info(
4796
+ `No ports declared in ${opts.name}.yml. Run \`monoceros add-port ${opts.name} -- <port>\` to expose one.`
4797
+ );
4798
+ return 0;
4799
+ }
4800
+ const ports = portEntries.map(portNumber);
4801
+ const globalConfig = await readMonocerosConfig({
4802
+ ...opts.monocerosHome ? { monocerosHome: opts.monocerosHome } : {}
4803
+ });
4804
+ const hostPort = proxyHostPort(globalConfig);
4805
+ const urls = proxyUrlsFor(opts.name, ports, hostPort);
4806
+ const isTty2 = out.isTTY ?? false;
4807
+ const fmt = colorsFor(out);
4808
+ const portSuffix = hostPort === 80 ? "" : `:${hostPort}`;
4809
+ const rows = [];
4810
+ rows.push({
4811
+ port: urls[0].port,
4812
+ url: `http://${opts.name}.localhost${portSuffix}`,
4813
+ tag: "default"
4814
+ });
4815
+ for (const u of urls) {
4816
+ rows.push({ port: u.port, url: u.url, tag: "" });
4817
+ }
4818
+ if (!isTty2) {
4819
+ for (const r of rows) {
4820
+ out.write(`${r.port} ${r.url} ${r.tag}
4821
+ `);
4822
+ }
4823
+ return 0;
4824
+ }
4825
+ const portWidth = Math.max(...rows.map((r) => String(r.port).length));
4826
+ const urlWidth = Math.max(...rows.map((r) => r.url.length));
4827
+ const gutter = 2;
4828
+ for (const r of rows) {
4829
+ const portStr = String(r.port).padStart(portWidth);
4830
+ const urlPad = " ".repeat(urlWidth - r.url.length + gutter);
4831
+ const tag = r.tag ? fmt.dim(`(${r.tag})`) : "";
4832
+ out.write(` ${fmt.cyan(portStr)} \u2192 ${r.url}${urlPad}${tag}
4833
+ `);
4834
+ }
4835
+ return 0;
4836
+ }
4837
+ var portCommand = defineCommand13({
4838
+ meta: {
4839
+ name: "port",
4840
+ group: "discovery",
4841
+ description: "List the Traefik URLs for a container. Reads ports from `routing.ports` in the container yml and the host port from `routing.hostPort` in monoceros-config.yml (default 80). When piped, drops formatting and emits `port<TAB>url<TAB>tag` per line for grep/awk consumption."
4842
+ },
4843
+ args: {
4844
+ name: {
4845
+ type: "positional",
4846
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
4847
+ required: true
4848
+ }
4849
+ },
4850
+ async run({ args }) {
4851
+ try {
4852
+ const code = await runPortListing({ name: args.name });
4853
+ process.exit(code);
4854
+ } catch (err) {
4855
+ consola16.error(err instanceof Error ? err.message : String(err));
4856
+ process.exit(1);
4857
+ }
4858
+ }
4859
+ });
4860
+
4073
4861
  // src/commands/remove-apt-packages.ts
4074
- import { defineCommand as defineCommand12 } from "citty";
4075
- import { consola as consola15 } from "consola";
4076
- var removeAptPackagesCommand = defineCommand12({
4862
+ import { defineCommand as defineCommand14 } from "citty";
4863
+ import { consola as consola17 } from "consola";
4864
+ var removeAptPackagesCommand = defineCommand14({
4077
4865
  meta: {
4078
4866
  name: "remove-apt-packages",
4079
4867
  group: "edit",
@@ -4095,7 +4883,7 @@ var removeAptPackagesCommand = defineCommand12({
4095
4883
  async run({ args }) {
4096
4884
  const packages = [...getInnerArgs()];
4097
4885
  if (packages.length === 0) {
4098
- consola15.error(
4886
+ consola17.error(
4099
4887
  "No package names given. Usage: `monoceros remove-apt-packages <containername> [--yes] -- <pkg> [<pkg> \u2026]`."
4100
4888
  );
4101
4889
  process.exit(1);
@@ -4108,16 +4896,16 @@ var removeAptPackagesCommand = defineCommand12({
4108
4896
  });
4109
4897
  process.exit(result.status === "aborted" ? 1 : 0);
4110
4898
  } catch (err) {
4111
- consola15.error(err instanceof Error ? err.message : String(err));
4899
+ consola17.error(err instanceof Error ? err.message : String(err));
4112
4900
  process.exit(1);
4113
4901
  }
4114
4902
  }
4115
4903
  });
4116
4904
 
4117
4905
  // src/commands/remove-feature.ts
4118
- import { defineCommand as defineCommand13 } from "citty";
4119
- import { consola as consola16 } from "consola";
4120
- var removeFeatureCommand = defineCommand13({
4906
+ import { defineCommand as defineCommand15 } from "citty";
4907
+ import { consola as consola18 } from "consola";
4908
+ var removeFeatureCommand = defineCommand15({
4121
4909
  meta: {
4122
4910
  name: "remove-feature",
4123
4911
  group: "edit",
@@ -4150,27 +4938,27 @@ var removeFeatureCommand = defineCommand13({
4150
4938
  });
4151
4939
  process.exit(result.status === "aborted" ? 1 : 0);
4152
4940
  } catch (err) {
4153
- consola16.error(err instanceof Error ? err.message : String(err));
4941
+ consola18.error(err instanceof Error ? err.message : String(err));
4154
4942
  process.exit(1);
4155
4943
  }
4156
4944
  }
4157
4945
  });
4158
4946
 
4159
4947
  // src/commands/remove.ts
4160
- import { defineCommand as defineCommand14 } from "citty";
4161
- import { consola as consola18 } from "consola";
4948
+ import { defineCommand as defineCommand16 } from "citty";
4949
+ import { consola as consola20 } from "consola";
4162
4950
  import { createInterface } from "readline/promises";
4163
4951
 
4164
4952
  // src/remove/index.ts
4165
- import { existsSync as existsSync8, promises as fs11 } from "fs";
4166
- import path10 from "path";
4167
- import { consola as consola17 } from "consola";
4953
+ import { existsSync as existsSync8, promises as fs13 } from "fs";
4954
+ import path12 from "path";
4955
+ import { consola as consola19 } from "consola";
4168
4956
  async function runRemove(opts) {
4169
4957
  const home = opts.monocerosHome ?? monocerosHome();
4170
4958
  const logger = opts.logger ?? {
4171
- info: (msg) => consola17.info(msg),
4172
- success: (msg) => consola17.success(msg),
4173
- warn: (msg) => consola17.warn(msg)
4959
+ info: (msg) => consola19.info(msg),
4960
+ success: (msg) => consola19.success(msg),
4961
+ warn: (msg) => consola19.warn(msg)
4174
4962
  };
4175
4963
  if (!REGEX.solutionName.test(opts.name)) {
4176
4964
  throw new Error(
@@ -4214,23 +5002,23 @@ async function runRemove(opts) {
4214
5002
  let backupPath = null;
4215
5003
  if (!opts.noBackup && (hasYml || hasContainer)) {
4216
5004
  const ts = (opts.now ?? /* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4217
- backupPath = path10.join(home, "container-backups", `${opts.name}-${ts}`);
4218
- await fs11.mkdir(backupPath, { recursive: true });
5005
+ backupPath = path12.join(home, "container-backups", `${opts.name}-${ts}`);
5006
+ await fs13.mkdir(backupPath, { recursive: true });
4219
5007
  if (hasYml) {
4220
- await fs11.copyFile(ymlPath, path10.join(backupPath, `${opts.name}.yml`));
5008
+ await fs13.copyFile(ymlPath, path12.join(backupPath, `${opts.name}.yml`));
4221
5009
  }
4222
5010
  if (hasContainer) {
4223
- await fs11.cp(containerPath, path10.join(backupPath, "container"), {
5011
+ await fs13.cp(containerPath, path12.join(backupPath, "container"), {
4224
5012
  recursive: true
4225
5013
  });
4226
5014
  }
4227
5015
  logger.info(`Backup written to ${prettyPath(backupPath)}.`);
4228
5016
  }
4229
5017
  if (hasYml) {
4230
- await fs11.rm(ymlPath, { force: true });
5018
+ await fs13.rm(ymlPath, { force: true });
4231
5019
  }
4232
5020
  if (hasContainer) {
4233
- await fs11.rm(containerPath, { recursive: true, force: true });
5021
+ await fs13.rm(containerPath, { recursive: true, force: true });
4234
5022
  }
4235
5023
  logger.success(
4236
5024
  `Removed '${opts.name}': docker objects gone, container-configs entry deleted, container directory deleted.`
@@ -4240,6 +5028,24 @@ async function runRemove(opts) {
4240
5028
  "No backup created (--no-backup). The host-side state is gone for good."
4241
5029
  );
4242
5030
  }
5031
+ try {
5032
+ await removeDynamicConfig(opts.name, { monocerosHome: home });
5033
+ } catch (err) {
5034
+ logger.warn?.(
5035
+ `Could not remove Traefik dynamic config for ${opts.name}: ${err instanceof Error ? err.message : String(err)}. Ignored.`
5036
+ );
5037
+ }
5038
+ try {
5039
+ await maybeStopProxy({
5040
+ ...opts.proxyDocker ? { docker: opts.proxyDocker } : {},
5041
+ monocerosHome: home,
5042
+ logger: { info: (msg) => logger.info(msg), warn: logger.warn }
5043
+ });
5044
+ } catch (err) {
5045
+ logger.warn?.(
5046
+ `Could not tear down the Traefik proxy: ${err instanceof Error ? err.message : String(err)}. Ignored.`
5047
+ );
5048
+ }
4243
5049
  return {
4244
5050
  configPath: hasYml ? ymlPath : null,
4245
5051
  containerPath: hasContainer ? containerPath : null,
@@ -4249,7 +5055,7 @@ async function runRemove(opts) {
4249
5055
  }
4250
5056
 
4251
5057
  // src/commands/remove.ts
4252
- var removeCommand = defineCommand14({
5058
+ var removeCommand = defineCommand16({
4253
5059
  meta: {
4254
5060
  name: "remove",
4255
5061
  group: "lifecycle",
@@ -4286,7 +5092,7 @@ var removeCommand = defineCommand14({
4286
5092
  const skipPrompt = args.yes === true;
4287
5093
  if (!skipPrompt) {
4288
5094
  const warning = noBackup ? `About to remove '${args.name}' WITHOUT a backup. Docker objects, container-configs entry, and container directory will all be deleted.` : `About to remove '${args.name}'. A backup will be written to container-backups/ first, then docker objects, container-configs entry, and container directory will all be deleted.`;
4289
- consola18.warn(warning);
5095
+ consola20.warn(warning);
4290
5096
  const rl = createInterface({
4291
5097
  input: process.stdin,
4292
5098
  output: process.stdout
@@ -4294,7 +5100,7 @@ var removeCommand = defineCommand14({
4294
5100
  const answer = await rl.question("Continue? [y/N] ");
4295
5101
  rl.close();
4296
5102
  if (!/^y(es)?$/i.test(answer.trim())) {
4297
- consola18.info("Aborted. Nothing changed.");
5103
+ consola20.info("Aborted. Nothing changed.");
4298
5104
  process.exit(0);
4299
5105
  }
4300
5106
  }
@@ -4303,35 +5109,35 @@ var removeCommand = defineCommand14({
4303
5109
  ...noBackup ? { noBackup: true } : {}
4304
5110
  });
4305
5111
  } catch (err) {
4306
- consola18.error(err instanceof Error ? err.message : String(err));
5112
+ consola20.error(err instanceof Error ? err.message : String(err));
4307
5113
  process.exit(1);
4308
5114
  }
4309
5115
  }
4310
5116
  });
4311
5117
 
4312
5118
  // src/commands/restore.ts
4313
- import { defineCommand as defineCommand15 } from "citty";
4314
- import { consola as consola20 } from "consola";
5119
+ import { defineCommand as defineCommand17 } from "citty";
5120
+ import { consola as consola22 } from "consola";
4315
5121
 
4316
5122
  // src/restore/index.ts
4317
- import { existsSync as existsSync9, promises as fs12 } from "fs";
4318
- import path11 from "path";
4319
- import { consola as consola19 } from "consola";
5123
+ import { existsSync as existsSync9, promises as fs14 } from "fs";
5124
+ import path13 from "path";
5125
+ import { consola as consola21 } from "consola";
4320
5126
  async function runRestore(opts) {
4321
5127
  const home = opts.monocerosHome ?? monocerosHome();
4322
5128
  const logger = opts.logger ?? {
4323
- info: (msg) => consola19.info(msg),
4324
- success: (msg) => consola19.success(msg)
5129
+ info: (msg) => consola21.info(msg),
5130
+ success: (msg) => consola21.success(msg)
4325
5131
  };
4326
- const backup = path11.resolve(opts.backupPath);
5132
+ const backup = path13.resolve(opts.backupPath);
4327
5133
  if (!existsSync9(backup)) {
4328
5134
  throw new Error(`Backup not found: ${backup}.`);
4329
5135
  }
4330
- const stat = await fs12.stat(backup);
5136
+ const stat = await fs14.stat(backup);
4331
5137
  if (!stat.isDirectory()) {
4332
5138
  throw new Error(`Backup path is not a directory: ${backup}.`);
4333
5139
  }
4334
- const entries = await fs12.readdir(backup);
5140
+ const entries = await fs14.readdir(backup);
4335
5141
  const ymlFiles = entries.filter((f) => f.endsWith(".yml"));
4336
5142
  if (ymlFiles.length === 0) {
4337
5143
  throw new Error(
@@ -4345,7 +5151,7 @@ async function runRestore(opts) {
4345
5151
  }
4346
5152
  const ymlFile = ymlFiles[0];
4347
5153
  const name = ymlFile.replace(/\.yml$/, "");
4348
- const containerInBackup = path11.join(backup, "container");
5154
+ const containerInBackup = path13.join(backup, "container");
4349
5155
  const hasContainer = existsSync9(containerInBackup);
4350
5156
  const destYml = containerConfigPath(name, home);
4351
5157
  const destContainer = containerDir(name, home);
@@ -4359,10 +5165,10 @@ async function runRestore(opts) {
4359
5165
  `Refusing to restore: ${destContainer} already exists. Remove the current container first (\`monoceros remove ${name}\`).`
4360
5166
  );
4361
5167
  }
4362
- await fs12.mkdir(containerConfigsDir(home), { recursive: true });
4363
- await fs12.copyFile(path11.join(backup, ymlFile), destYml);
5168
+ await fs14.mkdir(containerConfigsDir(home), { recursive: true });
5169
+ await fs14.copyFile(path13.join(backup, ymlFile), destYml);
4364
5170
  if (hasContainer) {
4365
- await fs12.cp(containerInBackup, destContainer, { recursive: true });
5171
+ await fs14.cp(containerInBackup, destContainer, { recursive: true });
4366
5172
  }
4367
5173
  logger.success(`Restored '${name}' from ${prettyPath(backup)}.`);
4368
5174
  logger.info(
@@ -4376,7 +5182,7 @@ async function runRestore(opts) {
4376
5182
  }
4377
5183
 
4378
5184
  // src/commands/restore.ts
4379
- var restoreCommand = defineCommand15({
5185
+ var restoreCommand = defineCommand17({
4380
5186
  meta: {
4381
5187
  name: "restore",
4382
5188
  group: "lifecycle",
@@ -4393,16 +5199,16 @@ var restoreCommand = defineCommand15({
4393
5199
  try {
4394
5200
  await runRestore({ backupPath: args["backup-path"] });
4395
5201
  } catch (err) {
4396
- consola20.error(err instanceof Error ? err.message : String(err));
5202
+ consola22.error(err instanceof Error ? err.message : String(err));
4397
5203
  process.exit(1);
4398
5204
  }
4399
5205
  }
4400
5206
  });
4401
5207
 
4402
5208
  // src/commands/remove-from-url.ts
4403
- import { defineCommand as defineCommand16 } from "citty";
4404
- import { consola as consola21 } from "consola";
4405
- var removeFromUrlCommand = defineCommand16({
5209
+ import { defineCommand as defineCommand18 } from "citty";
5210
+ import { consola as consola23 } from "consola";
5211
+ var removeFromUrlCommand = defineCommand18({
4406
5212
  meta: {
4407
5213
  name: "remove-from-url",
4408
5214
  group: "edit",
@@ -4435,16 +5241,16 @@ var removeFromUrlCommand = defineCommand16({
4435
5241
  });
4436
5242
  process.exit(result.status === "aborted" ? 1 : 0);
4437
5243
  } catch (err) {
4438
- consola21.error(err instanceof Error ? err.message : String(err));
5244
+ consola23.error(err instanceof Error ? err.message : String(err));
4439
5245
  process.exit(1);
4440
5246
  }
4441
5247
  }
4442
5248
  });
4443
5249
 
4444
5250
  // src/commands/remove-language.ts
4445
- import { defineCommand as defineCommand17 } from "citty";
4446
- import { consola as consola22 } from "consola";
4447
- var removeLanguageCommand = defineCommand17({
5251
+ import { defineCommand as defineCommand19 } from "citty";
5252
+ import { consola as consola24 } from "consola";
5253
+ var removeLanguageCommand = defineCommand19({
4448
5254
  meta: {
4449
5255
  name: "remove-language",
4450
5256
  group: "edit",
@@ -4477,16 +5283,64 @@ var removeLanguageCommand = defineCommand17({
4477
5283
  });
4478
5284
  process.exit(result.status === "aborted" ? 1 : 0);
4479
5285
  } catch (err) {
4480
- consola22.error(err instanceof Error ? err.message : String(err));
5286
+ consola24.error(err instanceof Error ? err.message : String(err));
5287
+ process.exit(1);
5288
+ }
5289
+ }
5290
+ });
5291
+
5292
+ // src/commands/remove-port.ts
5293
+ import { defineCommand as defineCommand20 } from "citty";
5294
+ import { consola as consola25 } from "consola";
5295
+ var removePortCommand = defineCommand20({
5296
+ meta: {
5297
+ name: "remove-port",
5298
+ group: "edit",
5299
+ description: "Remove one or more ports from the container config. Pass port numbers after `--` (e.g. `monoceros remove-port sandbox -- 3000 5173`). Idempotent \u2014 ports not present are skipped silently."
5300
+ },
5301
+ args: {
5302
+ name: {
5303
+ type: "positional",
5304
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
5305
+ required: true
5306
+ },
5307
+ yes: {
5308
+ type: "boolean",
5309
+ description: "Skip the interactive confirmation and apply the diff.",
5310
+ alias: ["y"],
5311
+ default: false
5312
+ }
5313
+ },
5314
+ async run({ args }) {
5315
+ const tokens = [...getInnerArgs()];
5316
+ if (tokens.length === 0) {
5317
+ consola25.error(
5318
+ "No ports given. Usage: `monoceros remove-port <containername> [--yes] -- <port> [<port> \u2026]`."
5319
+ );
5320
+ process.exit(1);
5321
+ }
5322
+ try {
5323
+ const result = await runRemovePort({
5324
+ name: args.name,
5325
+ ports: tokens.map(coerceToken2),
5326
+ yes: args.yes
5327
+ });
5328
+ process.exit(result.status === "aborted" ? 1 : 0);
5329
+ } catch (err) {
5330
+ consola25.error(err instanceof Error ? err.message : String(err));
4481
5331
  process.exit(1);
4482
5332
  }
4483
5333
  }
4484
5334
  });
5335
+ function coerceToken2(raw) {
5336
+ const n = Number(raw);
5337
+ return Number.isFinite(n) ? n : raw;
5338
+ }
4485
5339
 
4486
5340
  // src/commands/remove-repo.ts
4487
- import { defineCommand as defineCommand18 } from "citty";
4488
- import { consola as consola23 } from "consola";
4489
- var removeRepoCommand = defineCommand18({
5341
+ import { defineCommand as defineCommand21 } from "citty";
5342
+ import { consola as consola26 } from "consola";
5343
+ var removeRepoCommand = defineCommand21({
4490
5344
  meta: {
4491
5345
  name: "remove-repo",
4492
5346
  group: "edit",
@@ -4519,16 +5373,16 @@ var removeRepoCommand = defineCommand18({
4519
5373
  });
4520
5374
  process.exit(result.status === "aborted" ? 1 : 0);
4521
5375
  } catch (err) {
4522
- consola23.error(err instanceof Error ? err.message : String(err));
5376
+ consola26.error(err instanceof Error ? err.message : String(err));
4523
5377
  process.exit(1);
4524
5378
  }
4525
5379
  }
4526
5380
  });
4527
5381
 
4528
5382
  // src/commands/remove-service.ts
4529
- import { defineCommand as defineCommand19 } from "citty";
4530
- import { consola as consola24 } from "consola";
4531
- var removeServiceCommand = defineCommand19({
5383
+ import { defineCommand as defineCommand22 } from "citty";
5384
+ import { consola as consola27 } from "consola";
5385
+ var removeServiceCommand = defineCommand22({
4532
5386
  meta: {
4533
5387
  name: "remove-service",
4534
5388
  group: "edit",
@@ -4561,19 +5415,19 @@ var removeServiceCommand = defineCommand19({
4561
5415
  });
4562
5416
  process.exit(result.status === "aborted" ? 1 : 0);
4563
5417
  } catch (err) {
4564
- consola24.error(err instanceof Error ? err.message : String(err));
5418
+ consola27.error(err instanceof Error ? err.message : String(err));
4565
5419
  process.exit(1);
4566
5420
  }
4567
5421
  }
4568
5422
  });
4569
5423
 
4570
5424
  // src/commands/run.ts
4571
- import { defineCommand as defineCommand20 } from "citty";
4572
- import { consola as consola25 } from "consola";
5425
+ import { defineCommand as defineCommand23 } from "citty";
5426
+ import { consola as consola28 } from "consola";
4573
5427
 
4574
5428
  // src/devcontainer/shell.ts
4575
5429
  import { existsSync as existsSync10 } from "fs";
4576
- import path12 from "path";
5430
+ import path14 from "path";
4577
5431
  async function runShell(opts) {
4578
5432
  assertContainerExists(opts.root);
4579
5433
  const spawnFn = opts.spawn ?? spawnDevcontainer;
@@ -4596,7 +5450,7 @@ async function runShell(opts) {
4596
5450
  );
4597
5451
  }
4598
5452
  function assertContainerExists(root) {
4599
- if (!existsSync10(path12.join(root, ".devcontainer"))) {
5453
+ if (!existsSync10(path14.join(root, ".devcontainer"))) {
4600
5454
  throw new Error(
4601
5455
  `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
4602
5456
  );
@@ -4632,7 +5486,7 @@ async function runInContainer(opts) {
4632
5486
  }
4633
5487
 
4634
5488
  // src/commands/run.ts
4635
- var runCommand = defineCommand20({
5489
+ var runCommand = defineCommand23({
4636
5490
  meta: {
4637
5491
  name: "run",
4638
5492
  group: "run",
@@ -4648,7 +5502,7 @@ var runCommand = defineCommand20({
4648
5502
  async run({ args }) {
4649
5503
  const command = [...getInnerArgs()];
4650
5504
  if (command.length === 0) {
4651
- consola25.error(
5505
+ consola28.error(
4652
5506
  "No command provided. Usage: `monoceros run <containername> -- <cmd> [args\u2026]`."
4653
5507
  );
4654
5508
  process.exit(1);
@@ -4660,16 +5514,16 @@ var runCommand = defineCommand20({
4660
5514
  });
4661
5515
  process.exit(exitCode);
4662
5516
  } catch (err) {
4663
- consola25.error(err instanceof Error ? err.message : String(err));
5517
+ consola28.error(err instanceof Error ? err.message : String(err));
4664
5518
  process.exit(1);
4665
5519
  }
4666
5520
  }
4667
5521
  });
4668
5522
 
4669
5523
  // src/commands/shell.ts
4670
- import { defineCommand as defineCommand21 } from "citty";
4671
- import { consola as consola26 } from "consola";
4672
- var shellCommand = defineCommand21({
5524
+ import { defineCommand as defineCommand24 } from "citty";
5525
+ import { consola as consola29 } from "consola";
5526
+ var shellCommand = defineCommand24({
4673
5527
  meta: {
4674
5528
  name: "shell",
4675
5529
  group: "run",
@@ -4687,15 +5541,16 @@ var shellCommand = defineCommand21({
4687
5541
  const exitCode = await runShell({ root: containerDir(args.name) });
4688
5542
  process.exit(exitCode);
4689
5543
  } catch (err) {
4690
- consola26.error(err instanceof Error ? err.message : String(err));
5544
+ consola29.error(err instanceof Error ? err.message : String(err));
4691
5545
  process.exit(1);
4692
5546
  }
4693
5547
  }
4694
5548
  });
4695
5549
 
4696
5550
  // src/commands/start.ts
4697
- import { defineCommand as defineCommand22 } from "citty";
4698
- var startCommand = defineCommand22({
5551
+ import { defineCommand as defineCommand25 } from "citty";
5552
+ import { consola as consola30 } from "consola";
5553
+ var startCommand = defineCommand25({
4699
5554
  meta: {
4700
5555
  name: "start",
4701
5556
  group: "run",
@@ -4709,13 +5564,33 @@ var startCommand = defineCommand22({
4709
5564
  }
4710
5565
  },
4711
5566
  run({ args }) {
4712
- return dispatch(() => runStart({ root: containerDir(args.name) }));
5567
+ return dispatch(async () => {
5568
+ let needsProxy = false;
5569
+ let hostPort = 80;
5570
+ try {
5571
+ const parsed = await readConfig(containerConfigPath(args.name));
5572
+ if ((parsed.config.routing?.ports ?? []).length > 0) {
5573
+ needsProxy = true;
5574
+ const global = await readMonocerosConfig();
5575
+ hostPort = proxyHostPort(global);
5576
+ }
5577
+ } catch (err) {
5578
+ consola30.warn(
5579
+ `Could not read container yml ahead of start: ${err instanceof Error ? err.message : String(err)}. Skipping Traefik pre-flight.`
5580
+ );
5581
+ }
5582
+ if (needsProxy) {
5583
+ await preflightHostPort(hostPort);
5584
+ await ensureProxy({ hostPort });
5585
+ }
5586
+ return runStart({ root: containerDir(args.name) });
5587
+ });
4713
5588
  }
4714
5589
  });
4715
5590
 
4716
5591
  // src/commands/status.ts
4717
- import { defineCommand as defineCommand23 } from "citty";
4718
- var statusCommand = defineCommand23({
5592
+ import { defineCommand as defineCommand26 } from "citty";
5593
+ var statusCommand = defineCommand26({
4719
5594
  meta: {
4720
5595
  name: "status",
4721
5596
  group: "run",
@@ -4743,8 +5618,9 @@ var statusCommand = defineCommand23({
4743
5618
  });
4744
5619
 
4745
5620
  // src/commands/stop.ts
4746
- import { defineCommand as defineCommand24 } from "citty";
4747
- var stopCommand = defineCommand24({
5621
+ import { defineCommand as defineCommand27 } from "citty";
5622
+ import { consola as consola31 } from "consola";
5623
+ var stopCommand = defineCommand27({
4748
5624
  meta: {
4749
5625
  name: "stop",
4750
5626
  group: "run",
@@ -4762,17 +5638,27 @@ var stopCommand = defineCommand24({
4762
5638
  }
4763
5639
  },
4764
5640
  run({ args }) {
4765
- return dispatch(
4766
- () => runStop({
5641
+ return dispatch(async () => {
5642
+ const exit = await runStop({
4767
5643
  root: containerDir(args.name),
4768
5644
  ...typeof args.service === "string" ? { service: args.service } : {}
4769
- })
4770
- );
5645
+ });
5646
+ try {
5647
+ await maybeStopProxy({
5648
+ logger: { info: (msg) => consola31.info(msg) }
5649
+ });
5650
+ } catch (err) {
5651
+ consola31.warn(
5652
+ `Could not tear down the Traefik proxy: ${err instanceof Error ? err.message : String(err)}. Ignored.`
5653
+ );
5654
+ }
5655
+ return exit;
5656
+ });
4771
5657
  }
4772
5658
  });
4773
5659
 
4774
5660
  // src/main.ts
4775
- var main = defineCommand25({
5661
+ var main = defineCommand28({
4776
5662
  meta: {
4777
5663
  name: "monoceros",
4778
5664
  version: CLI_VERSION,
@@ -4796,12 +5682,15 @@ var main = defineCommand25({
4796
5682
  "add-feature": addFeatureCommand,
4797
5683
  "add-from-url": addFromUrlCommand,
4798
5684
  "add-repo": addRepoCommand,
5685
+ "add-port": addPortCommand,
4799
5686
  "remove-service": removeServiceCommand,
4800
5687
  "remove-language": removeLanguageCommand,
4801
5688
  "remove-apt-packages": removeAptPackagesCommand,
4802
5689
  "remove-feature": removeFeatureCommand,
4803
5690
  "remove-from-url": removeFromUrlCommand,
4804
5691
  "remove-repo": removeRepoCommand,
5692
+ "remove-port": removePortCommand,
5693
+ port: portCommand,
4805
5694
  completion: completionCommand
4806
5695
  }
4807
5696
  });