@getmonoceros/workbench 1.6.12 → 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
  {
@@ -2732,13 +3309,13 @@ function formatRootlessNotSupportedError() {
2732
3309
  }
2733
3310
 
2734
3311
  // src/devcontainer/identity.ts
2735
- import { spawn as spawn6 } from "child_process";
2736
- import { promises as fs7 } from "fs";
2737
- import path7 from "path";
2738
- import { consola as consola9 } from "consola";
3312
+ import { spawn as spawn7 } from "child_process";
3313
+ import { promises as fs9 } from "fs";
3314
+ import path9 from "path";
3315
+ import { consola as consola10 } from "consola";
2739
3316
  var realGitConfigGet = (key) => {
2740
3317
  return new Promise((resolve, reject) => {
2741
- const child = spawn6("git", ["config", "--global", "--get", key], {
3318
+ const child = spawn7("git", ["config", "--global", "--get", key], {
2742
3319
  stdio: ["ignore", "pipe", "inherit"]
2743
3320
  });
2744
3321
  let stdout = "";
@@ -2757,14 +3334,14 @@ var realIdentityPrompt = async (key) => {
2757
3334
  return void 0;
2758
3335
  }
2759
3336
  const label = key === "user.name" ? "Git user.name for this dev container (full name)" : "Git user.email for this dev container";
2760
- const value = await consola9.prompt(`${label}:`, { type: "text" });
3337
+ const value = await consola10.prompt(`${label}:`, { type: "text" });
2761
3338
  if (typeof value !== "string") return void 0;
2762
3339
  const trimmed = value.trim();
2763
3340
  return trimmed.length > 0 ? trimmed : void 0;
2764
3341
  };
2765
3342
  async function collectGitIdentity(devContainerRoot, options = {}) {
2766
- const gitconfigDir = path7.join(devContainerRoot, ".monoceros");
2767
- const gitconfigPath = path7.join(gitconfigDir, "gitconfig");
3343
+ const gitconfigDir = path9.join(devContainerRoot, ".monoceros");
3344
+ const gitconfigPath = path9.join(gitconfigDir, "gitconfig");
2768
3345
  const spawnFn = options.spawn ?? realGitConfigGet;
2769
3346
  const promptFn = options.prompt ?? realIdentityPrompt;
2770
3347
  const logger = options.logger ?? { info: () => {
@@ -2790,8 +3367,8 @@ async function collectGitIdentity(devContainerRoot, options = {}) {
2790
3367
  const lines = ["[user]"];
2791
3368
  if (name !== void 0) lines.push(` name = ${name}`);
2792
3369
  if (email !== void 0) lines.push(` email = ${email}`);
2793
- await fs7.mkdir(gitconfigDir, { recursive: true });
2794
- await fs7.writeFile(gitconfigPath, lines.join("\n") + "\n");
3370
+ await fs9.mkdir(gitconfigDir, { recursive: true });
3371
+ await fs9.writeFile(gitconfigPath, lines.join("\n") + "\n");
2795
3372
  return {
2796
3373
  ...name !== void 0 ? { name } : {},
2797
3374
  ...email !== void 0 ? { email } : {},
@@ -2833,7 +3410,7 @@ async function readKeyFromHost(spawnFn, key, logger) {
2833
3410
  }
2834
3411
  async function readExistingGitconfig(filePath) {
2835
3412
  try {
2836
- const content = await fs7.readFile(filePath, "utf8");
3413
+ const content = await fs9.readFile(filePath, "utf8");
2837
3414
  const result = {};
2838
3415
  const nameMatch = /^\s*name\s*=\s*(.+?)\s*$/m.exec(content);
2839
3416
  const emailMatch = /^\s*email\s*=\s*(.+?)\s*$/m.exec(content);
@@ -2849,9 +3426,9 @@ async function readExistingGitconfig(filePath) {
2849
3426
  async function runApply(opts) {
2850
3427
  const home = opts.monocerosHome ?? monocerosHome();
2851
3428
  const logger = opts.logger ?? {
2852
- info: (msg) => consola10.info(msg),
2853
- success: (msg) => consola10.success(msg),
2854
- warn: (msg) => consola10.warn(msg),
3429
+ info: (msg) => consola11.info(msg),
3430
+ success: (msg) => consola11.success(msg),
3431
+ warn: (msg) => consola11.warn(msg),
2855
3432
  // Default section renderer: empty line, bold-underlined "▸ Label",
2856
3433
  // empty line. Mirrors install.sh's section visuals.
2857
3434
  section: (label) => process.stderr.write(`
@@ -2933,7 +3510,7 @@ ${sectionLine(label)}
2933
3510
  if (dockerMode === "rootless") {
2934
3511
  throw new Error(formatRootlessNotSupportedError());
2935
3512
  }
2936
- await fs8.mkdir(targetDir, { recursive: true });
3513
+ await fs10.mkdir(targetDir, { recursive: true });
2937
3514
  await writeScaffold(createOpts, targetDir, { dockerMode });
2938
3515
  await writeStateFile(
2939
3516
  targetDir,
@@ -2954,6 +3531,30 @@ ${sectionLine(label)}
2954
3531
  'Pulling runtime image and building feature layers. First apply takes ~1\u20132 min (Docker downloads the multi-arch base); subsequent applies are cached and fast. devcontainer-cli may log a "No manifest found" line \u2014 harmless, the pull continues.'
2955
3532
  )
2956
3533
  );
3534
+ const ports = createOpts.ports ?? [];
3535
+ const hasPorts = ports.length > 0;
3536
+ if (hasPorts) {
3537
+ await preflightHostPort(proxyHostPort(globalConfig), {
3538
+ ...opts.proxyDocker ? { docker: opts.proxyDocker } : {}
3539
+ });
3540
+ }
3541
+ try {
3542
+ if (hasPorts) {
3543
+ await writeDynamicConfig(opts.name, ports, { monocerosHome: home });
3544
+ await ensureProxy({
3545
+ ...opts.proxyDocker ? { docker: opts.proxyDocker } : {},
3546
+ monocerosHome: home,
3547
+ hostPort: proxyHostPort(globalConfig),
3548
+ logger
3549
+ });
3550
+ } else {
3551
+ await removeDynamicConfig(opts.name, { monocerosHome: home });
3552
+ }
3553
+ } catch (err) {
3554
+ logger.warn?.(
3555
+ `Could not sync Traefik routes: ${err instanceof Error ? err.message : String(err)}. The container will start, but \`<name>.localhost\` routing may not work until the next \`monoceros apply\`.`
3556
+ );
3557
+ }
2957
3558
  const exitCode = await runContainerCycle(targetDir, {
2958
3559
  hasCompose: needsCompose(createOpts),
2959
3560
  ...opts.cleanupSpawn !== void 0 ? { cleanupSpawn: opts.cleanupSpawn } : {},
@@ -2968,7 +3569,7 @@ ${sectionLine(label)}
2968
3569
  }
2969
3570
  async function assertSafeTargetDir(targetDir, expectedOrigin) {
2970
3571
  if (!existsSync4(targetDir)) return;
2971
- const entries = await fs8.readdir(targetDir);
3572
+ const entries = await fs10.readdir(targetDir);
2972
3573
  if (entries.length === 0) return;
2973
3574
  const state = await readStateFile(targetDir);
2974
3575
  if (state) {
@@ -3010,22 +3611,22 @@ function warnOnDeprecatedFeatureRefs(containerFeatures, globalConfig, logger) {
3010
3611
  }
3011
3612
 
3012
3613
  // src/version.ts
3013
- var CLI_VERSION = true ? "1.6.12" : "dev";
3614
+ var CLI_VERSION = true ? "1.7.0" : "dev";
3014
3615
 
3015
3616
  // src/commands/_dispatch.ts
3016
- import { consola as consola11 } from "consola";
3617
+ import { consola as consola12 } from "consola";
3017
3618
  async function dispatch(runner) {
3018
3619
  try {
3019
3620
  const exitCode = await runner();
3020
3621
  process.exit(exitCode);
3021
3622
  } catch (err) {
3022
- consola11.error(err instanceof Error ? err.message : String(err));
3623
+ consola12.error(err instanceof Error ? err.message : String(err));
3023
3624
  process.exit(1);
3024
3625
  }
3025
3626
  }
3026
3627
 
3027
3628
  // src/commands/apply.ts
3028
- var applyCommand = defineCommand7({
3629
+ var applyCommand = defineCommand8({
3029
3630
  meta: {
3030
3631
  name: "apply",
3031
3632
  group: "lifecycle",
@@ -3050,7 +3651,7 @@ var applyCommand = defineCommand7({
3050
3651
  });
3051
3652
 
3052
3653
  // src/commands/completion.ts
3053
- import { defineCommand as defineCommand8 } from "citty";
3654
+ import { defineCommand as defineCommand9 } from "citty";
3054
3655
  var ALL_COMMANDS = [
3055
3656
  "init",
3056
3657
  "list-components",
@@ -3069,12 +3670,15 @@ var ALL_COMMANDS = [
3069
3670
  "add-feature",
3070
3671
  "add-from-url",
3071
3672
  "add-repo",
3673
+ "add-port",
3072
3674
  "remove-service",
3073
3675
  "remove-language",
3074
3676
  "remove-apt-packages",
3075
3677
  "remove-feature",
3076
3678
  "remove-from-url",
3077
3679
  "remove-repo",
3680
+ "remove-port",
3681
+ "port",
3078
3682
  "completion"
3079
3683
  ];
3080
3684
  var COMMANDS_WITH_CONTAINER_ARG = [
@@ -3092,12 +3696,15 @@ var COMMANDS_WITH_CONTAINER_ARG = [
3092
3696
  "add-feature",
3093
3697
  "add-from-url",
3094
3698
  "add-repo",
3699
+ "add-port",
3095
3700
  "remove-service",
3096
3701
  "remove-language",
3097
3702
  "remove-apt-packages",
3098
3703
  "remove-feature",
3099
3704
  "remove-from-url",
3100
- "remove-repo"
3705
+ "remove-repo",
3706
+ "remove-port",
3707
+ "port"
3101
3708
  ];
3102
3709
  var SHELLS = ["bash", "zsh", "pwsh"];
3103
3710
  function renderCompletionScript(shell) {
@@ -3230,7 +3837,7 @@ function renderCompletionScript(shell) {
3230
3837
  ""
3231
3838
  ].join("\n");
3232
3839
  }
3233
- var completionCommand = defineCommand8({
3840
+ var completionCommand = defineCommand9({
3234
3841
  meta: {
3235
3842
  name: "completion",
3236
3843
  group: "tooling",
@@ -3257,16 +3864,16 @@ var completionCommand = defineCommand8({
3257
3864
  });
3258
3865
 
3259
3866
  // src/commands/init.ts
3260
- import { defineCommand as defineCommand9 } from "citty";
3261
- import { consola as consola13 } from "consola";
3867
+ import { defineCommand as defineCommand10 } from "citty";
3868
+ import { consola as consola14 } from "consola";
3262
3869
 
3263
3870
  // src/init/index.ts
3264
- import { existsSync as existsSync7, promises as fs10 } from "fs";
3265
- import { consola as consola12 } from "consola";
3871
+ import { existsSync as existsSync7, promises as fs12 } from "fs";
3872
+ import { consola as consola13 } from "consola";
3266
3873
 
3267
3874
  // src/init/components.ts
3268
- import { existsSync as existsSync5, promises as fs9 } from "fs";
3269
- import path8 from "path";
3875
+ import { existsSync as existsSync5, promises as fs11 } from "fs";
3876
+ import path10 from "path";
3270
3877
  import { z as z3 } from "zod";
3271
3878
  import { parse as parseYaml } from "yaml";
3272
3879
  var CategorySchema = z3.enum(["language", "service", "feature"]);
@@ -3321,17 +3928,17 @@ async function loadComponentCatalog(rootDir = componentsDir()) {
3321
3928
  return out;
3322
3929
  }
3323
3930
  async function walk(baseDir, currentDir, out) {
3324
- const entries = await fs9.readdir(currentDir, { withFileTypes: true });
3931
+ const entries = await fs11.readdir(currentDir, { withFileTypes: true });
3325
3932
  for (const entry2 of entries) {
3326
- const full = path8.join(currentDir, entry2.name);
3933
+ const full = path10.join(currentDir, entry2.name);
3327
3934
  if (entry2.isDirectory()) {
3328
3935
  await walk(baseDir, full, out);
3329
3936
  continue;
3330
3937
  }
3331
3938
  if (!entry2.isFile() || !entry2.name.endsWith(".yml")) continue;
3332
- const relative = path8.relative(baseDir, full);
3333
- const name = relative.replace(/\.yml$/, "").split(path8.sep).join("/");
3334
- const text = await fs9.readFile(full, "utf8");
3939
+ const relative = path10.relative(baseDir, full);
3940
+ const name = relative.replace(/\.yml$/, "").split(path10.sep).join("/");
3941
+ const text = await fs11.readFile(full, "utf8");
3335
3942
  let raw;
3336
3943
  try {
3337
3944
  raw = parseYaml(text);
@@ -3587,6 +4194,7 @@ function generateDocumentedYml(name, catalog, lookupManifest, repoUrls = []) {
3587
4194
  /* commented */
3588
4195
  repoUrls.length === 0
3589
4196
  );
4197
+ renderRoutingBlock(lines);
3590
4198
  return ensureTrailingNewline(lines.join("\n"));
3591
4199
  }
3592
4200
  var COMMENT_WIDTH = 72;
@@ -3686,6 +4294,31 @@ function renderReposBlock(out, urls, commented) {
3686
4294
  }
3687
4295
  out.push("");
3688
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
+ }
3689
4322
  function deriveDefaultPath(url) {
3690
4323
  let last = url;
3691
4324
  const slash = url.lastIndexOf("/");
@@ -3740,10 +4373,10 @@ function ensureTrailingNewline(s) {
3740
4373
 
3741
4374
  // src/init/manifest.ts
3742
4375
  import { existsSync as existsSync6, readFileSync as readFileSync3 } from "fs";
3743
- import path9 from "path";
4376
+ import path11 from "path";
3744
4377
  function resolveManifestPath(name, checkoutRoot) {
3745
4378
  if (checkoutRoot) {
3746
- const checkoutPath = path9.join(
4379
+ const checkoutPath = path11.join(
3747
4380
  checkoutRoot,
3748
4381
  "images",
3749
4382
  "features",
@@ -3752,7 +4385,7 @@ function resolveManifestPath(name, checkoutRoot) {
3752
4385
  );
3753
4386
  if (existsSync6(checkoutPath)) return checkoutPath;
3754
4387
  }
3755
- const bundlePath = path9.join(
4388
+ const bundlePath = path11.join(
3756
4389
  bundledFeaturesDir(),
3757
4390
  name,
3758
4391
  "devcontainer-feature.json"
@@ -3795,8 +4428,8 @@ async function runInit(opts) {
3795
4428
  const workbench = opts.workbenchRoot ?? workbenchRoot();
3796
4429
  const home = opts.monocerosHome ?? monocerosHome();
3797
4430
  const logger = opts.logger ?? {
3798
- success: (msg) => consola12.success(msg),
3799
- info: (msg) => consola12.info(msg)
4431
+ success: (msg) => consola13.success(msg),
4432
+ info: (msg) => consola13.info(msg)
3800
4433
  };
3801
4434
  if (!REGEX.solutionName.test(opts.name)) {
3802
4435
  throw new Error(
@@ -3857,8 +4490,8 @@ async function runInit(opts) {
3857
4490
  const components = resolveComponents(catalog, requested);
3858
4491
  text = generateComposedYml(opts.name, components, lookup, repos);
3859
4492
  }
3860
- await fs10.mkdir(containerConfigsDir(home), { recursive: true });
3861
- await fs10.writeFile(dest, text, "utf8");
4493
+ await fs12.mkdir(containerConfigsDir(home), { recursive: true });
4494
+ await fs12.writeFile(dest, text, "utf8");
3862
4495
  const documented = requested.length === 0;
3863
4496
  const displayPath = prettyPath(dest);
3864
4497
  if (documented) {
@@ -3877,7 +4510,7 @@ async function runInit(opts) {
3877
4510
  }
3878
4511
 
3879
4512
  // src/commands/init.ts
3880
- var initCommand = defineCommand9({
4513
+ var initCommand = defineCommand10({
3881
4514
  meta: {
3882
4515
  name: "init",
3883
4516
  group: "lifecycle",
@@ -3910,7 +4543,7 @@ var initCommand = defineCommand9({
3910
4543
  ...withRepoList.length > 0 ? { withRepo: withRepoList } : {}
3911
4544
  });
3912
4545
  } catch (err) {
3913
- consola13.error(err instanceof Error ? err.message : String(err));
4546
+ consola14.error(err instanceof Error ? err.message : String(err));
3914
4547
  process.exit(1);
3915
4548
  }
3916
4549
  }
@@ -3954,8 +4587,8 @@ function collectWithList(withArg, rawArgs) {
3954
4587
  }
3955
4588
 
3956
4589
  // src/commands/list-components.ts
3957
- import { defineCommand as defineCommand10 } from "citty";
3958
- import { consola as consola14 } from "consola";
4590
+ import { defineCommand as defineCommand11 } from "citty";
4591
+ import { consola as consola15 } from "consola";
3959
4592
  var CATEGORY_LABELS = {
3960
4593
  language: "Languages",
3961
4594
  service: "Services",
@@ -3966,7 +4599,7 @@ var CATEGORY_ORDER = [
3966
4599
  "service",
3967
4600
  "feature"
3968
4601
  ];
3969
- var listComponentsCommand = defineCommand10({
4602
+ var listComponentsCommand = defineCommand11({
3970
4603
  meta: {
3971
4604
  name: "list-components",
3972
4605
  group: "discovery",
@@ -3977,7 +4610,7 @@ var listComponentsCommand = defineCommand10({
3977
4610
  try {
3978
4611
  const catalog = await loadComponentCatalog();
3979
4612
  if (catalog.size === 0) {
3980
- consola14.warn(
4613
+ consola15.warn(
3981
4614
  "No components found. The workbench checkout looks incomplete."
3982
4615
  );
3983
4616
  process.exit(0);
@@ -4028,15 +4661,15 @@ var listComponentsCommand = defineCommand10({
4028
4661
  }
4029
4662
  process.exit(0);
4030
4663
  } catch (err) {
4031
- consola14.error(err instanceof Error ? err.message : String(err));
4664
+ consola15.error(err instanceof Error ? err.message : String(err));
4032
4665
  process.exit(1);
4033
4666
  }
4034
4667
  }
4035
4668
  });
4036
4669
 
4037
4670
  // src/commands/logs.ts
4038
- import { defineCommand as defineCommand11 } from "citty";
4039
- var logsCommand = defineCommand11({
4671
+ import { defineCommand as defineCommand12 } from "citty";
4672
+ var logsCommand = defineCommand12({
4040
4673
  meta: {
4041
4674
  name: "logs",
4042
4675
  group: "run",
@@ -4070,10 +4703,87 @@ var logsCommand = defineCommand11({
4070
4703
  }
4071
4704
  });
4072
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
+
4073
4783
  // src/commands/remove-apt-packages.ts
4074
- import { defineCommand as defineCommand12 } from "citty";
4075
- import { consola as consola15 } from "consola";
4076
- var removeAptPackagesCommand = defineCommand12({
4784
+ import { defineCommand as defineCommand14 } from "citty";
4785
+ import { consola as consola17 } from "consola";
4786
+ var removeAptPackagesCommand = defineCommand14({
4077
4787
  meta: {
4078
4788
  name: "remove-apt-packages",
4079
4789
  group: "edit",
@@ -4095,7 +4805,7 @@ var removeAptPackagesCommand = defineCommand12({
4095
4805
  async run({ args }) {
4096
4806
  const packages = [...getInnerArgs()];
4097
4807
  if (packages.length === 0) {
4098
- consola15.error(
4808
+ consola17.error(
4099
4809
  "No package names given. Usage: `monoceros remove-apt-packages <containername> [--yes] -- <pkg> [<pkg> \u2026]`."
4100
4810
  );
4101
4811
  process.exit(1);
@@ -4108,16 +4818,16 @@ var removeAptPackagesCommand = defineCommand12({
4108
4818
  });
4109
4819
  process.exit(result.status === "aborted" ? 1 : 0);
4110
4820
  } catch (err) {
4111
- consola15.error(err instanceof Error ? err.message : String(err));
4821
+ consola17.error(err instanceof Error ? err.message : String(err));
4112
4822
  process.exit(1);
4113
4823
  }
4114
4824
  }
4115
4825
  });
4116
4826
 
4117
4827
  // src/commands/remove-feature.ts
4118
- import { defineCommand as defineCommand13 } from "citty";
4119
- import { consola as consola16 } from "consola";
4120
- var removeFeatureCommand = defineCommand13({
4828
+ import { defineCommand as defineCommand15 } from "citty";
4829
+ import { consola as consola18 } from "consola";
4830
+ var removeFeatureCommand = defineCommand15({
4121
4831
  meta: {
4122
4832
  name: "remove-feature",
4123
4833
  group: "edit",
@@ -4150,27 +4860,27 @@ var removeFeatureCommand = defineCommand13({
4150
4860
  });
4151
4861
  process.exit(result.status === "aborted" ? 1 : 0);
4152
4862
  } catch (err) {
4153
- consola16.error(err instanceof Error ? err.message : String(err));
4863
+ consola18.error(err instanceof Error ? err.message : String(err));
4154
4864
  process.exit(1);
4155
4865
  }
4156
4866
  }
4157
4867
  });
4158
4868
 
4159
4869
  // src/commands/remove.ts
4160
- import { defineCommand as defineCommand14 } from "citty";
4161
- import { consola as consola18 } from "consola";
4870
+ import { defineCommand as defineCommand16 } from "citty";
4871
+ import { consola as consola20 } from "consola";
4162
4872
  import { createInterface } from "readline/promises";
4163
4873
 
4164
4874
  // src/remove/index.ts
4165
- import { existsSync as existsSync8, promises as fs11 } from "fs";
4166
- import path10 from "path";
4167
- import { consola as consola17 } from "consola";
4875
+ import { existsSync as existsSync8, promises as fs13 } from "fs";
4876
+ import path12 from "path";
4877
+ import { consola as consola19 } from "consola";
4168
4878
  async function runRemove(opts) {
4169
4879
  const home = opts.monocerosHome ?? monocerosHome();
4170
4880
  const logger = opts.logger ?? {
4171
- info: (msg) => consola17.info(msg),
4172
- success: (msg) => consola17.success(msg),
4173
- warn: (msg) => consola17.warn(msg)
4881
+ info: (msg) => consola19.info(msg),
4882
+ success: (msg) => consola19.success(msg),
4883
+ warn: (msg) => consola19.warn(msg)
4174
4884
  };
4175
4885
  if (!REGEX.solutionName.test(opts.name)) {
4176
4886
  throw new Error(
@@ -4214,23 +4924,23 @@ async function runRemove(opts) {
4214
4924
  let backupPath = null;
4215
4925
  if (!opts.noBackup && (hasYml || hasContainer)) {
4216
4926
  const ts = (opts.now ?? /* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4217
- backupPath = path10.join(home, "container-backups", `${opts.name}-${ts}`);
4218
- await fs11.mkdir(backupPath, { recursive: true });
4927
+ backupPath = path12.join(home, "container-backups", `${opts.name}-${ts}`);
4928
+ await fs13.mkdir(backupPath, { recursive: true });
4219
4929
  if (hasYml) {
4220
- await fs11.copyFile(ymlPath, path10.join(backupPath, `${opts.name}.yml`));
4930
+ await fs13.copyFile(ymlPath, path12.join(backupPath, `${opts.name}.yml`));
4221
4931
  }
4222
4932
  if (hasContainer) {
4223
- await fs11.cp(containerPath, path10.join(backupPath, "container"), {
4933
+ await fs13.cp(containerPath, path12.join(backupPath, "container"), {
4224
4934
  recursive: true
4225
4935
  });
4226
4936
  }
4227
4937
  logger.info(`Backup written to ${prettyPath(backupPath)}.`);
4228
4938
  }
4229
4939
  if (hasYml) {
4230
- await fs11.rm(ymlPath, { force: true });
4940
+ await fs13.rm(ymlPath, { force: true });
4231
4941
  }
4232
4942
  if (hasContainer) {
4233
- await fs11.rm(containerPath, { recursive: true, force: true });
4943
+ await fs13.rm(containerPath, { recursive: true, force: true });
4234
4944
  }
4235
4945
  logger.success(
4236
4946
  `Removed '${opts.name}': docker objects gone, container-configs entry deleted, container directory deleted.`
@@ -4240,6 +4950,24 @@ async function runRemove(opts) {
4240
4950
  "No backup created (--no-backup). The host-side state is gone for good."
4241
4951
  );
4242
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
+ }
4243
4971
  return {
4244
4972
  configPath: hasYml ? ymlPath : null,
4245
4973
  containerPath: hasContainer ? containerPath : null,
@@ -4249,7 +4977,7 @@ async function runRemove(opts) {
4249
4977
  }
4250
4978
 
4251
4979
  // src/commands/remove.ts
4252
- var removeCommand = defineCommand14({
4980
+ var removeCommand = defineCommand16({
4253
4981
  meta: {
4254
4982
  name: "remove",
4255
4983
  group: "lifecycle",
@@ -4286,7 +5014,7 @@ var removeCommand = defineCommand14({
4286
5014
  const skipPrompt = args.yes === true;
4287
5015
  if (!skipPrompt) {
4288
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.`;
4289
- consola18.warn(warning);
5017
+ consola20.warn(warning);
4290
5018
  const rl = createInterface({
4291
5019
  input: process.stdin,
4292
5020
  output: process.stdout
@@ -4294,7 +5022,7 @@ var removeCommand = defineCommand14({
4294
5022
  const answer = await rl.question("Continue? [y/N] ");
4295
5023
  rl.close();
4296
5024
  if (!/^y(es)?$/i.test(answer.trim())) {
4297
- consola18.info("Aborted. Nothing changed.");
5025
+ consola20.info("Aborted. Nothing changed.");
4298
5026
  process.exit(0);
4299
5027
  }
4300
5028
  }
@@ -4303,35 +5031,35 @@ var removeCommand = defineCommand14({
4303
5031
  ...noBackup ? { noBackup: true } : {}
4304
5032
  });
4305
5033
  } catch (err) {
4306
- consola18.error(err instanceof Error ? err.message : String(err));
5034
+ consola20.error(err instanceof Error ? err.message : String(err));
4307
5035
  process.exit(1);
4308
5036
  }
4309
5037
  }
4310
5038
  });
4311
5039
 
4312
5040
  // src/commands/restore.ts
4313
- import { defineCommand as defineCommand15 } from "citty";
4314
- import { consola as consola20 } from "consola";
5041
+ import { defineCommand as defineCommand17 } from "citty";
5042
+ import { consola as consola22 } from "consola";
4315
5043
 
4316
5044
  // src/restore/index.ts
4317
- import { existsSync as existsSync9, promises as fs12 } from "fs";
4318
- import path11 from "path";
4319
- import { consola as consola19 } from "consola";
5045
+ import { existsSync as existsSync9, promises as fs14 } from "fs";
5046
+ import path13 from "path";
5047
+ import { consola as consola21 } from "consola";
4320
5048
  async function runRestore(opts) {
4321
5049
  const home = opts.monocerosHome ?? monocerosHome();
4322
5050
  const logger = opts.logger ?? {
4323
- info: (msg) => consola19.info(msg),
4324
- success: (msg) => consola19.success(msg)
5051
+ info: (msg) => consola21.info(msg),
5052
+ success: (msg) => consola21.success(msg)
4325
5053
  };
4326
- const backup = path11.resolve(opts.backupPath);
5054
+ const backup = path13.resolve(opts.backupPath);
4327
5055
  if (!existsSync9(backup)) {
4328
5056
  throw new Error(`Backup not found: ${backup}.`);
4329
5057
  }
4330
- const stat = await fs12.stat(backup);
5058
+ const stat = await fs14.stat(backup);
4331
5059
  if (!stat.isDirectory()) {
4332
5060
  throw new Error(`Backup path is not a directory: ${backup}.`);
4333
5061
  }
4334
- const entries = await fs12.readdir(backup);
5062
+ const entries = await fs14.readdir(backup);
4335
5063
  const ymlFiles = entries.filter((f) => f.endsWith(".yml"));
4336
5064
  if (ymlFiles.length === 0) {
4337
5065
  throw new Error(
@@ -4345,7 +5073,7 @@ async function runRestore(opts) {
4345
5073
  }
4346
5074
  const ymlFile = ymlFiles[0];
4347
5075
  const name = ymlFile.replace(/\.yml$/, "");
4348
- const containerInBackup = path11.join(backup, "container");
5076
+ const containerInBackup = path13.join(backup, "container");
4349
5077
  const hasContainer = existsSync9(containerInBackup);
4350
5078
  const destYml = containerConfigPath(name, home);
4351
5079
  const destContainer = containerDir(name, home);
@@ -4359,10 +5087,10 @@ async function runRestore(opts) {
4359
5087
  `Refusing to restore: ${destContainer} already exists. Remove the current container first (\`monoceros remove ${name}\`).`
4360
5088
  );
4361
5089
  }
4362
- await fs12.mkdir(containerConfigsDir(home), { recursive: true });
4363
- 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);
4364
5092
  if (hasContainer) {
4365
- await fs12.cp(containerInBackup, destContainer, { recursive: true });
5093
+ await fs14.cp(containerInBackup, destContainer, { recursive: true });
4366
5094
  }
4367
5095
  logger.success(`Restored '${name}' from ${prettyPath(backup)}.`);
4368
5096
  logger.info(
@@ -4376,7 +5104,7 @@ async function runRestore(opts) {
4376
5104
  }
4377
5105
 
4378
5106
  // src/commands/restore.ts
4379
- var restoreCommand = defineCommand15({
5107
+ var restoreCommand = defineCommand17({
4380
5108
  meta: {
4381
5109
  name: "restore",
4382
5110
  group: "lifecycle",
@@ -4393,16 +5121,16 @@ var restoreCommand = defineCommand15({
4393
5121
  try {
4394
5122
  await runRestore({ backupPath: args["backup-path"] });
4395
5123
  } catch (err) {
4396
- consola20.error(err instanceof Error ? err.message : String(err));
5124
+ consola22.error(err instanceof Error ? err.message : String(err));
4397
5125
  process.exit(1);
4398
5126
  }
4399
5127
  }
4400
5128
  });
4401
5129
 
4402
5130
  // src/commands/remove-from-url.ts
4403
- import { defineCommand as defineCommand16 } from "citty";
4404
- import { consola as consola21 } from "consola";
4405
- var removeFromUrlCommand = defineCommand16({
5131
+ import { defineCommand as defineCommand18 } from "citty";
5132
+ import { consola as consola23 } from "consola";
5133
+ var removeFromUrlCommand = defineCommand18({
4406
5134
  meta: {
4407
5135
  name: "remove-from-url",
4408
5136
  group: "edit",
@@ -4435,16 +5163,16 @@ var removeFromUrlCommand = defineCommand16({
4435
5163
  });
4436
5164
  process.exit(result.status === "aborted" ? 1 : 0);
4437
5165
  } catch (err) {
4438
- consola21.error(err instanceof Error ? err.message : String(err));
5166
+ consola23.error(err instanceof Error ? err.message : String(err));
4439
5167
  process.exit(1);
4440
5168
  }
4441
5169
  }
4442
5170
  });
4443
5171
 
4444
5172
  // src/commands/remove-language.ts
4445
- import { defineCommand as defineCommand17 } from "citty";
4446
- import { consola as consola22 } from "consola";
4447
- var removeLanguageCommand = defineCommand17({
5173
+ import { defineCommand as defineCommand19 } from "citty";
5174
+ import { consola as consola24 } from "consola";
5175
+ var removeLanguageCommand = defineCommand19({
4448
5176
  meta: {
4449
5177
  name: "remove-language",
4450
5178
  group: "edit",
@@ -4477,16 +5205,64 @@ var removeLanguageCommand = defineCommand17({
4477
5205
  });
4478
5206
  process.exit(result.status === "aborted" ? 1 : 0);
4479
5207
  } catch (err) {
4480
- 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));
4481
5253
  process.exit(1);
4482
5254
  }
4483
5255
  }
4484
5256
  });
5257
+ function coerceToken2(raw) {
5258
+ const n = Number(raw);
5259
+ return Number.isFinite(n) ? n : raw;
5260
+ }
4485
5261
 
4486
5262
  // src/commands/remove-repo.ts
4487
- import { defineCommand as defineCommand18 } from "citty";
4488
- import { consola as consola23 } from "consola";
4489
- var removeRepoCommand = defineCommand18({
5263
+ import { defineCommand as defineCommand21 } from "citty";
5264
+ import { consola as consola26 } from "consola";
5265
+ var removeRepoCommand = defineCommand21({
4490
5266
  meta: {
4491
5267
  name: "remove-repo",
4492
5268
  group: "edit",
@@ -4519,16 +5295,16 @@ var removeRepoCommand = defineCommand18({
4519
5295
  });
4520
5296
  process.exit(result.status === "aborted" ? 1 : 0);
4521
5297
  } catch (err) {
4522
- consola23.error(err instanceof Error ? err.message : String(err));
5298
+ consola26.error(err instanceof Error ? err.message : String(err));
4523
5299
  process.exit(1);
4524
5300
  }
4525
5301
  }
4526
5302
  });
4527
5303
 
4528
5304
  // src/commands/remove-service.ts
4529
- import { defineCommand as defineCommand19 } from "citty";
4530
- import { consola as consola24 } from "consola";
4531
- var removeServiceCommand = defineCommand19({
5305
+ import { defineCommand as defineCommand22 } from "citty";
5306
+ import { consola as consola27 } from "consola";
5307
+ var removeServiceCommand = defineCommand22({
4532
5308
  meta: {
4533
5309
  name: "remove-service",
4534
5310
  group: "edit",
@@ -4561,19 +5337,19 @@ var removeServiceCommand = defineCommand19({
4561
5337
  });
4562
5338
  process.exit(result.status === "aborted" ? 1 : 0);
4563
5339
  } catch (err) {
4564
- consola24.error(err instanceof Error ? err.message : String(err));
5340
+ consola27.error(err instanceof Error ? err.message : String(err));
4565
5341
  process.exit(1);
4566
5342
  }
4567
5343
  }
4568
5344
  });
4569
5345
 
4570
5346
  // src/commands/run.ts
4571
- import { defineCommand as defineCommand20 } from "citty";
4572
- import { consola as consola25 } from "consola";
5347
+ import { defineCommand as defineCommand23 } from "citty";
5348
+ import { consola as consola28 } from "consola";
4573
5349
 
4574
5350
  // src/devcontainer/shell.ts
4575
5351
  import { existsSync as existsSync10 } from "fs";
4576
- import path12 from "path";
5352
+ import path14 from "path";
4577
5353
  async function runShell(opts) {
4578
5354
  assertContainerExists(opts.root);
4579
5355
  const spawnFn = opts.spawn ?? spawnDevcontainer;
@@ -4596,7 +5372,7 @@ async function runShell(opts) {
4596
5372
  );
4597
5373
  }
4598
5374
  function assertContainerExists(root) {
4599
- if (!existsSync10(path12.join(root, ".devcontainer"))) {
5375
+ if (!existsSync10(path14.join(root, ".devcontainer"))) {
4600
5376
  throw new Error(
4601
5377
  `No .devcontainer/ at ${root}. Run \`monoceros apply <name>\` first.`
4602
5378
  );
@@ -4632,7 +5408,7 @@ async function runInContainer(opts) {
4632
5408
  }
4633
5409
 
4634
5410
  // src/commands/run.ts
4635
- var runCommand = defineCommand20({
5411
+ var runCommand = defineCommand23({
4636
5412
  meta: {
4637
5413
  name: "run",
4638
5414
  group: "run",
@@ -4648,7 +5424,7 @@ var runCommand = defineCommand20({
4648
5424
  async run({ args }) {
4649
5425
  const command = [...getInnerArgs()];
4650
5426
  if (command.length === 0) {
4651
- consola25.error(
5427
+ consola28.error(
4652
5428
  "No command provided. Usage: `monoceros run <containername> -- <cmd> [args\u2026]`."
4653
5429
  );
4654
5430
  process.exit(1);
@@ -4660,16 +5436,16 @@ var runCommand = defineCommand20({
4660
5436
  });
4661
5437
  process.exit(exitCode);
4662
5438
  } catch (err) {
4663
- consola25.error(err instanceof Error ? err.message : String(err));
5439
+ consola28.error(err instanceof Error ? err.message : String(err));
4664
5440
  process.exit(1);
4665
5441
  }
4666
5442
  }
4667
5443
  });
4668
5444
 
4669
5445
  // src/commands/shell.ts
4670
- import { defineCommand as defineCommand21 } from "citty";
4671
- import { consola as consola26 } from "consola";
4672
- var shellCommand = defineCommand21({
5446
+ import { defineCommand as defineCommand24 } from "citty";
5447
+ import { consola as consola29 } from "consola";
5448
+ var shellCommand = defineCommand24({
4673
5449
  meta: {
4674
5450
  name: "shell",
4675
5451
  group: "run",
@@ -4687,15 +5463,16 @@ var shellCommand = defineCommand21({
4687
5463
  const exitCode = await runShell({ root: containerDir(args.name) });
4688
5464
  process.exit(exitCode);
4689
5465
  } catch (err) {
4690
- consola26.error(err instanceof Error ? err.message : String(err));
5466
+ consola29.error(err instanceof Error ? err.message : String(err));
4691
5467
  process.exit(1);
4692
5468
  }
4693
5469
  }
4694
5470
  });
4695
5471
 
4696
5472
  // src/commands/start.ts
4697
- import { defineCommand as defineCommand22 } from "citty";
4698
- var startCommand = defineCommand22({
5473
+ import { defineCommand as defineCommand25 } from "citty";
5474
+ import { consola as consola30 } from "consola";
5475
+ var startCommand = defineCommand25({
4699
5476
  meta: {
4700
5477
  name: "start",
4701
5478
  group: "run",
@@ -4709,13 +5486,33 @@ var startCommand = defineCommand22({
4709
5486
  }
4710
5487
  },
4711
5488
  run({ args }) {
4712
- 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
+ });
4713
5510
  }
4714
5511
  });
4715
5512
 
4716
5513
  // src/commands/status.ts
4717
- import { defineCommand as defineCommand23 } from "citty";
4718
- var statusCommand = defineCommand23({
5514
+ import { defineCommand as defineCommand26 } from "citty";
5515
+ var statusCommand = defineCommand26({
4719
5516
  meta: {
4720
5517
  name: "status",
4721
5518
  group: "run",
@@ -4743,8 +5540,9 @@ var statusCommand = defineCommand23({
4743
5540
  });
4744
5541
 
4745
5542
  // src/commands/stop.ts
4746
- import { defineCommand as defineCommand24 } from "citty";
4747
- var stopCommand = defineCommand24({
5543
+ import { defineCommand as defineCommand27 } from "citty";
5544
+ import { consola as consola31 } from "consola";
5545
+ var stopCommand = defineCommand27({
4748
5546
  meta: {
4749
5547
  name: "stop",
4750
5548
  group: "run",
@@ -4762,17 +5560,27 @@ var stopCommand = defineCommand24({
4762
5560
  }
4763
5561
  },
4764
5562
  run({ args }) {
4765
- return dispatch(
4766
- () => runStop({
5563
+ return dispatch(async () => {
5564
+ const exit = await runStop({
4767
5565
  root: containerDir(args.name),
4768
5566
  ...typeof args.service === "string" ? { service: args.service } : {}
4769
- })
4770
- );
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
+ });
4771
5579
  }
4772
5580
  });
4773
5581
 
4774
5582
  // src/main.ts
4775
- var main = defineCommand25({
5583
+ var main = defineCommand28({
4776
5584
  meta: {
4777
5585
  name: "monoceros",
4778
5586
  version: CLI_VERSION,
@@ -4796,12 +5604,15 @@ var main = defineCommand25({
4796
5604
  "add-feature": addFeatureCommand,
4797
5605
  "add-from-url": addFromUrlCommand,
4798
5606
  "add-repo": addRepoCommand,
5607
+ "add-port": addPortCommand,
4799
5608
  "remove-service": removeServiceCommand,
4800
5609
  "remove-language": removeLanguageCommand,
4801
5610
  "remove-apt-packages": removeAptPackagesCommand,
4802
5611
  "remove-feature": removeFeatureCommand,
4803
5612
  "remove-from-url": removeFromUrlCommand,
4804
5613
  "remove-repo": removeRepoCommand,
5614
+ "remove-port": removePortCommand,
5615
+ port: portCommand,
4805
5616
  completion: completionCommand
4806
5617
  }
4807
5618
  });