@getmonoceros/workbench 1.6.11 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,14 +2352,14 @@ 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({
1792
2359
  meta: {
1793
- name: "add-service",
2360
+ name: "add-port",
1794
2361
  group: "edit",
1795
- description: "Add a compose service (postgres, mysql, redis, \u2026) to the container config. Idempotent, prints a diff before writing."
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)."
1796
2363
  },
1797
2364
  args: {
1798
2365
  name: {
@@ -1800,24 +2367,32 @@ var addServiceCommand = defineCommand6({
1800
2367
  description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
1801
2368
  required: true
1802
2369
  },
1803
- service: {
1804
- type: "positional",
1805
- description: "Service identifier (postgres, mysql, redis).",
1806
- required: true
1807
- },
1808
2370
  yes: {
1809
2371
  type: "boolean",
1810
2372
  description: "Skip the interactive confirmation and apply the diff.",
1811
2373
  alias: ["y"],
1812
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
1813
2380
  }
1814
2381
  },
1815
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
+ }
1816
2390
  try {
1817
- const result = await runAddService({
2391
+ const result = await runAddPort({
1818
2392
  name: args.name,
1819
- service: args.service,
1820
- yes: args.yes
2393
+ ports: tokens.map(coerceToken),
2394
+ yes: args.yes,
2395
+ asDefault: args.default
1821
2396
  });
1822
2397
  process.exit(result.status === "aborted" ? 1 : 0);
1823
2398
  } catch (err) {
@@ -1826,76 +2401,63 @@ var addServiceCommand = defineCommand6({
1826
2401
  }
1827
2402
  }
1828
2403
  });
2404
+ function coerceToken(raw) {
2405
+ const n = Number(raw);
2406
+ return Number.isFinite(n) ? n : raw;
2407
+ }
1829
2408
 
1830
- // src/commands/apply.ts
2409
+ // src/commands/add-service.ts
1831
2410
  import { defineCommand as defineCommand7 } from "citty";
1832
-
1833
- // 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
- );
2411
+ import { consola as consola8 } from "consola";
2412
+ var addServiceCommand = defineCommand7({
2413
+ meta: {
2414
+ name: "add-service",
2415
+ group: "edit",
2416
+ description: "Add a compose service (postgres, mysql, redis, \u2026) to the container config. Idempotent, prints a diff before writing."
2417
+ },
2418
+ args: {
2419
+ name: {
2420
+ type: "positional",
2421
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
2422
+ required: true
2423
+ },
2424
+ service: {
2425
+ type: "positional",
2426
+ description: "Service identifier (postgres, mysql, redis).",
2427
+ required: true
2428
+ },
2429
+ yes: {
2430
+ type: "boolean",
2431
+ description: "Skip the interactive confirmation and apply the diff.",
2432
+ alias: ["y"],
2433
+ default: false
2434
+ }
2435
+ },
2436
+ async run({ args }) {
2437
+ try {
2438
+ const result = await runAddService({
2439
+ name: args.name,
2440
+ service: args.service,
2441
+ yes: args.yes
2442
+ });
2443
+ process.exit(result.status === "aborted" ? 1 : 0);
2444
+ } catch (err) {
2445
+ consola8.error(err instanceof Error ? err.message : String(err));
2446
+ process.exit(1);
2447
+ }
1892
2448
  }
1893
- return result.data;
1894
- }
2449
+ });
2450
+
2451
+ // src/commands/apply.ts
2452
+ import { defineCommand as defineCommand8 } from "citty";
2453
+
2454
+ // src/apply/index.ts
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
  {
@@ -2711,28 +3288,34 @@ function formatRootlessNotSupportedError() {
2711
3288
  ``,
2712
3289
  `To fix, switch back to standard rootful Docker:`,
2713
3290
  ``,
2714
- cyan2(` systemctl --user disable --now docker.service docker.socket`),
3291
+ cyan2(
3292
+ ` systemctl --user stop docker.service docker.socket 2>/dev/null || true`
3293
+ ),
2715
3294
  cyan2(` dockerd-rootless-setuptool.sh uninstall`),
2716
- cyan2(` rm -rf ~/.local/share/docker`),
2717
- cyan2(` unset DOCKER_HOST`),
3295
+ cyan2(` rootlesskit rm -rf ~/.local/share/docker`),
3296
+ cyan2(` unset DOCKER_HOST DOCKER_CONTEXT`),
2718
3297
  cyan2(` sudo systemctl enable --now docker`),
2719
3298
  cyan2(` sudo usermod -aG docker $USER`),
2720
3299
  ``,
2721
- `Verify with ${cyan2("docker info | grep -i rootless")} \u2014 it should`,
2722
- `produce no output. Then re-run.`,
3300
+ `If you added DOCKER_HOST or DOCKER_CONTEXT to ~/.bashrc /`,
3301
+ `~/.profile (the rootless setup may have suggested it), remove`,
3302
+ `those lines too \u2014 the 'unset' above only affects your current`,
3303
+ `shell. Otherwise new terminals keep pointing at the rootless`,
3304
+ `socket and Monoceros's auto-recovery has nothing to fall back to.`,
2723
3305
  ``,
2724
- `Background: see ${cyan2("docs/docker-on-linux.md")} in the workbench repo.`
3306
+ `Then re-run. Background: see ${cyan2("docs/docker-on-linux.md")} in`,
3307
+ `the workbench repo.`
2725
3308
  ].join("\n");
2726
3309
  }
2727
3310
 
2728
3311
  // src/devcontainer/identity.ts
2729
- import { spawn as spawn6 } from "child_process";
2730
- import { promises as fs7 } from "fs";
2731
- import path7 from "path";
2732
- 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";
2733
3316
  var realGitConfigGet = (key) => {
2734
3317
  return new Promise((resolve, reject) => {
2735
- const child = spawn6("git", ["config", "--global", "--get", key], {
3318
+ const child = spawn7("git", ["config", "--global", "--get", key], {
2736
3319
  stdio: ["ignore", "pipe", "inherit"]
2737
3320
  });
2738
3321
  let stdout = "";
@@ -2751,14 +3334,14 @@ var realIdentityPrompt = async (key) => {
2751
3334
  return void 0;
2752
3335
  }
2753
3336
  const label = key === "user.name" ? "Git user.name for this dev container (full name)" : "Git user.email for this dev container";
2754
- const value = await consola9.prompt(`${label}:`, { type: "text" });
3337
+ const value = await consola10.prompt(`${label}:`, { type: "text" });
2755
3338
  if (typeof value !== "string") return void 0;
2756
3339
  const trimmed = value.trim();
2757
3340
  return trimmed.length > 0 ? trimmed : void 0;
2758
3341
  };
2759
3342
  async function collectGitIdentity(devContainerRoot, options = {}) {
2760
- const gitconfigDir = path7.join(devContainerRoot, ".monoceros");
2761
- const gitconfigPath = path7.join(gitconfigDir, "gitconfig");
3343
+ const gitconfigDir = path9.join(devContainerRoot, ".monoceros");
3344
+ const gitconfigPath = path9.join(gitconfigDir, "gitconfig");
2762
3345
  const spawnFn = options.spawn ?? realGitConfigGet;
2763
3346
  const promptFn = options.prompt ?? realIdentityPrompt;
2764
3347
  const logger = options.logger ?? { info: () => {
@@ -2784,8 +3367,8 @@ async function collectGitIdentity(devContainerRoot, options = {}) {
2784
3367
  const lines = ["[user]"];
2785
3368
  if (name !== void 0) lines.push(` name = ${name}`);
2786
3369
  if (email !== void 0) lines.push(` email = ${email}`);
2787
- await fs7.mkdir(gitconfigDir, { recursive: true });
2788
- await fs7.writeFile(gitconfigPath, lines.join("\n") + "\n");
3370
+ await fs9.mkdir(gitconfigDir, { recursive: true });
3371
+ await fs9.writeFile(gitconfigPath, lines.join("\n") + "\n");
2789
3372
  return {
2790
3373
  ...name !== void 0 ? { name } : {},
2791
3374
  ...email !== void 0 ? { email } : {},
@@ -2827,7 +3410,7 @@ async function readKeyFromHost(spawnFn, key, logger) {
2827
3410
  }
2828
3411
  async function readExistingGitconfig(filePath) {
2829
3412
  try {
2830
- const content = await fs7.readFile(filePath, "utf8");
3413
+ const content = await fs9.readFile(filePath, "utf8");
2831
3414
  const result = {};
2832
3415
  const nameMatch = /^\s*name\s*=\s*(.+?)\s*$/m.exec(content);
2833
3416
  const emailMatch = /^\s*email\s*=\s*(.+?)\s*$/m.exec(content);
@@ -2843,9 +3426,9 @@ async function readExistingGitconfig(filePath) {
2843
3426
  async function runApply(opts) {
2844
3427
  const home = opts.monocerosHome ?? monocerosHome();
2845
3428
  const logger = opts.logger ?? {
2846
- info: (msg) => consola10.info(msg),
2847
- success: (msg) => consola10.success(msg),
2848
- warn: (msg) => consola10.warn(msg),
3429
+ info: (msg) => consola11.info(msg),
3430
+ success: (msg) => consola11.success(msg),
3431
+ warn: (msg) => consola11.warn(msg),
2849
3432
  // Default section renderer: empty line, bold-underlined "▸ Label",
2850
3433
  // empty line. Mirrors install.sh's section visuals.
2851
3434
  section: (label) => process.stderr.write(`
@@ -2927,7 +3510,7 @@ ${sectionLine(label)}
2927
3510
  if (dockerMode === "rootless") {
2928
3511
  throw new Error(formatRootlessNotSupportedError());
2929
3512
  }
2930
- await fs8.mkdir(targetDir, { recursive: true });
3513
+ await fs10.mkdir(targetDir, { recursive: true });
2931
3514
  await writeScaffold(createOpts, targetDir, { dockerMode });
2932
3515
  await writeStateFile(
2933
3516
  targetDir,
@@ -2948,6 +3531,30 @@ ${sectionLine(label)}
2948
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.'
2949
3532
  )
2950
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
+ }
2951
3558
  const exitCode = await runContainerCycle(targetDir, {
2952
3559
  hasCompose: needsCompose(createOpts),
2953
3560
  ...opts.cleanupSpawn !== void 0 ? { cleanupSpawn: opts.cleanupSpawn } : {},
@@ -2962,7 +3569,7 @@ ${sectionLine(label)}
2962
3569
  }
2963
3570
  async function assertSafeTargetDir(targetDir, expectedOrigin) {
2964
3571
  if (!existsSync4(targetDir)) return;
2965
- const entries = await fs8.readdir(targetDir);
3572
+ const entries = await fs10.readdir(targetDir);
2966
3573
  if (entries.length === 0) return;
2967
3574
  const state = await readStateFile(targetDir);
2968
3575
  if (state) {
@@ -3004,22 +3611,22 @@ function warnOnDeprecatedFeatureRefs(containerFeatures, globalConfig, logger) {
3004
3611
  }
3005
3612
 
3006
3613
  // src/version.ts
3007
- var CLI_VERSION = true ? "1.6.11" : "dev";
3614
+ var CLI_VERSION = true ? "1.7.0" : "dev";
3008
3615
 
3009
3616
  // src/commands/_dispatch.ts
3010
- import { consola as consola11 } from "consola";
3617
+ import { consola as consola12 } from "consola";
3011
3618
  async function dispatch(runner) {
3012
3619
  try {
3013
3620
  const exitCode = await runner();
3014
3621
  process.exit(exitCode);
3015
3622
  } catch (err) {
3016
- consola11.error(err instanceof Error ? err.message : String(err));
3623
+ consola12.error(err instanceof Error ? err.message : String(err));
3017
3624
  process.exit(1);
3018
3625
  }
3019
3626
  }
3020
3627
 
3021
3628
  // src/commands/apply.ts
3022
- var applyCommand = defineCommand7({
3629
+ var applyCommand = defineCommand8({
3023
3630
  meta: {
3024
3631
  name: "apply",
3025
3632
  group: "lifecycle",
@@ -3044,7 +3651,7 @@ var applyCommand = defineCommand7({
3044
3651
  });
3045
3652
 
3046
3653
  // src/commands/completion.ts
3047
- import { defineCommand as defineCommand8 } from "citty";
3654
+ import { defineCommand as defineCommand9 } from "citty";
3048
3655
  var ALL_COMMANDS = [
3049
3656
  "init",
3050
3657
  "list-components",
@@ -3063,12 +3670,15 @@ var ALL_COMMANDS = [
3063
3670
  "add-feature",
3064
3671
  "add-from-url",
3065
3672
  "add-repo",
3673
+ "add-port",
3066
3674
  "remove-service",
3067
3675
  "remove-language",
3068
3676
  "remove-apt-packages",
3069
3677
  "remove-feature",
3070
3678
  "remove-from-url",
3071
3679
  "remove-repo",
3680
+ "remove-port",
3681
+ "port",
3072
3682
  "completion"
3073
3683
  ];
3074
3684
  var COMMANDS_WITH_CONTAINER_ARG = [
@@ -3086,12 +3696,15 @@ var COMMANDS_WITH_CONTAINER_ARG = [
3086
3696
  "add-feature",
3087
3697
  "add-from-url",
3088
3698
  "add-repo",
3699
+ "add-port",
3089
3700
  "remove-service",
3090
3701
  "remove-language",
3091
3702
  "remove-apt-packages",
3092
3703
  "remove-feature",
3093
3704
  "remove-from-url",
3094
- "remove-repo"
3705
+ "remove-repo",
3706
+ "remove-port",
3707
+ "port"
3095
3708
  ];
3096
3709
  var SHELLS = ["bash", "zsh", "pwsh"];
3097
3710
  function renderCompletionScript(shell) {
@@ -3224,7 +3837,7 @@ function renderCompletionScript(shell) {
3224
3837
  ""
3225
3838
  ].join("\n");
3226
3839
  }
3227
- var completionCommand = defineCommand8({
3840
+ var completionCommand = defineCommand9({
3228
3841
  meta: {
3229
3842
  name: "completion",
3230
3843
  group: "tooling",
@@ -3251,16 +3864,16 @@ var completionCommand = defineCommand8({
3251
3864
  });
3252
3865
 
3253
3866
  // src/commands/init.ts
3254
- import { defineCommand as defineCommand9 } from "citty";
3255
- import { consola as consola13 } from "consola";
3867
+ import { defineCommand as defineCommand10 } from "citty";
3868
+ import { consola as consola14 } from "consola";
3256
3869
 
3257
3870
  // src/init/index.ts
3258
- import { existsSync as existsSync7, promises as fs10 } from "fs";
3259
- import { consola as consola12 } from "consola";
3871
+ import { existsSync as existsSync7, promises as fs12 } from "fs";
3872
+ import { consola as consola13 } from "consola";
3260
3873
 
3261
3874
  // src/init/components.ts
3262
- import { existsSync as existsSync5, promises as fs9 } from "fs";
3263
- import path8 from "path";
3875
+ import { existsSync as existsSync5, promises as fs11 } from "fs";
3876
+ import path10 from "path";
3264
3877
  import { z as z3 } from "zod";
3265
3878
  import { parse as parseYaml } from "yaml";
3266
3879
  var CategorySchema = z3.enum(["language", "service", "feature"]);
@@ -3315,17 +3928,17 @@ async function loadComponentCatalog(rootDir = componentsDir()) {
3315
3928
  return out;
3316
3929
  }
3317
3930
  async function walk(baseDir, currentDir, out) {
3318
- const entries = await fs9.readdir(currentDir, { withFileTypes: true });
3931
+ const entries = await fs11.readdir(currentDir, { withFileTypes: true });
3319
3932
  for (const entry2 of entries) {
3320
- const full = path8.join(currentDir, entry2.name);
3933
+ const full = path10.join(currentDir, entry2.name);
3321
3934
  if (entry2.isDirectory()) {
3322
3935
  await walk(baseDir, full, out);
3323
3936
  continue;
3324
3937
  }
3325
3938
  if (!entry2.isFile() || !entry2.name.endsWith(".yml")) continue;
3326
- const relative = path8.relative(baseDir, full);
3327
- const name = relative.replace(/\.yml$/, "").split(path8.sep).join("/");
3328
- 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");
3329
3942
  let raw;
3330
3943
  try {
3331
3944
  raw = parseYaml(text);
@@ -3581,6 +4194,7 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = []) {
3581
4194
  /* commented */
3582
4195
  repoUrls.length === 0
3583
4196
  );
4197
+ renderRoutingBlock(lines);
3584
4198
  return ensureTrailingNewline(lines.join("\n"));
3585
4199
  }
3586
4200
  var COMMENT_WIDTH = 72;
@@ -3680,6 +4294,31 @@ function renderReposBlock(out, urls, commented) {
3680
4294
  }
3681
4295
  out.push("");
3682
4296
  }
4297
+ function renderRoutingBlock(out) {
4298
+ out.push("# Routing \u2014 expose container ports to the host through the");
4299
+ out.push("# shared Traefik singleton. Once any port is declared the");
4300
+ out.push("# container joins the monoceros-proxy network and the proxy");
4301
+ out.push("# routes <name>.localhost (default port) and");
4302
+ out.push("# <name>-<port>.localhost (explicit). `monoceros add-port`");
4303
+ out.push("# manages the list; the block appears on first add.");
4304
+ out.push("#");
4305
+ out.push("# routing:");
4306
+ out.push("# ports: # internal container ports");
4307
+ out.push(
4308
+ "# - 3000 # first entry doubles as <name>.localhost"
4309
+ );
4310
+ out.push("# - 5173");
4311
+ out.push(
4312
+ "# vscodeAutoForward: false # default: false. Traefik is the single"
4313
+ );
4314
+ out.push(
4315
+ "# # source of truth \u2014 set true only if you"
4316
+ );
4317
+ out.push(
4318
+ "# # want VS Code's port panel as primary."
4319
+ );
4320
+ out.push("");
4321
+ }
3683
4322
  function deriveDefaultPath(url) {
3684
4323
  let last = url;
3685
4324
  const slash = url.lastIndexOf("/");
@@ -3734,10 +4373,10 @@ function ensureTrailingNewline(s) {
3734
4373
 
3735
4374
  // src/init/manifest.ts
3736
4375
  import { existsSync as existsSync6, readFileSync as readFileSync3 } from "fs";
3737
- import path9 from "path";
4376
+ import path11 from "path";
3738
4377
  function resolveManifestPath(name, checkoutRoot) {
3739
4378
  if (checkoutRoot) {
3740
- const checkoutPath = path9.join(
4379
+ const checkoutPath = path11.join(
3741
4380
  checkoutRoot,
3742
4381
  "images",
3743
4382
  "features",
@@ -3746,7 +4385,7 @@ function resolveManifestPath(name, checkoutRoot) {
3746
4385
  );
3747
4386
  if (existsSync6(checkoutPath)) return checkoutPath;
3748
4387
  }
3749
- const bundlePath = path9.join(
4388
+ const bundlePath = path11.join(
3750
4389
  bundledFeaturesDir(),
3751
4390
  name,
3752
4391
  "devcontainer-feature.json"
@@ -3789,8 +4428,8 @@ async function runInit(opts) {
3789
4428
  const workbench = opts.workbenchRoot ?? workbenchRoot();
3790
4429
  const home = opts.monocerosHome ?? monocerosHome();
3791
4430
  const logger = opts.logger ?? {
3792
- success: (msg) => consola12.success(msg),
3793
- info: (msg) => consola12.info(msg)
4431
+ success: (msg) => consola13.success(msg),
4432
+ info: (msg) => consola13.info(msg)
3794
4433
  };
3795
4434
  if (!REGEX.solutionName.test(opts.name)) {
3796
4435
  throw new Error(
@@ -3851,8 +4490,8 @@ async function runInit(opts) {
3851
4490
  const components = resolveComponents(catalog, requested);
3852
4491
  text = generateComposedYml(opts.name, components, lookup, repos);
3853
4492
  }
3854
- await fs10.mkdir(containerConfigsDir(home), { recursive: true });
3855
- await fs10.writeFile(dest, text, "utf8");
4493
+ await fs12.mkdir(containerConfigsDir(home), { recursive: true });
4494
+ await fs12.writeFile(dest, text, "utf8");
3856
4495
  const documented = requested.length === 0;
3857
4496
  const displayPath = prettyPath(dest);
3858
4497
  if (documented) {
@@ -3871,7 +4510,7 @@ async function runInit(opts) {
3871
4510
  }
3872
4511
 
3873
4512
  // src/commands/init.ts
3874
- var initCommand = defineCommand9({
4513
+ var initCommand = defineCommand10({
3875
4514
  meta: {
3876
4515
  name: "init",
3877
4516
  group: "lifecycle",
@@ -3904,7 +4543,7 @@ var initCommand = defineCommand9({
3904
4543
  ...withRepoList.length > 0 ? { withRepo: withRepoList } : {}
3905
4544
  });
3906
4545
  } catch (err) {
3907
- consola13.error(err instanceof Error ? err.message : String(err));
4546
+ consola14.error(err instanceof Error ? err.message : String(err));
3908
4547
  process.exit(1);
3909
4548
  }
3910
4549
  }
@@ -3948,8 +4587,8 @@ function collectWithList(withArg, rawArgs) {
3948
4587
  }
3949
4588
 
3950
4589
  // src/commands/list-components.ts
3951
- import { defineCommand as defineCommand10 } from "citty";
3952
- import { consola as consola14 } from "consola";
4590
+ import { defineCommand as defineCommand11 } from "citty";
4591
+ import { consola as consola15 } from "consola";
3953
4592
  var CATEGORY_LABELS = {
3954
4593
  language: "Languages",
3955
4594
  service: "Services",
@@ -3960,7 +4599,7 @@ var CATEGORY_ORDER = [
3960
4599
  "service",
3961
4600
  "feature"
3962
4601
  ];
3963
- var listComponentsCommand = defineCommand10({
4602
+ var listComponentsCommand = defineCommand11({
3964
4603
  meta: {
3965
4604
  name: "list-components",
3966
4605
  group: "discovery",
@@ -3971,7 +4610,7 @@ var listComponentsCommand = defineCommand10({
3971
4610
  try {
3972
4611
  const catalog = await loadComponentCatalog();
3973
4612
  if (catalog.size === 0) {
3974
- consola14.warn(
4613
+ consola15.warn(
3975
4614
  "No components found. The workbench checkout looks incomplete."
3976
4615
  );
3977
4616
  process.exit(0);
@@ -4022,15 +4661,15 @@ var listComponentsCommand = defineCommand10({
4022
4661
  }
4023
4662
  process.exit(0);
4024
4663
  } catch (err) {
4025
- consola14.error(err instanceof Error ? err.message : String(err));
4664
+ consola15.error(err instanceof Error ? err.message : String(err));
4026
4665
  process.exit(1);
4027
4666
  }
4028
4667
  }
4029
4668
  });
4030
4669
 
4031
4670
  // src/commands/logs.ts
4032
- import { defineCommand as defineCommand11 } from "citty";
4033
- var logsCommand = defineCommand11({
4671
+ import { defineCommand as defineCommand12 } from "citty";
4672
+ var logsCommand = defineCommand12({
4034
4673
  meta: {
4035
4674
  name: "logs",
4036
4675
  group: "run",
@@ -4064,10 +4703,87 @@ var logsCommand = defineCommand11({
4064
4703
  }
4065
4704
  });
4066
4705
 
4706
+ // src/commands/port.ts
4707
+ import { defineCommand as defineCommand13 } from "citty";
4708
+ import { consola as consola16 } from "consola";
4709
+ async function runPortListing(opts) {
4710
+ const out = opts.out ?? process.stdout;
4711
+ const info = opts.info ?? ((m) => consola16.info(m));
4712
+ const parsed = await readConfig(
4713
+ containerConfigPath(opts.name, opts.monocerosHome)
4714
+ );
4715
+ const portEntries = parsed.config.routing?.ports ?? [];
4716
+ if (portEntries.length === 0) {
4717
+ info(
4718
+ `No ports declared in ${opts.name}.yml. Run \`monoceros add-port ${opts.name} -- <port>\` to expose one.`
4719
+ );
4720
+ return 0;
4721
+ }
4722
+ const ports = portEntries.map(portNumber);
4723
+ const globalConfig = await readMonocerosConfig({
4724
+ ...opts.monocerosHome ? { monocerosHome: opts.monocerosHome } : {}
4725
+ });
4726
+ const hostPort = proxyHostPort(globalConfig);
4727
+ const urls = proxyUrlsFor(opts.name, ports, hostPort);
4728
+ const isTty2 = out.isTTY ?? false;
4729
+ const fmt = colorsFor(out);
4730
+ const portSuffix = hostPort === 80 ? "" : `:${hostPort}`;
4731
+ const rows = [];
4732
+ rows.push({
4733
+ port: urls[0].port,
4734
+ url: `http://${opts.name}.localhost${portSuffix}`,
4735
+ tag: "default"
4736
+ });
4737
+ for (const u of urls) {
4738
+ rows.push({ port: u.port, url: u.url, tag: "" });
4739
+ }
4740
+ if (!isTty2) {
4741
+ for (const r of rows) {
4742
+ out.write(`${r.port} ${r.url} ${r.tag}
4743
+ `);
4744
+ }
4745
+ return 0;
4746
+ }
4747
+ const portWidth = Math.max(...rows.map((r) => String(r.port).length));
4748
+ const urlWidth = Math.max(...rows.map((r) => r.url.length));
4749
+ const gutter = 2;
4750
+ for (const r of rows) {
4751
+ const portStr = String(r.port).padStart(portWidth);
4752
+ const urlPad = " ".repeat(urlWidth - r.url.length + gutter);
4753
+ const tag = r.tag ? fmt.dim(`(${r.tag})`) : "";
4754
+ out.write(` ${fmt.cyan(portStr)} \u2192 ${r.url}${urlPad}${tag}
4755
+ `);
4756
+ }
4757
+ return 0;
4758
+ }
4759
+ var portCommand = defineCommand13({
4760
+ meta: {
4761
+ name: "port",
4762
+ group: "discovery",
4763
+ 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."
4764
+ },
4765
+ args: {
4766
+ name: {
4767
+ type: "positional",
4768
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
4769
+ required: true
4770
+ }
4771
+ },
4772
+ async run({ args }) {
4773
+ try {
4774
+ const code = await runPortListing({ name: args.name });
4775
+ process.exit(code);
4776
+ } catch (err) {
4777
+ consola16.error(err instanceof Error ? err.message : String(err));
4778
+ process.exit(1);
4779
+ }
4780
+ }
4781
+ });
4782
+
4067
4783
  // src/commands/remove-apt-packages.ts
4068
- import { defineCommand as defineCommand12 } from "citty";
4069
- import { consola as consola15 } from "consola";
4070
- var removeAptPackagesCommand = defineCommand12({
4784
+ import { defineCommand as defineCommand14 } from "citty";
4785
+ import { consola as consola17 } from "consola";
4786
+ var removeAptPackagesCommand = defineCommand14({
4071
4787
  meta: {
4072
4788
  name: "remove-apt-packages",
4073
4789
  group: "edit",
@@ -4089,7 +4805,7 @@ var removeAptPackagesCommand = defineCommand12({
4089
4805
  async run({ args }) {
4090
4806
  const packages = [...getInnerArgs()];
4091
4807
  if (packages.length === 0) {
4092
- consola15.error(
4808
+ consola17.error(
4093
4809
  "No package names given. Usage: `monoceros remove-apt-packages <containername> [--yes] -- <pkg> [<pkg> \u2026]`."
4094
4810
  );
4095
4811
  process.exit(1);
@@ -4102,16 +4818,16 @@ var removeAptPackagesCommand = defineCommand12({
4102
4818
  });
4103
4819
  process.exit(result.status === "aborted" ? 1 : 0);
4104
4820
  } catch (err) {
4105
- consola15.error(err instanceof Error ? err.message : String(err));
4821
+ consola17.error(err instanceof Error ? err.message : String(err));
4106
4822
  process.exit(1);
4107
4823
  }
4108
4824
  }
4109
4825
  });
4110
4826
 
4111
4827
  // src/commands/remove-feature.ts
4112
- import { defineCommand as defineCommand13 } from "citty";
4113
- import { consola as consola16 } from "consola";
4114
- var removeFeatureCommand = defineCommand13({
4828
+ import { defineCommand as defineCommand15 } from "citty";
4829
+ import { consola as consola18 } from "consola";
4830
+ var removeFeatureCommand = defineCommand15({
4115
4831
  meta: {
4116
4832
  name: "remove-feature",
4117
4833
  group: "edit",
@@ -4144,27 +4860,27 @@ var removeFeatureCommand = defineCommand13({
4144
4860
  });
4145
4861
  process.exit(result.status === "aborted" ? 1 : 0);
4146
4862
  } catch (err) {
4147
- consola16.error(err instanceof Error ? err.message : String(err));
4863
+ consola18.error(err instanceof Error ? err.message : String(err));
4148
4864
  process.exit(1);
4149
4865
  }
4150
4866
  }
4151
4867
  });
4152
4868
 
4153
4869
  // src/commands/remove.ts
4154
- import { defineCommand as defineCommand14 } from "citty";
4155
- import { consola as consola18 } from "consola";
4870
+ import { defineCommand as defineCommand16 } from "citty";
4871
+ import { consola as consola20 } from "consola";
4156
4872
  import { createInterface } from "readline/promises";
4157
4873
 
4158
4874
  // src/remove/index.ts
4159
- import { existsSync as existsSync8, promises as fs11 } from "fs";
4160
- import path10 from "path";
4161
- import { consola as consola17 } from "consola";
4875
+ import { existsSync as existsSync8, promises as fs13 } from "fs";
4876
+ import path12 from "path";
4877
+ import { consola as consola19 } from "consola";
4162
4878
  async function runRemove(opts) {
4163
4879
  const home = opts.monocerosHome ?? monocerosHome();
4164
4880
  const logger = opts.logger ?? {
4165
- info: (msg) => consola17.info(msg),
4166
- success: (msg) => consola17.success(msg),
4167
- warn: (msg) => consola17.warn(msg)
4881
+ info: (msg) => consola19.info(msg),
4882
+ success: (msg) => consola19.success(msg),
4883
+ warn: (msg) => consola19.warn(msg)
4168
4884
  };
4169
4885
  if (!REGEX.solutionName.test(opts.name)) {
4170
4886
  throw new Error(
@@ -4208,23 +4924,23 @@ async function runRemove(opts) {
4208
4924
  let backupPath = null;
4209
4925
  if (!opts.noBackup && (hasYml || hasContainer)) {
4210
4926
  const ts = (opts.now ?? /* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4211
- backupPath = path10.join(home, "container-backups", `${opts.name}-${ts}`);
4212
- await fs11.mkdir(backupPath, { recursive: true });
4927
+ backupPath = path12.join(home, "container-backups", `${opts.name}-${ts}`);
4928
+ await fs13.mkdir(backupPath, { recursive: true });
4213
4929
  if (hasYml) {
4214
- await fs11.copyFile(ymlPath, path10.join(backupPath, `${opts.name}.yml`));
4930
+ await fs13.copyFile(ymlPath, path12.join(backupPath, `${opts.name}.yml`));
4215
4931
  }
4216
4932
  if (hasContainer) {
4217
- await fs11.cp(containerPath, path10.join(backupPath, "container"), {
4933
+ await fs13.cp(containerPath, path12.join(backupPath, "container"), {
4218
4934
  recursive: true
4219
4935
  });
4220
4936
  }
4221
4937
  logger.info(`Backup written to ${prettyPath(backupPath)}.`);
4222
4938
  }
4223
4939
  if (hasYml) {
4224
- await fs11.rm(ymlPath, { force: true });
4940
+ await fs13.rm(ymlPath, { force: true });
4225
4941
  }
4226
4942
  if (hasContainer) {
4227
- await fs11.rm(containerPath, { recursive: true, force: true });
4943
+ await fs13.rm(containerPath, { recursive: true, force: true });
4228
4944
  }
4229
4945
  logger.success(
4230
4946
  `Removed '${opts.name}': docker objects gone, container-configs entry deleted, container directory deleted.`
@@ -4234,6 +4950,24 @@ async function runRemove(opts) {
4234
4950
  "No backup created (--no-backup). The host-side state is gone for good."
4235
4951
  );
4236
4952
  }
4953
+ try {
4954
+ await removeDynamicConfig(opts.name, { monocerosHome: home });
4955
+ } catch (err) {
4956
+ logger.warn?.(
4957
+ `Could not remove Traefik dynamic config for ${opts.name}: ${err instanceof Error ? err.message : String(err)}. Ignored.`
4958
+ );
4959
+ }
4960
+ try {
4961
+ await maybeStopProxy({
4962
+ ...opts.proxyDocker ? { docker: opts.proxyDocker } : {},
4963
+ monocerosHome: home,
4964
+ logger: { info: (msg) => logger.info(msg), warn: logger.warn }
4965
+ });
4966
+ } catch (err) {
4967
+ logger.warn?.(
4968
+ `Could not tear down the Traefik proxy: ${err instanceof Error ? err.message : String(err)}. Ignored.`
4969
+ );
4970
+ }
4237
4971
  return {
4238
4972
  configPath: hasYml ? ymlPath : null,
4239
4973
  containerPath: hasContainer ? containerPath : null,
@@ -4243,7 +4977,7 @@ async function runRemove(opts) {
4243
4977
  }
4244
4978
 
4245
4979
  // src/commands/remove.ts
4246
- var removeCommand = defineCommand14({
4980
+ var removeCommand = defineCommand16({
4247
4981
  meta: {
4248
4982
  name: "remove",
4249
4983
  group: "lifecycle",
@@ -4280,7 +5014,7 @@ var removeCommand = defineCommand14({
4280
5014
  const skipPrompt = args.yes === true;
4281
5015
  if (!skipPrompt) {
4282
5016
  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.`;
4283
- consola18.warn(warning);
5017
+ consola20.warn(warning);
4284
5018
  const rl = createInterface({
4285
5019
  input: process.stdin,
4286
5020
  output: process.stdout
@@ -4288,7 +5022,7 @@ var removeCommand = defineCommand14({
4288
5022
  const answer = await rl.question("Continue? [y/N] ");
4289
5023
  rl.close();
4290
5024
  if (!/^y(es)?$/i.test(answer.trim())) {
4291
- consola18.info("Aborted. Nothing changed.");
5025
+ consola20.info("Aborted. Nothing changed.");
4292
5026
  process.exit(0);
4293
5027
  }
4294
5028
  }
@@ -4297,35 +5031,35 @@ var removeCommand = defineCommand14({
4297
5031
  ...noBackup ? { noBackup: true } : {}
4298
5032
  });
4299
5033
  } catch (err) {
4300
- consola18.error(err instanceof Error ? err.message : String(err));
5034
+ consola20.error(err instanceof Error ? err.message : String(err));
4301
5035
  process.exit(1);
4302
5036
  }
4303
5037
  }
4304
5038
  });
4305
5039
 
4306
5040
  // src/commands/restore.ts
4307
- import { defineCommand as defineCommand15 } from "citty";
4308
- import { consola as consola20 } from "consola";
5041
+ import { defineCommand as defineCommand17 } from "citty";
5042
+ import { consola as consola22 } from "consola";
4309
5043
 
4310
5044
  // src/restore/index.ts
4311
- import { existsSync as existsSync9, promises as fs12 } from "fs";
4312
- import path11 from "path";
4313
- import { consola as consola19 } from "consola";
5045
+ import { existsSync as existsSync9, promises as fs14 } from "fs";
5046
+ import path13 from "path";
5047
+ import { consola as consola21 } from "consola";
4314
5048
  async function runRestore(opts) {
4315
5049
  const home = opts.monocerosHome ?? monocerosHome();
4316
5050
  const logger = opts.logger ?? {
4317
- info: (msg) => consola19.info(msg),
4318
- success: (msg) => consola19.success(msg)
5051
+ info: (msg) => consola21.info(msg),
5052
+ success: (msg) => consola21.success(msg)
4319
5053
  };
4320
- const backup = path11.resolve(opts.backupPath);
5054
+ const backup = path13.resolve(opts.backupPath);
4321
5055
  if (!existsSync9(backup)) {
4322
5056
  throw new Error(`Backup not found: ${backup}.`);
4323
5057
  }
4324
- const stat = await fs12.stat(backup);
5058
+ const stat = await fs14.stat(backup);
4325
5059
  if (!stat.isDirectory()) {
4326
5060
  throw new Error(`Backup path is not a directory: ${backup}.`);
4327
5061
  }
4328
- const entries = await fs12.readdir(backup);
5062
+ const entries = await fs14.readdir(backup);
4329
5063
  const ymlFiles = entries.filter((f) => f.endsWith(".yml"));
4330
5064
  if (ymlFiles.length === 0) {
4331
5065
  throw new Error(
@@ -4339,7 +5073,7 @@ async function runRestore(opts) {
4339
5073
  }
4340
5074
  const ymlFile = ymlFiles[0];
4341
5075
  const name = ymlFile.replace(/\.yml$/, "");
4342
- const containerInBackup = path11.join(backup, "container");
5076
+ const containerInBackup = path13.join(backup, "container");
4343
5077
  const hasContainer = existsSync9(containerInBackup);
4344
5078
  const destYml = containerConfigPath(name, home);
4345
5079
  const destContainer = containerDir(name, home);
@@ -4353,10 +5087,10 @@ async function runRestore(opts) {
4353
5087
  `Refusing to restore: ${destContainer} already exists. Remove the current container first (\`monoceros remove ${name}\`).`
4354
5088
  );
4355
5089
  }
4356
- await fs12.mkdir(containerConfigsDir(home), { recursive: true });
4357
- await fs12.copyFile(path11.join(backup, ymlFile), destYml);
5090
+ await fs14.mkdir(containerConfigsDir(home), { recursive: true });
5091
+ await fs14.copyFile(path13.join(backup, ymlFile), destYml);
4358
5092
  if (hasContainer) {
4359
- await fs12.cp(containerInBackup, destContainer, { recursive: true });
5093
+ await fs14.cp(containerInBackup, destContainer, { recursive: true });
4360
5094
  }
4361
5095
  logger.success(`Restored '${name}' from ${prettyPath(backup)}.`);
4362
5096
  logger.info(
@@ -4370,7 +5104,7 @@ async function runRestore(opts) {
4370
5104
  }
4371
5105
 
4372
5106
  // src/commands/restore.ts
4373
- var restoreCommand = defineCommand15({
5107
+ var restoreCommand = defineCommand17({
4374
5108
  meta: {
4375
5109
  name: "restore",
4376
5110
  group: "lifecycle",
@@ -4387,16 +5121,16 @@ var restoreCommand = defineCommand15({
4387
5121
  try {
4388
5122
  await runRestore({ backupPath: args["backup-path"] });
4389
5123
  } catch (err) {
4390
- consola20.error(err instanceof Error ? err.message : String(err));
5124
+ consola22.error(err instanceof Error ? err.message : String(err));
4391
5125
  process.exit(1);
4392
5126
  }
4393
5127
  }
4394
5128
  });
4395
5129
 
4396
5130
  // src/commands/remove-from-url.ts
4397
- import { defineCommand as defineCommand16 } from "citty";
4398
- import { consola as consola21 } from "consola";
4399
- var removeFromUrlCommand = defineCommand16({
5131
+ import { defineCommand as defineCommand18 } from "citty";
5132
+ import { consola as consola23 } from "consola";
5133
+ var removeFromUrlCommand = defineCommand18({
4400
5134
  meta: {
4401
5135
  name: "remove-from-url",
4402
5136
  group: "edit",
@@ -4429,16 +5163,16 @@ var removeFromUrlCommand = defineCommand16({
4429
5163
  });
4430
5164
  process.exit(result.status === "aborted" ? 1 : 0);
4431
5165
  } catch (err) {
4432
- consola21.error(err instanceof Error ? err.message : String(err));
5166
+ consola23.error(err instanceof Error ? err.message : String(err));
4433
5167
  process.exit(1);
4434
5168
  }
4435
5169
  }
4436
5170
  });
4437
5171
 
4438
5172
  // src/commands/remove-language.ts
4439
- import { defineCommand as defineCommand17 } from "citty";
4440
- import { consola as consola22 } from "consola";
4441
- var removeLanguageCommand = defineCommand17({
5173
+ import { defineCommand as defineCommand19 } from "citty";
5174
+ import { consola as consola24 } from "consola";
5175
+ var removeLanguageCommand = defineCommand19({
4442
5176
  meta: {
4443
5177
  name: "remove-language",
4444
5178
  group: "edit",
@@ -4471,16 +5205,64 @@ var removeLanguageCommand = defineCommand17({
4471
5205
  });
4472
5206
  process.exit(result.status === "aborted" ? 1 : 0);
4473
5207
  } catch (err) {
4474
- consola22.error(err instanceof Error ? err.message : String(err));
5208
+ consola24.error(err instanceof Error ? err.message : String(err));
5209
+ process.exit(1);
5210
+ }
5211
+ }
5212
+ });
5213
+
5214
+ // src/commands/remove-port.ts
5215
+ import { defineCommand as defineCommand20 } from "citty";
5216
+ import { consola as consola25 } from "consola";
5217
+ var removePortCommand = defineCommand20({
5218
+ meta: {
5219
+ name: "remove-port",
5220
+ group: "edit",
5221
+ 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."
5222
+ },
5223
+ args: {
5224
+ name: {
5225
+ type: "positional",
5226
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
5227
+ required: true
5228
+ },
5229
+ yes: {
5230
+ type: "boolean",
5231
+ description: "Skip the interactive confirmation and apply the diff.",
5232
+ alias: ["y"],
5233
+ default: false
5234
+ }
5235
+ },
5236
+ async run({ args }) {
5237
+ const tokens = [...getInnerArgs()];
5238
+ if (tokens.length === 0) {
5239
+ consola25.error(
5240
+ "No ports given. Usage: `monoceros remove-port <containername> [--yes] -- <port> [<port> \u2026]`."
5241
+ );
5242
+ process.exit(1);
5243
+ }
5244
+ try {
5245
+ const result = await runRemovePort({
5246
+ name: args.name,
5247
+ ports: tokens.map(coerceToken2),
5248
+ yes: args.yes
5249
+ });
5250
+ process.exit(result.status === "aborted" ? 1 : 0);
5251
+ } catch (err) {
5252
+ consola25.error(err instanceof Error ? err.message : String(err));
4475
5253
  process.exit(1);
4476
5254
  }
4477
5255
  }
4478
5256
  });
5257
+ function coerceToken2(raw) {
5258
+ const n = Number(raw);
5259
+ return Number.isFinite(n) ? n : raw;
5260
+ }
4479
5261
 
4480
5262
  // src/commands/remove-repo.ts
4481
- import { defineCommand as defineCommand18 } from "citty";
4482
- import { consola as consola23 } from "consola";
4483
- var removeRepoCommand = defineCommand18({
5263
+ import { defineCommand as defineCommand21 } from "citty";
5264
+ import { consola as consola26 } from "consola";
5265
+ var removeRepoCommand = defineCommand21({
4484
5266
  meta: {
4485
5267
  name: "remove-repo",
4486
5268
  group: "edit",
@@ -4513,16 +5295,16 @@ var removeRepoCommand = defineCommand18({
4513
5295
  });
4514
5296
  process.exit(result.status === "aborted" ? 1 : 0);
4515
5297
  } catch (err) {
4516
- consola23.error(err instanceof Error ? err.message : String(err));
5298
+ consola26.error(err instanceof Error ? err.message : String(err));
4517
5299
  process.exit(1);
4518
5300
  }
4519
5301
  }
4520
5302
  });
4521
5303
 
4522
5304
  // src/commands/remove-service.ts
4523
- import { defineCommand as defineCommand19 } from "citty";
4524
- import { consola as consola24 } from "consola";
4525
- var removeServiceCommand = defineCommand19({
5305
+ import { defineCommand as defineCommand22 } from "citty";
5306
+ import { consola as consola27 } from "consola";
5307
+ var removeServiceCommand = defineCommand22({
4526
5308
  meta: {
4527
5309
  name: "remove-service",
4528
5310
  group: "edit",
@@ -4555,19 +5337,19 @@ var removeServiceCommand = defineCommand19({
4555
5337
  });
4556
5338
  process.exit(result.status === "aborted" ? 1 : 0);
4557
5339
  } catch (err) {
4558
- consola24.error(err instanceof Error ? err.message : String(err));
5340
+ consola27.error(err instanceof Error ? err.message : String(err));
4559
5341
  process.exit(1);
4560
5342
  }
4561
5343
  }
4562
5344
  });
4563
5345
 
4564
5346
  // src/commands/run.ts
4565
- import { defineCommand as defineCommand20 } from "citty";
4566
- import { consola as consola25 } from "consola";
5347
+ import { defineCommand as defineCommand23 } from "citty";
5348
+ import { consola as consola28 } from "consola";
4567
5349
 
4568
5350
  // src/devcontainer/shell.ts
4569
5351
  import { existsSync as existsSync10 } from "fs";
4570
- import path12 from "path";
5352
+ import path14 from "path";
4571
5353
  async function runShell(opts) {
4572
5354
  assertContainerExists(opts.root);
4573
5355
  const spawnFn = opts.spawn ?? spawnDevcontainer;
@@ -4590,7 +5372,7 @@ async function runShell(opts) {
4590
5372
  );
4591
5373
  }
4592
5374
  function assertContainerExists(root) {
4593
- if (!existsSync10(path12.join(root, ".devcontainer"))) {
5375
+ if (!existsSync10(path14.join(root, ".devcontainer"))) {
4594
5376
  throw new Error(
4595
5377
  `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
4596
5378
  );
@@ -4626,7 +5408,7 @@ async function runInContainer(opts) {
4626
5408
  }
4627
5409
 
4628
5410
  // src/commands/run.ts
4629
- var runCommand = defineCommand20({
5411
+ var runCommand = defineCommand23({
4630
5412
  meta: {
4631
5413
  name: "run",
4632
5414
  group: "run",
@@ -4642,7 +5424,7 @@ var runCommand = defineCommand20({
4642
5424
  async run({ args }) {
4643
5425
  const command = [...getInnerArgs()];
4644
5426
  if (command.length === 0) {
4645
- consola25.error(
5427
+ consola28.error(
4646
5428
  "No command provided. Usage: `monoceros run <containername> -- <cmd> [args\u2026]`."
4647
5429
  );
4648
5430
  process.exit(1);
@@ -4654,16 +5436,16 @@ var runCommand = defineCommand20({
4654
5436
  });
4655
5437
  process.exit(exitCode);
4656
5438
  } catch (err) {
4657
- consola25.error(err instanceof Error ? err.message : String(err));
5439
+ consola28.error(err instanceof Error ? err.message : String(err));
4658
5440
  process.exit(1);
4659
5441
  }
4660
5442
  }
4661
5443
  });
4662
5444
 
4663
5445
  // src/commands/shell.ts
4664
- import { defineCommand as defineCommand21 } from "citty";
4665
- import { consola as consola26 } from "consola";
4666
- var shellCommand = defineCommand21({
5446
+ import { defineCommand as defineCommand24 } from "citty";
5447
+ import { consola as consola29 } from "consola";
5448
+ var shellCommand = defineCommand24({
4667
5449
  meta: {
4668
5450
  name: "shell",
4669
5451
  group: "run",
@@ -4681,15 +5463,16 @@ var shellCommand = defineCommand21({
4681
5463
  const exitCode = await runShell({ root: containerDir(args.name) });
4682
5464
  process.exit(exitCode);
4683
5465
  } catch (err) {
4684
- consola26.error(err instanceof Error ? err.message : String(err));
5466
+ consola29.error(err instanceof Error ? err.message : String(err));
4685
5467
  process.exit(1);
4686
5468
  }
4687
5469
  }
4688
5470
  });
4689
5471
 
4690
5472
  // src/commands/start.ts
4691
- import { defineCommand as defineCommand22 } from "citty";
4692
- var startCommand = defineCommand22({
5473
+ import { defineCommand as defineCommand25 } from "citty";
5474
+ import { consola as consola30 } from "consola";
5475
+ var startCommand = defineCommand25({
4693
5476
  meta: {
4694
5477
  name: "start",
4695
5478
  group: "run",
@@ -4703,13 +5486,33 @@ var startCommand = defineCommand22({
4703
5486
  }
4704
5487
  },
4705
5488
  run({ args }) {
4706
- return dispatch(() => runStart({ root: containerDir(args.name) }));
5489
+ return dispatch(async () => {
5490
+ let needsProxy = false;
5491
+ let hostPort = 80;
5492
+ try {
5493
+ const parsed = await readConfig(containerConfigPath(args.name));
5494
+ if ((parsed.config.routing?.ports ?? []).length > 0) {
5495
+ needsProxy = true;
5496
+ const global = await readMonocerosConfig();
5497
+ hostPort = proxyHostPort(global);
5498
+ }
5499
+ } catch (err) {
5500
+ consola30.warn(
5501
+ `Could not read container yml ahead of start: ${err instanceof Error ? err.message : String(err)}. Skipping Traefik pre-flight.`
5502
+ );
5503
+ }
5504
+ if (needsProxy) {
5505
+ await preflightHostPort(hostPort);
5506
+ await ensureProxy({ hostPort });
5507
+ }
5508
+ return runStart({ root: containerDir(args.name) });
5509
+ });
4707
5510
  }
4708
5511
  });
4709
5512
 
4710
5513
  // src/commands/status.ts
4711
- import { defineCommand as defineCommand23 } from "citty";
4712
- var statusCommand = defineCommand23({
5514
+ import { defineCommand as defineCommand26 } from "citty";
5515
+ var statusCommand = defineCommand26({
4713
5516
  meta: {
4714
5517
  name: "status",
4715
5518
  group: "run",
@@ -4737,8 +5540,9 @@ var statusCommand = defineCommand23({
4737
5540
  });
4738
5541
 
4739
5542
  // src/commands/stop.ts
4740
- import { defineCommand as defineCommand24 } from "citty";
4741
- var stopCommand = defineCommand24({
5543
+ import { defineCommand as defineCommand27 } from "citty";
5544
+ import { consola as consola31 } from "consola";
5545
+ var stopCommand = defineCommand27({
4742
5546
  meta: {
4743
5547
  name: "stop",
4744
5548
  group: "run",
@@ -4756,17 +5560,27 @@ var stopCommand = defineCommand24({
4756
5560
  }
4757
5561
  },
4758
5562
  run({ args }) {
4759
- return dispatch(
4760
- () => runStop({
5563
+ return dispatch(async () => {
5564
+ const exit = await runStop({
4761
5565
  root: containerDir(args.name),
4762
5566
  ...typeof args.service === "string" ? { service: args.service } : {}
4763
- })
4764
- );
5567
+ });
5568
+ try {
5569
+ await maybeStopProxy({
5570
+ logger: { info: (msg) => consola31.info(msg) }
5571
+ });
5572
+ } catch (err) {
5573
+ consola31.warn(
5574
+ `Could not tear down the Traefik proxy: ${err instanceof Error ? err.message : String(err)}. Ignored.`
5575
+ );
5576
+ }
5577
+ return exit;
5578
+ });
4765
5579
  }
4766
5580
  });
4767
5581
 
4768
5582
  // src/main.ts
4769
- var main = defineCommand25({
5583
+ var main = defineCommand28({
4770
5584
  meta: {
4771
5585
  name: "monoceros",
4772
5586
  version: CLI_VERSION,
@@ -4790,12 +5604,15 @@ var main = defineCommand25({
4790
5604
  "add-feature": addFeatureCommand,
4791
5605
  "add-from-url": addFromUrlCommand,
4792
5606
  "add-repo": addRepoCommand,
5607
+ "add-port": addPortCommand,
4793
5608
  "remove-service": removeServiceCommand,
4794
5609
  "remove-language": removeLanguageCommand,
4795
5610
  "remove-apt-packages": removeAptPackagesCommand,
4796
5611
  "remove-feature": removeFeatureCommand,
4797
5612
  "remove-from-url": removeFromUrlCommand,
4798
5613
  "remove-repo": removeRepoCommand,
5614
+ "remove-port": removePortCommand,
5615
+ port: portCommand,
4799
5616
  completion: completionCommand
4800
5617
  }
4801
5618
  });