@devrouter/cli 0.0.22 → 0.0.23

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/dev.js CHANGED
@@ -161,6 +161,7 @@ This folder is managed by the devrouter CLI.
161
161
  - dev init [--write-agents] [--write-skill] [--with-linear]
162
162
  - dev -V [--repo <path>] (installed/local version + next upgrade)
163
163
  - dev upgrade [version] [--repo <path>]
164
+ - dev setup --yes [--repo <path>] [--json]
164
165
  - dev up
165
166
  - dev down
166
167
  - dev status
@@ -169,10 +170,13 @@ This folder is managed by the devrouter CLI.
169
170
  - dev open <name>
170
171
  - dev logs [-f] [--tail N]
171
172
  - dev repo init
173
+ - dev repo inspect [--repo <path>] [--json]
174
+ - dev repo devcontainer write [--repo <path>] [--dry-run] [--yes] [--json]
175
+ - dev repo devcontainer verify [--repo <path>] [--live] [--yes] [--json]
172
176
  - dev repo agents [--with-linear]
173
177
  - dev app add --name <name> --host <host.localhost> --protocol <http|tcp> --runtime <host|docker>
174
178
  - dev app run <name>
175
- - dev app exec <name> [--shell] [--env-map TARGET=SOURCE] -- <command>
179
+ - dev app exec <name> [--shell] [--env <env>] -- <command>
176
180
  - dev app ls
177
181
  - dev app rm <name>
178
182
  - dev tls install
@@ -340,6 +344,45 @@ var init_router = __esm({
340
344
  }
341
345
  });
342
346
 
347
+ // src/core/capabilities.ts
348
+ function formatSupportedTcpProtocols() {
349
+ return SUPPORTED_TCP_PROTOCOLS.join(", ");
350
+ }
351
+ function formatSupportedProtocolsForRuntime(runtime) {
352
+ return RUNTIME_PROTOCOL_COMPATIBILITY[runtime].join(", ");
353
+ }
354
+ function buildPostgresDependencyUrl(port) {
355
+ return POSTGRES_DEPENDENCY_URL_TEMPLATE.replace("<port>", String(port));
356
+ }
357
+ function buildPostgresDependencyShadowUrl(port) {
358
+ return POSTGRES_DEPENDENCY_SHADOW_URL_TEMPLATE.replace("<port>", String(port));
359
+ }
360
+ var SUPPORTED_RUNTIMES, SUPPORTED_PROTOCOLS, SUPPORTED_TCP_PROTOCOLS, DEPENDENCY_ONLY_RUNTIME, RUNTIME_PROTOCOL_COMPATIBILITY, WORKSPACE_PLACEHOLDER, SECRET_MANAGER_ENV_PLACEHOLDER, DEP_ENV_SUFFIXES, POSTGRES_DEPENDENCY_USER, POSTGRES_DEPENDENCY_PASSWORD, POSTGRES_DEPENDENCY_DATABASE, POSTGRES_DEPENDENCY_SHADOW_DATABASE, POSTGRES_DEPENDENCY_URL_TEMPLATE, POSTGRES_DEPENDENCY_SHADOW_URL_TEMPLATE;
361
+ var init_capabilities = __esm({
362
+ "src/core/capabilities.ts"() {
363
+ "use strict";
364
+ init_router();
365
+ SUPPORTED_RUNTIMES = ["host", "docker", "proxy"];
366
+ SUPPORTED_PROTOCOLS = ["http", "tcp"];
367
+ SUPPORTED_TCP_PROTOCOLS = Object.freeze(Object.keys(TCP_PROTOCOL_REGISTRY));
368
+ DEPENDENCY_ONLY_RUNTIME = "docker";
369
+ RUNTIME_PROTOCOL_COMPATIBILITY = {
370
+ host: ["http"],
371
+ docker: ["http", "tcp"],
372
+ proxy: ["http", "tcp"]
373
+ };
374
+ WORKSPACE_PLACEHOLDER = "${WORKSPACE}";
375
+ SECRET_MANAGER_ENV_PLACEHOLDER = "{env}";
376
+ DEP_ENV_SUFFIXES = ["HOST", "PORT", "URL", "SHADOW_URL"];
377
+ POSTGRES_DEPENDENCY_USER = "prisma";
378
+ POSTGRES_DEPENDENCY_PASSWORD = "prisma";
379
+ POSTGRES_DEPENDENCY_DATABASE = "prisma";
380
+ POSTGRES_DEPENDENCY_SHADOW_DATABASE = "shadow";
381
+ POSTGRES_DEPENDENCY_URL_TEMPLATE = `postgres://${POSTGRES_DEPENDENCY_USER}:${POSTGRES_DEPENDENCY_PASSWORD}@localhost:<port>/${POSTGRES_DEPENDENCY_DATABASE}`;
382
+ POSTGRES_DEPENDENCY_SHADOW_URL_TEMPLATE = `postgres://${POSTGRES_DEPENDENCY_USER}:${POSTGRES_DEPENDENCY_PASSWORD}@localhost:<port>/${POSTGRES_DEPENDENCY_SHADOW_DATABASE}`;
383
+ }
384
+ });
385
+
343
386
  // src/core/host-routes.ts
344
387
  function parseUpstream(upstream) {
345
388
  const match = UPSTREAM_RE.exec(upstream.trim());
@@ -351,8 +394,8 @@ function parseUpstream(upstream) {
351
394
  if (port < 1 || port > 65535) {
352
395
  throw new Error(`upstream port must be between 1 and 65535 (got ${port}).`);
353
396
  }
354
- const upstreamHost = LOOPBACK_HOSTS.has(host) ? "host.docker.internal" : host;
355
- return { host, port, upstreamHost };
397
+ const upstreamHost2 = LOOPBACK_HOSTS.has(host) ? "host.docker.internal" : host;
398
+ return { host, port, upstreamHost: upstreamHost2 };
356
399
  }
357
400
  function isPidRunning(pid) {
358
401
  if (!pid || pid <= 0) {
@@ -553,31 +596,16 @@ function removeHostRouteById(id) {
553
596
  return true;
554
597
  });
555
598
  }
556
- function removeHostRouteByName(name, repoPath) {
599
+ function removeHostRoutesWhere(predicate) {
557
600
  return withStateLock(() => {
558
601
  const routes = listHostRouteState();
559
- const matches = routes.filter((route) => {
560
- if (route.name !== name) {
561
- return false;
562
- }
563
- if (repoPath && route.repoPath !== repoPath) {
564
- return false;
565
- }
566
- return true;
567
- });
568
- if (matches.length === 0) {
569
- throw new Error(`No host route named '${name}' found.`);
570
- }
571
- if (matches.length > 1 && !repoPath) {
572
- const refs = matches.map((route) => `${route.name} (${route.repoPath})`).join(", ");
573
- throw new Error(
574
- `Multiple host routes named '${name}' exist. Re-run with --repo to disambiguate: ${refs}`
575
- );
602
+ const removed = routes.filter(predicate);
603
+ if (removed.length === 0) {
604
+ return [];
576
605
  }
577
- const match = matches[0];
578
- const next = routes.filter((route) => route.id !== match.id);
579
- writeState(next);
580
- return match;
606
+ const removedIds = new Set(removed.map((route) => route.id));
607
+ writeState(routes.filter((route) => !removedIds.has(route.id)));
608
+ return removed;
581
609
  });
582
610
  }
583
611
  function listHostRoutes(tlsEnabled) {
@@ -759,6 +787,19 @@ function parseEnvMap(value, pathLabel) {
759
787
  }
760
788
  return result;
761
789
  }
790
+ function isSupportedProtocol(value) {
791
+ return SUPPORTED_PROTOCOLS.includes(value);
792
+ }
793
+ function isSupportedRuntime(value) {
794
+ return SUPPORTED_RUNTIMES.includes(value);
795
+ }
796
+ function isSupportedTcpProtocol(value) {
797
+ return SUPPORTED_TCP_PROTOCOLS.includes(value);
798
+ }
799
+ function runtimeSupportsProtocol(runtime, protocol) {
800
+ const supportedProtocols = RUNTIME_PROTOCOL_COMPATIBILITY[runtime];
801
+ return supportedProtocols.includes(protocol);
802
+ }
762
803
  function parseDependencies(value, pathLabel) {
763
804
  if (value === void 0) {
764
805
  return [];
@@ -807,21 +848,21 @@ function parseHostStrategy(value, pathLabel) {
807
848
  function parseDockerConfig(value, pathLabel) {
808
849
  const objectValue = ensureObject(value, pathLabel);
809
850
  ensureAllowedKeys(objectValue, ["service", "internalPort", "composeFiles", "router"], pathLabel);
810
- const composeFiles = toStringArray(objectValue.composeFiles, `${pathLabel}.composeFiles`);
851
+ const composeFiles2 = toStringArray(objectValue.composeFiles, `${pathLabel}.composeFiles`);
811
852
  return {
812
853
  service: toStringOrThrow(objectValue.service, `${pathLabel}.service`),
813
854
  internalPort: toIntegerOrThrow(objectValue.internalPort, `${pathLabel}.internalPort`),
814
- composeFiles: composeFiles.length > 0 ? composeFiles : ["docker-compose.yml"],
855
+ composeFiles: composeFiles2.length > 0 ? composeFiles2 : ["docker-compose.yml"],
815
856
  router: objectValue.router === void 0 ? void 0 : toStringOrThrow(objectValue.router, `${pathLabel}.router`)
816
857
  };
817
858
  }
818
859
  function parseDependencyDockerConfig(value, pathLabel) {
819
860
  const objectValue = ensureObject(value, pathLabel);
820
861
  ensureAllowedKeys(objectValue, ["service", "composeFiles"], pathLabel);
821
- const composeFiles = toStringArray(objectValue.composeFiles, `${pathLabel}.composeFiles`);
862
+ const composeFiles2 = toStringArray(objectValue.composeFiles, `${pathLabel}.composeFiles`);
822
863
  return {
823
864
  service: toStringOrThrow(objectValue.service, `${pathLabel}.service`),
824
- composeFiles: composeFiles.length > 0 ? composeFiles : ["docker-compose.yml"]
865
+ composeFiles: composeFiles2.length > 0 ? composeFiles2 : ["docker-compose.yml"]
825
866
  };
826
867
  }
827
868
  function parseHostOrThrow(value, pathLabel) {
@@ -863,13 +904,13 @@ function parseApp(value, index) {
863
904
  throw new Error(`${pathLabel}.hostRun is not supported when kind=dependency.`);
864
905
  }
865
906
  const runtime2 = toStringOrThrow(objectValue.runtime, `${pathLabel}.runtime`);
866
- if (runtime2 !== "docker") {
867
- throw new Error(`${pathLabel}.runtime must be 'docker' when kind=dependency.`);
907
+ if (runtime2 !== DEPENDENCY_ONLY_RUNTIME) {
908
+ throw new Error(`${pathLabel}.runtime must be '${DEPENDENCY_ONLY_RUNTIME}' when kind=dependency.`);
868
909
  }
869
910
  return {
870
911
  kind: "dependency",
871
912
  name,
872
- runtime: "docker",
913
+ runtime: DEPENDENCY_ONLY_RUNTIME,
873
914
  dependencies,
874
915
  docker: parseDependencyDockerConfig(objectValue.docker, `${pathLabel}.docker`)
875
916
  };
@@ -877,8 +918,14 @@ function parseApp(value, index) {
877
918
  const host = parseHostOrThrow(objectValue.host, `${pathLabel}.host`);
878
919
  const protocol = toStringOrThrow(objectValue.protocol, `${pathLabel}.protocol`);
879
920
  const runtime = toStringOrThrow(objectValue.runtime, `${pathLabel}.runtime`);
921
+ if (!isSupportedProtocol(protocol)) {
922
+ throw new Error(`${pathLabel}.protocol must be one of: ${SUPPORTED_PROTOCOLS.join(", ")}.`);
923
+ }
924
+ if (!isSupportedRuntime(runtime)) {
925
+ throw new Error(`${pathLabel}.runtime must be one of: ${SUPPORTED_RUNTIMES.join(", ")}.`);
926
+ }
880
927
  if (runtime === "host") {
881
- if (protocol !== "http") {
928
+ if (!runtimeSupportsProtocol("host", protocol)) {
882
929
  throw new Error(`${pathLabel}: host runtime currently supports only protocol=http.`);
883
930
  }
884
931
  const hostRun = ensureObject(objectValue.hostRun, `${pathLabel}.hostRun`);
@@ -916,10 +963,9 @@ function parseApp(value, index) {
916
963
  }
917
964
  if (protocol === "tcp") {
918
965
  const tcpProtocol = toStringOrThrow(objectValue.tcpProtocol, `${pathLabel}.tcpProtocol`);
919
- const supportedProtocols = Object.keys(TCP_PROTOCOL_REGISTRY);
920
- if (!supportedProtocols.includes(tcpProtocol)) {
966
+ if (!isSupportedTcpProtocol(tcpProtocol)) {
921
967
  throw new Error(
922
- `${pathLabel}.tcpProtocol must be one of: ${supportedProtocols.join(", ")}.`
968
+ `${pathLabel}.tcpProtocol must be one of: ${formatSupportedTcpProtocols()}.`
923
969
  );
924
970
  }
925
971
  return {
@@ -934,7 +980,7 @@ function parseApp(value, index) {
934
980
  }
935
981
  }
936
982
  if (runtime === "proxy") {
937
- if (protocol !== "http" && protocol !== "tcp") {
983
+ if (!runtimeSupportsProtocol("proxy", protocol)) {
938
984
  throw new Error(`${pathLabel}: proxy runtime supports protocol=http or protocol=tcp.`);
939
985
  }
940
986
  if (objectValue.hostRun !== void 0) {
@@ -950,9 +996,8 @@ function parseApp(value, index) {
950
996
  assertUpstreamSpec(upstream, `${pathLabel}.upstream`);
951
997
  if (protocol === "tcp") {
952
998
  const tcpProtocol = toStringOrThrow(objectValue.tcpProtocol, `${pathLabel}.tcpProtocol`);
953
- const supportedProtocols = Object.keys(TCP_PROTOCOL_REGISTRY);
954
- if (!supportedProtocols.includes(tcpProtocol)) {
955
- throw new Error(`${pathLabel}.tcpProtocol must be one of: ${supportedProtocols.join(", ")}.`);
999
+ if (!isSupportedTcpProtocol(tcpProtocol)) {
1000
+ throw new Error(`${pathLabel}.tcpProtocol must be one of: ${formatSupportedTcpProtocols()}.`);
956
1001
  }
957
1002
  return {
958
1003
  name,
@@ -987,11 +1032,11 @@ function parseConfig(raw, configPath) {
987
1032
  const metadata = ensureObject(root.devrouter, `${configPath}.devrouter`);
988
1033
  ensureAllowedKeys(metadata, ["version"], `${configPath}.devrouter`);
989
1034
  if (metadata.version !== void 0) {
990
- const devrouterVersion = toStringOrThrow(metadata.version, `${configPath}.devrouter.version`);
991
- if (!DEVROUTER_VERSION_RE.test(devrouterVersion)) {
1035
+ const devrouterVersion2 = toStringOrThrow(metadata.version, `${configPath}.devrouter.version`);
1036
+ if (!DEVROUTER_VERSION_RE.test(devrouterVersion2)) {
992
1037
  throw new Error(`${configPath}.devrouter.version must be a semantic version like 0.0.14.`);
993
1038
  }
994
- devrouter = { version: devrouterVersion };
1039
+ devrouter = { version: devrouterVersion2 };
995
1040
  } else {
996
1041
  devrouter = {};
997
1042
  }
@@ -1021,8 +1066,10 @@ function parseConfig(raw, configPath) {
1021
1066
  throw new Error(`${configPath}.secretManager.defaultEnv must be alphanumeric with hyphens.`);
1022
1067
  }
1023
1068
  }
1024
- if (command.includes("{env}") && !defaultEnv) {
1025
- throw new Error(`${configPath}.secretManager.defaultEnv is required when command contains {env}.`);
1069
+ if (command.includes(SECRET_MANAGER_ENV_PLACEHOLDER) && !defaultEnv) {
1070
+ throw new Error(
1071
+ `${configPath}.secretManager.defaultEnv is required when command contains ${SECRET_MANAGER_ENV_PLACEHOLDER}.`
1072
+ );
1026
1073
  }
1027
1074
  secretManager = { command, ...defaultEnv ? { defaultEnv } : {} };
1028
1075
  }
@@ -1122,8 +1169,8 @@ function buildAppFromOptions(options) {
1122
1169
  }
1123
1170
  const dependencies = options.dependsOn.map((app) => ({ app }));
1124
1171
  if (kind === "dependency") {
1125
- if (options.runtime !== void 0 && options.runtime !== "docker") {
1126
- throw new Error("--runtime must be docker when --kind dependency");
1172
+ if (options.runtime !== void 0 && options.runtime !== DEPENDENCY_ONLY_RUNTIME) {
1173
+ throw new Error(`--runtime must be ${DEPENDENCY_ONLY_RUNTIME} when --kind dependency`);
1127
1174
  }
1128
1175
  if (options.host !== void 0) {
1129
1176
  throw new Error("--host is not supported when --kind dependency");
@@ -1152,7 +1199,7 @@ function buildAppFromOptions(options) {
1152
1199
  return {
1153
1200
  kind: "dependency",
1154
1201
  name: options.name,
1155
- runtime: "docker",
1202
+ runtime: DEPENDENCY_ONLY_RUNTIME,
1156
1203
  dependencies,
1157
1204
  docker: {
1158
1205
  service: options.service,
@@ -1219,10 +1266,9 @@ function buildAppFromOptions(options) {
1219
1266
  assertUpstreamSpec(options.upstream, "--upstream");
1220
1267
  if (options.protocol === "tcp") {
1221
1268
  const tcpProtocol2 = options.tcpProtocol;
1222
- const supportedProtocols2 = Object.keys(TCP_PROTOCOL_REGISTRY);
1223
- if (!tcpProtocol2 || !supportedProtocols2.includes(tcpProtocol2)) {
1269
+ if (!tcpProtocol2 || !isSupportedTcpProtocol(tcpProtocol2)) {
1224
1270
  throw new Error(
1225
- `--tcp-protocol must be one of: ${supportedProtocols2.join(", ")} when --runtime proxy --protocol tcp`
1271
+ `--tcp-protocol must be one of: ${formatSupportedTcpProtocols()} when --runtime proxy --protocol tcp`
1226
1272
  );
1227
1273
  }
1228
1274
  return {
@@ -1266,10 +1312,9 @@ function buildAppFromOptions(options) {
1266
1312
  docker
1267
1313
  };
1268
1314
  }
1269
- const tcpProtocol = options.tcpProtocol ?? "postgres";
1270
- const supportedProtocols = Object.keys(TCP_PROTOCOL_REGISTRY);
1271
- if (!supportedProtocols.includes(tcpProtocol)) {
1272
- throw new Error(`--tcp-protocol must be one of: ${supportedProtocols.join(", ")}`);
1315
+ const tcpProtocol = options.tcpProtocol ?? DEFAULT_TCP_PROTOCOL;
1316
+ if (!isSupportedTcpProtocol(tcpProtocol)) {
1317
+ throw new Error(`--tcp-protocol must be one of: ${formatSupportedTcpProtocols()}`);
1273
1318
  }
1274
1319
  return {
1275
1320
  name: options.name,
@@ -1410,7 +1455,7 @@ function resolveAppDependencies(config, app) {
1410
1455
  }
1411
1456
  return results;
1412
1457
  }
1413
- var import_node_fs4, import_node_path4, import_yaml2, CONFIG_FILE_NAME, VALID_HOSTNAME_RE, DEVROUTER_VERSION_RE, VALID_ENV_NAME_RE, VALID_ENV_VAR_RE, WORKSPACE_PLACEHOLDER, UPSTREAM_TEMPLATE_RE, MAX_COMMAND_LENGTH, DEFAULT_HOST_STRATEGY;
1458
+ var import_node_fs4, import_node_path4, import_yaml2, CONFIG_FILE_NAME, DEFAULT_TCP_PROTOCOL, VALID_HOSTNAME_RE, DEVROUTER_VERSION_RE, VALID_ENV_NAME_RE, VALID_ENV_VAR_RE, UPSTREAM_TEMPLATE_RE, MAX_COMMAND_LENGTH, DEFAULT_HOST_STRATEGY;
1414
1459
  var init_repo_config = __esm({
1415
1460
  "src/core/repo-config.ts"() {
1416
1461
  "use strict";
@@ -1418,14 +1463,14 @@ var init_repo_config = __esm({
1418
1463
  import_node_path4 = __toESM(require("path"));
1419
1464
  import_yaml2 = __toESM(require("yaml"));
1420
1465
  init_host_routes();
1421
- init_router();
1422
1466
  init_workspace();
1467
+ init_capabilities();
1423
1468
  CONFIG_FILE_NAME = ".devrouter.yml";
1469
+ DEFAULT_TCP_PROTOCOL = "postgres";
1424
1470
  VALID_HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.localhost$/;
1425
1471
  DEVROUTER_VERSION_RE = /^\d+\.\d+\.\d+$/;
1426
1472
  VALID_ENV_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/i;
1427
1473
  VALID_ENV_VAR_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
1428
- WORKSPACE_PLACEHOLDER = "${WORKSPACE}";
1429
1474
  UPSTREAM_TEMPLATE_RE = /^(?:\$\{WORKSPACE\}|[a-zA-Z0-9._-])+:\d{1,5}$/;
1430
1475
  MAX_COMMAND_LENGTH = 4096;
1431
1476
  DEFAULT_HOST_STRATEGY = {
@@ -1458,6 +1503,15 @@ function renderCommandIntentSection() {
1458
1503
  );
1459
1504
  return ["Command intent reference:", ...lines].join("\n");
1460
1505
  }
1506
+ function quotedUnion(values) {
1507
+ return values.map((value) => `"${value}"`).join(" | ");
1508
+ }
1509
+ function formatDepEnvNames() {
1510
+ return DEP_ENV_SUFFIXES.map((suffix) => `\`{PREFIX}_${suffix}\``).join(", ");
1511
+ }
1512
+ function formatProtocolRule(runtime) {
1513
+ return formatSupportedProtocolsForRuntime(runtime).replace(", ", " or ");
1514
+ }
1461
1515
  function buildOnboardingPrompt(options = {}) {
1462
1516
  const repoPath = resolveRepoPath(options.repo);
1463
1517
  const entriesJson = normalizeEntriesJson(options.entriesJson);
@@ -1484,8 +1538,8 @@ function buildOnboardingPrompt(options = {}) {
1484
1538
  "- version: 1 (required)",
1485
1539
  "- devrouter.version: semantic version string (recommended; required for `dev -V`/`dev upgrade`)",
1486
1540
  "- project.name: string (optional)",
1487
- "- secretManager.command: string (optional; SM command including trailing `--` boundary; supports `{env}` template placeholder)",
1488
- "- secretManager.defaultEnv: string (optional; fallback env for `{env}` template; required when command contains `{env}`)",
1541
+ `- secretManager.command: string (optional; SM command including trailing \`--\` boundary; supports \`${SECRET_MANAGER_ENV_PLACEHOLDER}\` template placeholder)`,
1542
+ `- secretManager.defaultEnv: string (optional; fallback env for \`${SECRET_MANAGER_ENV_PLACEHOLDER}\` template; required when command contains \`${SECRET_MANAGER_ENV_PLACEHOLDER}\`)`,
1489
1543
  "- apps: array (required)",
1490
1544
  "",
1491
1545
  "Canonical valid skeleton:",
@@ -1502,8 +1556,8 @@ function buildOnboardingPrompt(options = {}) {
1502
1556
  '- dependencies: [{ app: "<name>", envMap: { TARGET: "SOURCE" } }] (optional; envMap aliases per-dep vars to app-expected names)',
1503
1557
  "- if kind=app:",
1504
1558
  " - host: <name>.localhost (single-label or multi-segment, for example `api.v2.app.localhost`)",
1505
- ' - protocol: "http" | "tcp"',
1506
- ' - runtime: "host" | "docker" | "proxy"',
1559
+ ` - protocol: ${quotedUnion(SUPPORTED_PROTOCOLS)}`,
1560
+ ` - runtime: ${quotedUnion(SUPPORTED_RUNTIMES)}`,
1507
1561
  "- if kind=app and runtime=host:",
1508
1562
  " - hostRun.command: string",
1509
1563
  " - hostRun.cwd: string",
@@ -1517,32 +1571,32 @@ function buildOnboardingPrompt(options = {}) {
1517
1571
  " - optional docker.router: string",
1518
1572
  "- if kind=app and runtime=proxy:",
1519
1573
  ' - upstream: "host:port" (an already-running port, e.g. a devcontainer published on 127.0.0.1:3000, or a container reachable by name on a shared Docker network such as `derivatives-db:5432`)',
1520
- " - upstream may use the `${WORKSPACE}` placeholder (e.g. `${WORKSPACE}-app:3000`) to target a per-workspace devcontainer alias; it is substituted with the resolved workspace token at runtime and re-validated. Do NOT put `${WORKSPACE}` in `host` (rejected) \u2014 the host is auto-namespaced.",
1574
+ ` - upstream may use the \`${WORKSPACE_PLACEHOLDER}\` placeholder (e.g. \`${WORKSPACE_PLACEHOLDER}-app:3000\`) to target a per-workspace devcontainer alias; it is substituted with the resolved workspace token at runtime and re-validated. Do NOT put \`${WORKSPACE_PLACEHOLDER}\` in \`host\` (rejected) \u2014 the host is auto-namespaced.`,
1521
1575
  " - protocol=http registers an HTTP route; protocol=tcp registers a TLS-SNI TCP route and additionally requires tcpProtocol",
1522
1576
  " - do not set hostRun/docker/dependencies (proxy only registers a route to the upstream)",
1523
1577
  " - loopback hosts (localhost/127.0.0.1/0.0.0.0) are rewritten to host.docker.internal for Traefik",
1524
1578
  "- if kind=app and protocol=tcp:",
1525
- ' - tcpProtocol: "postgres" | "redis" | "mariadb" | "mysql"',
1579
+ ` - tcpProtocol: ${quotedUnion(SUPPORTED_TCP_PROTOCOLS)}`,
1526
1580
  "- if kind=dependency:",
1527
- ' - runtime: "docker"',
1581
+ ` - runtime: "${DEPENDENCY_ONLY_RUNTIME}"`,
1528
1582
  " - docker.service: string",
1529
1583
  " - docker.composeFiles: string[]",
1530
1584
  " - do not set host/protocol/tcpProtocol/hostRun/docker.internalPort/docker.router",
1531
1585
  "",
1532
1586
  "Validation rules to enforce:",
1533
1587
  "- kind=app host must end with .localhost",
1534
- "- kind=app runtime=host supports protocol=http only",
1535
- "- kind=app runtime=proxy supports protocol=http or tcp, requires upstream (host:port), and has no dependencies; protocol=tcp also requires tcpProtocol",
1536
- "- kind=app protocol=tcp requires runtime=docker or proxy, and tcpProtocol (postgres, redis, mariadb, mysql)",
1537
- "- kind=dependency requires runtime=docker and non-routed docker config only",
1588
+ `- kind=app runtime=host supports protocol=${formatProtocolRule("host")} only`,
1589
+ `- kind=app runtime=proxy supports protocol=${formatProtocolRule("proxy")}, requires upstream (host:port), and has no dependencies; protocol=tcp also requires tcpProtocol`,
1590
+ `- kind=app protocol=tcp requires runtime=docker or proxy, and tcpProtocol (${formatSupportedTcpProtocols()})`,
1591
+ `- kind=dependency requires runtime=${DEPENDENCY_ONLY_RUNTIME} and non-routed docker config only`,
1538
1592
  "- unknown keys are not allowed (strict schema)",
1539
1593
  "",
1540
1594
  "Docker compose file requirements:",
1541
1595
  "- Every service that acts as a dependency MUST define a healthcheck \u2014 devrouter uses `docker compose up --wait` which blocks until healthy; without a healthcheck the wait returns immediately and the dependent app may start before the service is ready.",
1542
1596
  "- Services MUST NOT publish host ports (`ports:` mapping) for any port owned by devrouter (80, 443, 5432) \u2014 Traefik owns these; conflicts cause bind failures.",
1543
1597
  "- Services SHOULD NOT publish host ports at all \u2014 devrouter handles external routing via Traefik labels; publishing ports creates conflicts when running multiple repos.",
1544
- '- For TCP dependencies of host apps, devrouter automatically publishes a random host port and injects per-dep deterministic env vars: `{PREFIX}_HOST`, `{PREFIX}_PORT`, `{PREFIX}_URL` (protocol-specific), and `{PREFIX}_SHADOW_URL` (postgres only). `{PREFIX} = dep.name.toUpperCase().replace(/-/g, "_")`.',
1545
- "- For postgres deps, `{PREFIX}_URL=postgres://prisma:prisma@localhost:<port>/prisma` and `{PREFIX}_SHADOW_URL=postgres://prisma:prisma@localhost:<port>/shadow`. Use config-level `envMap` on the dependency reference to alias these to app-expected names (e.g. `DATABASE_URL: DB_URL`).",
1598
+ `- For TCP dependencies of host apps, devrouter automatically publishes a random host port and injects per-dep deterministic env vars: ${formatDepEnvNames()}. \`{PREFIX}_URL\` is protocol-specific, \`{PREFIX}_SHADOW_URL\` is postgres only. \`{PREFIX} = dep.name.toUpperCase().replace(/-/g, "_")\`.`,
1599
+ `- For postgres deps, \`{PREFIX}_URL=${POSTGRES_DEPENDENCY_URL_TEMPLATE}\` and \`{PREFIX}_SHADOW_URL=${POSTGRES_DEPENDENCY_SHADOW_URL_TEMPLATE}\`. Use config-level \`envMap\` on the dependency reference to alias these to app-expected names (e.g. \`DATABASE_URL: DB_URL\`).`,
1546
1600
  "- The Postgres service in docker-compose must use matching credentials (`POSTGRES_USER=prisma`, `POSTGRES_PASSWORD=prisma`, `POSTGRES_DB=prisma`) and create the shadow database (e.g. via an init script or the app's migration tool). If existing credentials differ, either update them to match or override via `envMap` aliasing.",
1547
1601
  "- If you changed postgres credentials/database defaults on an existing persistent volume, startup may still fail due to stale data. Recommend reconciling credentials or recreating volumes (for example `docker compose down -v`) when safe.",
1548
1602
  "- The TLS/SNI route on :5432 remains available for tools supporting `sslnegotiation=direct` (psql 17+, pgAdmin).",
@@ -1556,7 +1610,7 @@ function buildOnboardingPrompt(options = {}) {
1556
1610
  "- Host-runtime dependencies are NOT auto-started in v1 (must be started manually).",
1557
1611
  "- kind=dependency entries are dependency-only: they do not create routes and cannot be direct targets for `dev app run`, `dev app exec`, or `dev open`.",
1558
1612
  "- kind=dependency services are started/stopped as declared in compose (no Traefik labels added, no env/port injection).",
1559
- '- For TCP dependencies of host apps, devrouter publishes a random host port and injects per-dep deterministic vars: `{PREFIX}_HOST=localhost`, `{PREFIX}_PORT=<port>`, `{PREFIX}_URL` (protocol-specific), `{PREFIX}_SHADOW_URL` (postgres only). `{PREFIX} = dep.name.toUpperCase().replace(/-/g, "_")`.',
1613
+ `- For TCP dependencies of host apps, devrouter publishes a random host port and injects per-dep deterministic vars: \`{PREFIX}_HOST=localhost\`, \`{PREFIX}_PORT=<port>\`, \`{PREFIX}_URL\` (protocol-specific), \`{PREFIX}_SHADOW_URL\` (postgres only). \`{PREFIX} = dep.name.toUpperCase().replace(/-/g, "_")\`.`,
1560
1614
  "- Config-level `envMap` on dependency references aliases per-dep vars to app-expected names. Example: `envMap: { DATABASE_URL: DB_URL }` maps per-dep `DB_URL` to `DATABASE_URL` in the app process.",
1561
1615
  "- If the repo's Postgres docker-compose service uses different credentials than the injected defaults (`prisma:prisma`), flag this to the user and recommend aligning the compose env vars or using `envMap` aliasing.",
1562
1616
  "- Postgres multiplexing on shared :5432 requires TLS/SNI (useful for psql 17+, pgAdmin, not standard app clients).",
@@ -1566,17 +1620,17 @@ function buildOnboardingPrompt(options = {}) {
1566
1620
  "- `envMap` on dependency references (config-level) aliases per-dep vars after dependency env resolution. `envMap` fails fast when source var is missing.",
1567
1621
  "",
1568
1622
  "Workspace isolation (parallel git worktrees / agents):",
1569
- '- A "workspace token" lets several worktrees of one repo run in parallel without host/route collisions. It is a single identity spanning three layers: the devpod workspace id (`devpod up --id <ws>`), the routes devrouter registers, and the `${WORKSPACE}` placeholder in `.devrouter.yml` upstreams + the devcontainer compose network alias.',
1623
+ `- A "workspace token" lets several worktrees of one repo run in parallel without host/route collisions. It is a single identity spanning three layers: the devpod workspace id (\`devpod up --id <ws>\`), the routes devrouter registers, and the \`${WORKSPACE_PLACEHOLDER}\` placeholder in \`.devrouter.yml\` upstreams + the devcontainer compose network alias.`,
1570
1624
  "- Token resolution precedence: `--workspace <slug>` flag > `DEVROUTER_WORKSPACE` env var > auto-derived from a linked git worktree branch (sanitized: lowercase, non-alphanumeric \u2192 `-`, capped at 32 chars) > none. The primary checkout resolves to no token and routes exactly as before (fully back-compatible).",
1571
- "- When a workspace is active: hosts auto-namespace (`web.localhost` \u2192 `web.<ws>.localhost`), `${WORKSPACE}` in `upstream` is substituted with the token, and the docker `router` key is suffixed per workspace. The runtime config is computed in memory only \u2014 the committed `.devrouter.yml` is never rewritten.",
1625
+ `- When a workspace is active: hosts auto-namespace (\`web.localhost\` \u2192 \`web.<ws>.localhost\`), \`${WORKSPACE_PLACEHOLDER}\` in \`upstream\` is substituted with the token, and the docker \`router\` key is suffixed per workspace. The runtime config is computed in memory only \u2014 the committed \`.devrouter.yml\` is never rewritten.`,
1572
1626
  "- TLS: namespaced hosts (`web.<ws>.localhost`) are not covered by the `*.localhost` wildcard; devrouter auto-extends the mkcert cert SANs for active hosts when TLS is enabled.",
1573
- "- Lifecycle: `dev workspace up <branch>` (create worktree + devpod + routes), `dev workspace ls` (list worktrees/tokens/route counts), `dev workspace down <workspace|branch>` (free routes + stop devpod + remove worktree). `dev doctor` reclaims orphaned workspace proxy routes whose worktree dir was removed without `dev workspace down`.",
1574
- "- devcontainer integration: the devcontainer compose service exposes a devnet alias `${WORKSPACE}-app` (default `WORKSPACE=<project>` in `devcontainer.env`), and the proxy app uses `upstream: ${WORKSPACE}-app:<port>`. Spinning up workspace `feat-a` \u2192 alias `feat-a-app`, host `app.feat-a.localhost`.",
1627
+ "- Lifecycle: `dev workspace up <branch>` (create worktree + devpod + routes), `dev workspace ls` (list worktrees/tokens/route counts), `dev workspace down <workspace|branch>` (free routes + stop devpod + remove worktree). `dev doctor` reports orphaned workspace proxy routes whose worktree dir was removed without `dev workspace down`.",
1628
+ `- devcontainer integration: the devcontainer compose service exposes a devnet alias \`${WORKSPACE_PLACEHOLDER}-app\` (default \`WORKSPACE=<project>\` in \`devcontainer.env\`), and the proxy app uses \`upstream: ${WORKSPACE_PLACEHOLDER}-app:<port>\`. Spinning up workspace \`feat-a\` \u2192 alias \`feat-a-app\`, host \`app.feat-a.localhost\`.`,
1575
1629
  "",
1576
1630
  "Secret Manager Integration (config-based):",
1577
1631
  "- Optional top-level `secretManager.command` in `.devrouter.yml` wraps `dev app run` and `dev app exec` commands with the SM command and re-applies devrouter-injected dep env vars after the SM boundary via `env KEY=VAL` prefix.",
1578
- '- Example config: `secretManager: { command: "infisical run --env {env} --", defaultEnv: "dev" }`.',
1579
- "- `{env}` template placeholder in `secretManager.command` is resolved at runtime. `defaultEnv` provides the fallback; `--env` CLI flag overrides it.",
1632
+ `- Example config: \`secretManager: { command: "infisical run --env ${SECRET_MANAGER_ENV_PLACEHOLDER} --", defaultEnv: "dev" }\`.`,
1633
+ `- \`${SECRET_MANAGER_ENV_PLACEHOLDER}\` template placeholder in \`secretManager.command\` is resolved at runtime. \`defaultEnv\` provides the fallback; \`--env\` CLI flag overrides it.`,
1580
1634
  "- When configured, the effective command becomes: `<secretManager.command> env {PREFIX}_URL=<val> ... <user-command>`.",
1581
1635
  "- This ensures devrouter-injected vars take precedence over SM-defined values without manual forwarding.",
1582
1636
  "- Config-level `envMap` targets are also included in the re-injection set.",
@@ -1596,21 +1650,30 @@ function buildOnboardingPrompt(options = {}) {
1596
1650
  "- Warning: Do not run migration/seed until env probe confirms expected DB variables and values.",
1597
1651
  "",
1598
1652
  "Required workflow:",
1599
- "1) Inspect repository structure first (compose files, scripts, app folders, existing dev docs).",
1600
- "2) Create/update REPO_PATH/.devrouter.yml. If required for compliance, make only minimal related edits (for example docker-compose.yml, db init scripts, or existing dev script wiring).",
1601
- "3) For host apps, prefer existing repo dev scripts (pnpm dev/npm run dev/etc.) over handcrafted command chains.",
1602
- "4) If any tcp/postgres app is configured, run `dev up` and `dev tls install` before runtime validation.",
1603
- "5) Keep edits minimal, explicit, and idempotent. Do not modify unrelated services.",
1604
- "6) If required info is missing or ambiguous, stop and ask targeted questions.",
1653
+ "1) Run `dev setup --yes --json` for devrouter-owned machine state; use `dev doctor --repo <REPO_PATH> --json` to diagnose missing prerequisites without mutation.",
1654
+ "2) Run `dev repo inspect --repo <REPO_PATH> --json` before editing files.",
1655
+ "3) For the supported Node/pnpm/Postgres devcontainer shape, run `dev repo devcontainer write --repo <REPO_PATH> --dry-run --json`, review the plan, then run `dev repo devcontainer write --repo <REPO_PATH> --yes`.",
1656
+ "4) For unsupported shapes or custom existing files, make minimal manual edits and explain the assumptions.",
1657
+ "5) Verify static evidence with `dev repo devcontainer verify --repo <REPO_PATH> --json`; after the devcontainer is running, use `dev repo devcontainer verify --repo <REPO_PATH> --live --yes --json` for route probes.",
1658
+ "6) Keep edits minimal, explicit, and idempotent. Do not modify unrelated services.",
1659
+ "7) If required info is missing or ambiguous, stop and ask targeted questions.",
1660
+ "",
1661
+ "Validation commands to run/report for the devcontainer path:",
1662
+ "- dev setup --yes --json",
1663
+ "- dev doctor --repo <REPO_PATH> --json",
1664
+ "- dev repo inspect --repo <REPO_PATH> --json",
1665
+ "- dev repo devcontainer write --repo <REPO_PATH> --dry-run --json",
1666
+ "- dev repo devcontainer write --repo <REPO_PATH> --yes",
1667
+ "- dev repo devcontainer verify --repo <REPO_PATH> --json",
1668
+ "- After `devpod up <REPO_PATH>`: dev repo devcontainer verify --repo <REPO_PATH> --live --yes --json",
1605
1669
  "",
1606
- "Validation commands to run/report:",
1607
- "- dev up",
1608
- "- If tcp/postgres exists: dev tls install",
1670
+ "Validation commands to run/report for host/docker runtime apps:",
1671
+ "- dev setup --yes --json",
1672
+ "- dev doctor --repo <REPO_PATH> --json",
1609
1673
  "- dev app ls --repo <REPO_PATH>",
1610
1674
  "- For each entry (when safe): dev app run <name> --repo <REPO_PATH> --yes",
1611
1675
  "- Run one-shot commands with dep env: dev app exec <name> --repo <REPO_PATH> --yes -- <command>",
1612
1676
  "- Probe effective env before migration/seed: dev app exec <name> --repo <REPO_PATH> --yes -- printenv DB_URL DB_HOST DB_PORT DB_SHADOW_URL",
1613
- "- dev doctor --repo <REPO_PATH> --json",
1614
1677
  "- dev ls",
1615
1678
  "- For HTTP entries: curl -I http://<host>",
1616
1679
  '- For TCP postgres entries: provide connection hint (example: psql "... sslmode=require")',
@@ -1654,6 +1717,7 @@ var COMMAND_INTENTS;
1654
1717
  var init_ai_prompt = __esm({
1655
1718
  "src/core/ai-prompt.ts"() {
1656
1719
  "use strict";
1720
+ init_capabilities();
1657
1721
  init_repo_config();
1658
1722
  COMMAND_INTENTS = [
1659
1723
  {
@@ -1668,6 +1732,10 @@ var init_ai_prompt = __esm({
1668
1732
  command: "dev upgrade [version]",
1669
1733
  purpose: "Show upgrade targets from .devrouter.yml devrouter.version and print target adaptation prompt."
1670
1734
  },
1735
+ {
1736
+ command: "dev setup",
1737
+ purpose: "Run first-time devrouter machine setup and report structured diagnostics."
1738
+ },
1671
1739
  {
1672
1740
  command: "dev up",
1673
1741
  purpose: "Start shared Traefik and ensure the shared devnet network."
@@ -1697,6 +1765,18 @@ var init_ai_prompt = __esm({
1697
1765
  command: "dev repo init",
1698
1766
  purpose: "Create `.devrouter.yml` in a target repository."
1699
1767
  },
1768
+ {
1769
+ command: "dev repo inspect",
1770
+ purpose: "Inspect package, scripts, compose services, env names, devcontainer, devrouter config, and agent guidance for onboarding."
1771
+ },
1772
+ {
1773
+ command: "dev repo devcontainer write",
1774
+ purpose: "Dry-run or write conservative managed Node/pnpm/Postgres devcontainer/devrouter scaffold files."
1775
+ },
1776
+ {
1777
+ command: "dev repo devcontainer verify",
1778
+ purpose: "Emit static onboarding evidence, or live route probes with --live --yes."
1779
+ },
1700
1780
  {
1701
1781
  command: "dev app add",
1702
1782
  purpose: "Add or update one app entry in `.devrouter.yml`."
@@ -1954,51 +2034,76 @@ Local dev routing via a shared Traefik reverse proxy. Provides stable \`*.localh
1954
2034
  \`\`\`yaml
1955
2035
  version: 1
1956
2036
  devrouter:
1957
- version: <semver> # required for dev -V / dev upgrade
2037
+ version: <semver> # required for dev -V / dev upgrade
1958
2038
  project:
1959
- name: <string> # optional
2039
+ name: <string> # optional
1960
2040
  apps:
1961
- - name: <string> # unique within repo
1962
- kind: app | dependency # optional, default: app
1963
- dependencies: # optional
2041
+ - name: <string> # unique within repo
2042
+ kind: app | dependency # optional, default: app
2043
+ dependencies: # optional
1964
2044
  - app: <other-name>
2045
+ envMap: # optional; maps target env var name -> per-dep source var name
2046
+ DATABASE_URL: <UPPER_DEP_NAME>_URL
1965
2047
 
1966
2048
  # if kind=app:
1967
2049
  host: <name>.localhost
1968
2050
  protocol: http | tcp
1969
- runtime: host | docker
2051
+ runtime: host | docker | proxy
2052
+
2053
+ # if kind=app and runtime=proxy (protocol http or tcp):
2054
+ upstream: 127.0.0.1:3000 # already-running port to route to; no lifecycle/deps
2055
+ # Loopback (127.0.0.1/localhost) -> host.docker.internal (a published host
2056
+ # port). A non-loopback name is passed verbatim and resolved over devnet \u2014
2057
+ # so a devcontainer container ON devnet (with a network alias) can be fronted
2058
+ # by NAME with NO published host port: upstream: <alias>:3000. This is the
2059
+ # collision-free way to run many apps at once (each its own *.localhost).
2060
+ # upstream may use the \${WORKSPACE} placeholder (e.g. \${WORKSPACE}-app:3000)
2061
+ # to target a per-workspace devcontainer alias \u2014 substituted with the resolved
2062
+ # workspace token at runtime. See "Workspace isolation" below. Do NOT put
2063
+ # \${WORKSPACE} in \`host\` (rejected); the host is auto-namespaced.
2064
+ #
2065
+ # proxy + tcp (front a DB in an externally-managed container, e.g. a
2066
+ # devcontainer's Postgres on devnet) \u2014 no per-DB host port:
2067
+ # protocol: tcp
2068
+ # tcpProtocol: postgres # selects shared entrypoint :5432
2069
+ # upstream: <db-alias>:5432 # devnet alias of the DB container
2070
+ # Requires \`dev tls install\` (SNI is read from the TLS ClientHello). Connect
2071
+ # with direct-SSL so the ClientHello carries SNI, e.g.:
2072
+ # psql "host=db.<app>.localhost port=5432 sslmode=require sslnegotiation=direct ..."
1970
2073
 
1971
2074
  # if kind=app and runtime=host (protocol must be http):
1972
2075
  hostRun:
1973
2076
  command: <string>
1974
- cwd: <string> # relative to repo root, must not escape it
1975
- portTimeout: 120 # seconds, optional
2077
+ cwd: <string> # relative to repo root, must not escape it
2078
+ portTimeout: 120 # seconds, optional
1976
2079
  strategy:
1977
2080
  type: auto
1978
2081
  denyPorts: [80, 443, 5432]
1979
- allowPortRange: "1024-65535"
2082
+ allowPortRange: '1024-65535'
1980
2083
 
1981
2084
  # if kind=app and runtime=docker:
1982
2085
  docker:
1983
2086
  service: <string>
1984
2087
  internalPort: <number>
1985
- composeFiles: [<string>] # relative to repo root
1986
- router: <string> # optional
2088
+ composeFiles: [<string>] # relative to repo root
2089
+ router: <string> # optional
1987
2090
 
1988
2091
  # if kind=app and protocol=tcp:
1989
- tcpProtocol: postgres # required; one of: postgres, redis, mariadb, mysql
2092
+ tcpProtocol: postgres # required; runtime must be docker OR proxy
1990
2093
 
1991
2094
  # if kind=dependency:
1992
2095
  runtime: docker
1993
2096
  docker:
1994
2097
  service: <string>
1995
- composeFiles: [<string>] # relative to repo root
2098
+ composeFiles: [<string>] # relative to repo root
1996
2099
  \`\`\`
1997
2100
 
1998
2101
  Validation rules:
2102
+
1999
2103
  - \`kind=app\`: \`host\` must end with \`.localhost\`
2000
2104
  - \`kind=app\`: \`runtime=host\` supports \`protocol=http\` only
2001
- - \`kind=app\`: \`protocol=tcp\` requires \`runtime=docker\` and \`tcpProtocol\` (postgres, redis, mariadb, mysql)
2105
+ - \`kind=app\`: \`runtime=proxy\` supports \`protocol=http\` or \`protocol=tcp\`, requires \`upstream\` (\`host:port\`), and forbids \`hostRun\`/\`docker\`/\`dependencies\` (it only registers a route to an externally-managed upstream). \`protocol=tcp\` additionally requires \`tcpProtocol\` and TLS (\`dev tls install\`)
2106
+ - \`kind=app\`: \`protocol=tcp\` requires \`runtime=docker\` (devrouter-managed container) or \`runtime=proxy\` (externally-managed upstream), plus a supported \`tcpProtocol\` (postgres/redis/mariadb/mysql)
2002
2107
  - \`kind=dependency\`: must use \`runtime=docker\` and does not allow routed fields (\`host\`/\`protocol\`/\`tcpProtocol\`/\`hostRun\`/\`docker.internalPort\`/\`docker.router\`)
2003
2108
  - Unknown keys rejected (strict schema)
2004
2109
 
@@ -2006,13 +2111,14 @@ Validation rules:
2006
2111
 
2007
2112
  - **Healthcheck required**: every dependency service must define a \`healthcheck\`. \`docker compose up --wait\` blocks until healthy; without one, wait returns immediately.
2008
2113
  - **No published ports**: services must not publish host ports for devrouter-owned ports (80, 443, 5432). Avoid publishing ports at all -- devrouter handles routing via Traefik.
2009
- - **Postgres credentials**: use \`POSTGRES_USER=prisma\`, \`POSTGRES_PASSWORD=prisma\`, \`POSTGRES_DB=prisma\` and create a \`shadow\` database. devrouter injects \`DATABASE_URL\` / \`SHADOW_DATABASE_URL\` with these fixed credentials.
2114
+ - **Postgres credentials**: use \`POSTGRES_USER=prisma\`, \`POSTGRES_PASSWORD=prisma\`, \`POSTGRES_DB=prisma\` and create a \`shadow\` database. devrouter injects per-dep \`{PREFIX}_URL\` / \`{PREFIX}_SHADOW_URL\` with these credentials.
2010
2115
  - **Persistent volume warning**: if postgres defaults changed on an existing volume, reconcile credentials/data or recreate volumes when safe (for example \`docker compose down -v\`).
2011
2116
 
2012
2117
  Example healthcheck:
2118
+
2013
2119
  \`\`\`yaml
2014
2120
  healthcheck:
2015
- test: ["CMD-SHELL", "pg_isready -U prisma -d prisma"]
2121
+ test: ['CMD-SHELL', 'pg_isready -U prisma -d prisma']
2016
2122
  interval: 5s
2017
2123
  timeout: 3s
2018
2124
  retries: 20
@@ -2020,40 +2126,61 @@ healthcheck:
2020
2126
 
2021
2127
  ## Env var injection
2022
2128
 
2023
- When a host app depends on a TCP/Postgres Docker service, \`dev app run\` and \`dev app exec\` inject:
2129
+ When a host app depends on a TCP Docker service, \`dev app run\` and \`dev app exec\` inject per-dep deterministic vars (where \`{PREFIX} = dep.name.toUpperCase().replace(/-/g, "_")\`):
2024
2130
 
2025
- | Variable | Value |
2026
- |---|---|
2027
- | \`<UPPER_NAME>_HOST\` | \`localhost\` |
2028
- | \`<UPPER_NAME>_PORT\` | random mapped port |
2029
- | \`DATABASE_URL\` | \`postgres://prisma:prisma@localhost:<port>/prisma\` (postgres deps only) |
2030
- | \`SHADOW_DATABASE_URL\` | \`postgres://prisma:prisma@localhost:<port>/shadow\` (postgres deps only) |
2131
+ | Variable | Value |
2132
+ | ----------------------- | ----------------------------------------------------------- |
2133
+ | \`{PREFIX}_HOST\` | \`localhost\` |
2134
+ | \`{PREFIX}_PORT\` | random mapped port |
2135
+ | \`{PREFIX}_URL\` | protocol-specific URL (postgres, redis, mysql/mariadb) |
2136
+ | \`{PREFIX}_SHADOW_URL\` | \`postgres://prisma:prisma@localhost:<port>/shadow\` (postgres only) |
2031
2137
 
2032
2138
  Host apps also receive \`PORT\` (random free port), \`HOSTNAME=0.0.0.0\`, \`HOST=0.0.0.0\`.
2033
2139
 
2034
- \`dev app exec --env-map TARGET=SOURCE\` applies deterministic alias mapping after dependency env injection (for example \`DATABASE_URI=DATABASE_URL\`).
2140
+ Config-level \`envMap\` on dependency references aliases per-dep vars to app-expected names (for example \`DATABASE_URL: DB_URL\` maps the per-dep \`DB_URL\` to \`DATABASE_URL\`).
2141
+
2142
+ ## Workspace isolation (parallel git worktrees / agents)
2143
+
2144
+ Run several worktrees of one repo in parallel without host/route collisions. A **workspace token** is a single identity spanning three layers: the devpod workspace id (\`devpod up --id <ws>\`), the routes devrouter registers, and the \`\${WORKSPACE}\` placeholder in \`.devrouter.yml\` upstreams + the devcontainer compose network alias.
2145
+
2146
+ - **Token resolution** (precedence): \`--workspace <slug>\` flag > \`DEVROUTER_WORKSPACE\` env var > auto-derived from a linked git worktree branch (sanitized: lowercase, non-alphanumeric \u2192 \`-\`, capped at 32 chars) > none. The primary checkout resolves to no token and routes exactly as before (back-compatible).
2147
+ - **When active**: hosts auto-namespace (\`web.localhost\` \u2192 \`web.<ws>.localhost\`), \`\${WORKSPACE}\` in \`upstream\` is substituted with the token, and the docker \`router\` key is suffixed per workspace. The runtime config is computed in memory only \u2014 the committed \`.devrouter.yml\` is never rewritten.
2148
+ - **TLS**: namespaced hosts (\`web.<ws>.localhost\`) are not covered by the \`*.localhost\` wildcard; devrouter auto-extends the mkcert cert SANs for active hosts when TLS is enabled.
2149
+ - **devcontainer integration**: the devcontainer compose service exposes a devnet alias \`\${WORKSPACE}-app\` (default \`WORKSPACE=<project>\` in \`devcontainer.env\`); the proxy app uses \`upstream: \${WORKSPACE}-app:<port>\`. Workspace \`feat-a\` \u2192 alias \`feat-a-app\`, host \`app.feat-a.localhost\`.
2150
+ - **Lifecycle**: \`dev workspace up <branch>\` (create worktree + devpod + routes), \`dev workspace ls\` (list worktrees/tokens/route counts), \`dev workspace down <workspace|branch>\` (free routes by state-file workspace tag + stop devpod + remove worktree). \`dev doctor\` reports orphaned workspace proxy routes whose worktree dir was removed without \`dev workspace down\`.
2035
2151
 
2036
2152
  ## Secret manager interop (Infisical/Doppler)
2037
2153
 
2154
+ - Config-based SM integration: set \`secretManager.command\` in \`.devrouter.yml\` (include trailing \`--\`). devrouter wraps commands and re-injects dep env vars after the SM boundary.
2155
+ - \`secretManager.defaultEnv\`: optional fallback environment for \`{env}\` template in command string.
2156
+ - \`{env}\` template placeholder: \`secretManager.command: "infisical run --env {env} --"\` resolved at runtime. \`--env <env>\` CLI flag overrides \`defaultEnv\`.
2157
+ - Example config:
2158
+ \`\`\`yaml
2159
+ secretManager:
2160
+ command: infisical run --env {env} --
2161
+ defaultEnv: dev
2162
+ \`\`\`
2163
+ - Use \`envMap\` on dependency references to alias per-dep vars to app-expected names:
2164
+ \`\`\`yaml
2165
+ dependencies:
2166
+ - app: db
2167
+ envMap:
2168
+ DATABASE_URL: DB_URL
2169
+ DIRECT_URL: DB_URL
2170
+ SHADOW_DATABASE_URL: DB_SHADOW_URL
2171
+ \`\`\`
2038
2172
  - Prefer argv-safe command forms. Do not wrap \`infisical run\` or \`doppler run\` in \`sh -lc\` unless shell expansion is strictly required.
2039
2173
  - Canonical Infisical migrate command:
2040
- \`dev app exec <app> --yes --env-map DATABASE_URI=DATABASE_URL -- infisical run --projectId <id> --env=<env> -- pnpm payload migrate\`
2041
- - Canonical Infisical seed command:
2042
- \`dev app exec <app> --yes --env-map DATABASE_URI=DATABASE_URL -- infisical run --projectId <id> --env=<env> -- pnpm payload seed\`
2174
+ \`dev app exec <app> --yes -- infisical run --projectId <id> --env=<env> -- pnpm payload migrate\`
2043
2175
  - Canonical env probe command (run before migrate/seed):
2044
- \`dev app exec <app> --yes --env-map DATABASE_URI=DATABASE_URL -- printenv DATABASE_URL DATABASE_URI DB_HOST DB_PORT SHADOW_DATABASE_URL\`
2176
+ \`dev app exec <app> --yes -- printenv DB_URL DB_HOST DB_PORT DB_SHADOW_URL\`
2045
2177
  - Canonical Doppler migrate command:
2046
- \`dev app exec <app> --yes --env-map DATABASE_URI=DATABASE_URL -- doppler run -- pnpm payload migrate\`
2047
- - Precedence best practice: avoid defining local \`DATABASE_URL\` / \`DATABASE_URI\` in Infisical/Doppler when you expect devrouter local DB injection.
2048
- - Precedence best practice: store remote/prod URLs under non-conflicting names (for example \`PROD_DATABASE_URL\`) and map intentionally in app config/scripts.
2049
- - Precedence best practice: if secret manager must define \`DATABASE_URL\`, run the env probe and verify values before any migration/seed.
2050
- - Avoid pre-wrapper DB assignments such as \`DATABASE_URI=... <wrapper> run -- ...\`; wrapper-managed env may override those values.
2051
- - Safe host-run override pattern when wrapper also defines \`DATABASE_URI\`:
2052
- \`infisical run --projectId <id> --env=<env> -- env DATABASE_URI=\${DATABASE_URL:?missing DATABASE_URL} pnpm dev\`
2053
- - \`dev app run\` does not currently expose \`--env-map\`; if an app only accepts \`DATABASE_URI\`, prefer app-level fallback (\`DATABASE_URI\` then \`DATABASE_URL\`) or a small repo-local wrapper script.
2054
- - \`dev doctor --repo .\` warns on risky pre-wrapper DB assignments before \`run --\` for host apps that depend on postgres.
2178
+ \`dev app exec <app> --yes -- doppler run -- pnpm payload migrate\`
2179
+ - Precedence best practice: avoid defining per-dep var names in Infisical/Doppler when you expect devrouter local DB injection.
2180
+ - Precedence best practice: store remote/prod URLs under non-conflicting names (for example \`PROD_DATABASE_URL\`) and map intentionally via \`envMap\`.
2181
+ - Precedence best practice: if secret manager must define DB vars, run the env probe and verify values before any migration/seed.
2055
2182
  - Use \`dev app exec --shell -- "<single command string>"\` only when shell expansion is required.
2056
- - \`--env-map\` fails fast when SOURCE is missing so migrations do not run with partial mapping.
2183
+ - \`envMap\` fails fast when source var is missing so migrations do not run with partial mapping.
2057
2184
 
2058
2185
  ## Upgrade handling (required)
2059
2186
 
@@ -2080,6 +2207,7 @@ Host apps also receive \`PORT\` (random free port), \`HOSTNAME=0.0.0.0\`, \`HOST
2080
2207
  - \`dev init [--write-agents] [--write-skill] [--with-linear]\`: print AI onboarding prompt (non-mutating by default)
2081
2208
  - \`dev -V [--repo .]\`: show installed CLI version, local repo version, and next upgrade target
2082
2209
  - \`dev upgrade [version] [--repo .]\`: list upgrade targets or print target Agent Adaptation Prompt
2210
+ - \`dev setup --yes [--repo .] [--json]\`: first-run machine setup plus structured diagnostics
2083
2211
  - \`dev up\` / \`dev down\`: start/stop shared Traefik router
2084
2212
  - \`dev status\`: router/container/network/TLS health
2085
2213
  - \`dev doctor [--repo .]\`: deep diagnostics (global + repo)
@@ -2088,36 +2216,54 @@ Host apps also receive \`PORT\` (random free port), \`HOSTNAME=0.0.0.0\`, \`HOST
2088
2216
  - \`dev logs [-f]\`: Traefik access logs
2089
2217
  - \`dev tls install\`: install mkcert certs, enable HTTPS + TCP/SNI
2090
2218
  - \`dev repo init\`: create \`.devrouter.yml\`
2219
+ - \`dev repo inspect [--json]\`: inspect package, scripts, compose services, env names, devcontainer, devrouter config, and agent guidance for onboarding
2220
+ - \`dev repo devcontainer write --dry-run --json\`: plan conservative Node/pnpm/Postgres devcontainer/devrouter scaffold files without writing
2221
+ - \`dev repo devcontainer write --yes\`: write managed Node/pnpm/Postgres devcontainer/devrouter scaffold files when no custom-file conflicts exist
2222
+ - \`dev repo devcontainer verify --json\`: emit read-only onboarding evidence for PRs
2223
+ - \`dev repo devcontainer verify --live --yes --json\`: register proxy routes and probe HTTP routes after the devcontainer is running
2091
2224
  - \`dev repo agents [--with-linear]\`: write devrouter section in AGENTS.md + install this skill (and optional Linear workflow assets)
2092
2225
  - \`dev app add\`: add/update app entry in \`.devrouter.yml\`
2093
2226
  - \`dev app ls\`: list app entries
2094
- - \`dev app run <name>\`: run app with dependency lifecycle
2095
- - \`dev app exec <name> [--shell] [--env-map TARGET=SOURCE] -- <cmd>\`: one-shot command with resolved dep env
2096
- - \`dev app rm <name>\`: remove app entry
2227
+ - \`dev app run <name> [--env <env>] [--workspace <slug>]\`: run app with dependency lifecycle (--env overrides SM defaultEnv; --workspace overrides the per-workspace token)
2228
+ - \`dev app exec <name> [--shell] [--env <env>] [--workspace <slug>] -- <cmd>\`: one-shot command with resolved dep env
2229
+ - \`dev app rm <name> [--keep-config]\`: remove app entry (\`--keep-config\` frees only the live route/hostname, leaves \`.devrouter.yml\` untouched)
2230
+ - \`dev workspace up <branch> [--path <dir>] [--no-devpod] [--open]\`: create a worktree + devpod + namespaced routes
2231
+ - \`dev workspace ls [--json]\`: list git worktrees with workspace token + route count
2232
+ - \`dev workspace down <workspace|branch> [--keep-worktree] [--keep-devpod]\`: free routes + stop devpod + remove worktree
2097
2233
 
2098
2234
  ## Validation workflow
2099
2235
 
2100
- 1. \`dev up\` -- ensure shared router is running
2101
- 2. For TCP/Postgres repos: \`dev tls install\`
2102
- 3. \`dev doctor --repo .\` -- check global + repo health
2103
- 4. \`dev app ls --repo .\` -- verify entries match expectations
2104
- 5. \`dev app run <host-app> --repo . --yes\` -- start target app with deps
2105
- 6. \`dev ls\` -- confirm routes are exposed
2106
- 7. \`curl -I https://<host>.localhost\` -- HTTP reachability
2107
- 8. For TCP/Postgres: use \`dev open <name>\` for connection hint
2236
+ For devcontainer onboarding:
2237
+
2238
+ 1. \`dev setup --repo . --yes --json\`
2239
+ 2. \`dev doctor --repo . --json\`
2240
+ 3. \`dev repo inspect --repo . --json\`
2241
+ 4. \`dev repo devcontainer write --repo . --dry-run --json\`
2242
+ 5. \`dev repo devcontainer write --repo . --yes\`
2243
+ 6. \`dev repo devcontainer verify --repo . --json\`
2244
+ 7. Start the devcontainer, for example \`devpod up .\`
2245
+ 8. \`dev repo devcontainer verify --repo . --live --yes --json\`
2246
+
2247
+ For existing host/docker runtime apps:
2248
+
2249
+ 1. \`dev setup --repo . --yes\`
2250
+ 2. \`dev doctor --repo .\`
2251
+ 3. \`dev app ls --repo .\`
2252
+ 4. \`dev app run <host-app> --repo . --yes\`
2253
+ 5. \`dev ls\`
2254
+ 6. \`curl -I https://<host>.localhost\`
2255
+ 7. For TCP/Postgres, use \`dev open <name>\` for the connection hint.
2108
2256
 
2109
2257
  ## Runtime behavior notes
2110
2258
 
2111
- - \`dev app run\` auto-starts Docker dependencies, waits for health, stops them on exit.
2259
+ - \`dev app run\` auto-starts Docker dependencies and waits for health. Host app runs stop auto-started docker deps on exit; docker app runs leave target services running until explicit cleanup.
2112
2260
  - Host-runtime dependencies are NOT auto-started (v1).
2113
2261
  - \`kind=dependency\` entries do not create routes and cannot be direct targets for \`dev app run\`, \`dev app exec\`, or \`dev open\`.
2114
2262
  - \`kind=dependency\` services start as declared in compose (no Traefik label wiring, no random port publishing, no injected env vars).
2115
2263
  - Postgres on shared \`:5432\` requires TLS/SNI (\`dev tls install\`). Standard app clients should use the injected random port instead.
2116
- - \`dev app exec\` starts deps as needed for one-shot commands and preserves argv semantics by default (\`shell: false\`).
2117
- - \`dev app exec\` stops only deps started by that exec call; already-running deps stay running.
2118
- - If exec cannot determine pre-existing running services, it leaves selected deps running to avoid non-owned teardown.
2264
+ - \`dev app exec\` follows the same dep lifecycle for one-shot commands and preserves argv semantics by default (\`shell: false\`).
2119
2265
  - \`dev app exec --shell\` is explicit and requires exactly one command string after \`--\`.
2120
- - Secret-manager overlap caveat: if Infisical/Doppler defines DB vars too, probe effective env (\`printenv DATABASE_URL DATABASE_URI DB_HOST DB_PORT\`) before migrate/seed.
2266
+ - Secret-manager overlap caveat: if Infisical/Doppler defines DB vars too, probe effective env (\`printenv DB_URL DB_HOST DB_PORT\`) before migrate/seed.
2121
2267
  `;
2122
2268
  LINEAR_WORKFLOW_SKILL_CONTENT = `---
2123
2269
  name: linear-workflow
@@ -2235,8 +2381,8 @@ If the repository uses devrouter, use \`dev upgrade\` to resolve the required Ag
2235
2381
  function isoTimestamp(now) {
2236
2382
  return (now ?? /* @__PURE__ */ new Date()).toISOString();
2237
2383
  }
2238
- function requiredPlaceholder(path10) {
2239
- return `<REQUIRED: ${path10}>`;
2384
+ function requiredPlaceholder(path16) {
2385
+ return `<REQUIRED: ${path16}>`;
2240
2386
  }
2241
2387
  function normalizeOptionalValue(value) {
2242
2388
  const trimmed = value.trim();
@@ -2554,6 +2700,52 @@ function printDoctorReport(report) {
2554
2700
  }
2555
2701
  }
2556
2702
  }
2703
+ function printSetupReport(report) {
2704
+ const summaryRows = [
2705
+ ["Generated", report.generatedAt],
2706
+ ["Repo path", report.repoPath ?? "-"],
2707
+ ["Actions performed", String(report.summary.actions.performed)],
2708
+ ["Actions skipped", String(report.summary.actions.skipped)],
2709
+ ["Actions failed", String(report.summary.actions.failed)],
2710
+ ["Checks OK", String(report.summary.checks.ok)],
2711
+ ["Checks WARN", String(report.summary.checks.warn)],
2712
+ ["Checks ERROR", String(report.summary.checks.error)]
2713
+ ];
2714
+ process.stdout.write(`${renderTable(["FIELD", "VALUE"], summaryRows)}
2715
+
2716
+ `);
2717
+ const actionRows = report.actions.map((entry) => [
2718
+ entry.id,
2719
+ entry.status.toUpperCase(),
2720
+ entry.summary,
2721
+ entry.suggestion ?? "-"
2722
+ ]);
2723
+ process.stdout.write(`${renderTable(["ACTION", "STATUS", "SUMMARY", "SUGGESTION"], actionRows)}
2724
+ `);
2725
+ const detailedActions = report.actions.filter((entry) => entry.details);
2726
+ if (detailedActions.length > 0) {
2727
+ process.stdout.write("\nAction details:\n");
2728
+ for (const entry of detailedActions) {
2729
+ process.stdout.write(`- ${entry.id}: ${entry.details}
2730
+ `);
2731
+ }
2732
+ }
2733
+ const failingChecks = report.checks.filter((check) => check.level !== "ok");
2734
+ if (failingChecks.length > 0) {
2735
+ process.stdout.write("\nDiagnostic findings:\n");
2736
+ for (const check of failingChecks) {
2737
+ process.stdout.write(`- ${check.id} [${check.level.toUpperCase()}]: ${check.summary}
2738
+ `);
2739
+ }
2740
+ }
2741
+ if (report.nextSteps.length > 0) {
2742
+ process.stdout.write("\nRecommended next steps:\n");
2743
+ for (const step of report.nextSteps) {
2744
+ process.stdout.write(`- ${step}
2745
+ `);
2746
+ }
2747
+ }
2748
+ }
2557
2749
  var init_output = __esm({
2558
2750
  "src/core/output.ts"() {
2559
2751
  "use strict";
@@ -2946,138 +3138,217 @@ var init_docker = __esm({
2946
3138
  }
2947
3139
  });
2948
3140
 
2949
- // src/util/ports.ts
2950
- function findPortListeners(port) {
2951
- const result = (0, import_node_child_process4.spawnSync)("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], {
3141
+ // src/core/tls.ts
3142
+ function runOrThrow(command, args) {
3143
+ const result = (0, import_node_child_process4.spawnSync)(command, args, {
2952
3144
  encoding: "utf-8"
2953
3145
  });
2954
- if (result.status !== 0 || !result.stdout.trim()) {
2955
- return [];
2956
- }
2957
- const lines = result.stdout.trim().split(/\r?\n/);
2958
- if (lines.length <= 1) {
2959
- return [];
3146
+ if (result.status !== 0) {
3147
+ const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
3148
+ throw new Error(`${command} ${args.join(" ")} failed: ${details || "unknown error"}`);
2960
3149
  }
2961
- return lines.slice(1).map((line) => {
2962
- const parts = line.trim().split(/\s+/);
2963
- const command = parts[0] ?? "?";
2964
- const pid = parts[1] ?? "?";
2965
- const user = parts[2] ?? "?";
2966
- const address = parts.slice(-2).join(" ") || "?";
2967
- return {
2968
- port,
2969
- command,
2970
- pid,
2971
- user,
2972
- address
2973
- };
2974
- });
2975
3150
  }
2976
- var import_node_child_process4;
2977
- var init_ports = __esm({
2978
- "src/util/ports.ts"() {
2979
- "use strict";
2980
- import_node_child_process4 = require("child_process");
3151
+ function commandExists(command) {
3152
+ const result = (0, import_node_child_process4.spawnSync)("sh", ["-c", `command -v ${command}`], { encoding: "utf-8" });
3153
+ return result.status === 0;
3154
+ }
3155
+ function ensureMkcert() {
3156
+ if (commandExists("mkcert")) {
3157
+ return;
2981
3158
  }
2982
- });
2983
-
2984
- // src/commands/up.ts
2985
- var up_exports = {};
2986
- __export(up_exports, {
2987
- runUpCommand: () => runUpCommand
2988
- });
2989
- function buildPortConflictMessage() {
2990
- const listeners = [...findPortListeners(80), ...findPortListeners(443), ...findPortListeners(5432)];
2991
- if (listeners.length === 0) {
2992
- return "";
3159
+ if (!commandExists("brew")) {
3160
+ throw new Error("mkcert is missing and Homebrew is not available.");
2993
3161
  }
2994
- const details = listeners.map((listener) => {
2995
- return `- port ${listener.port}: ${listener.command} (pid ${listener.pid}, user ${listener.user}, ${listener.address})`;
2996
- }).join("\n");
2997
- return `Cannot start devrouter because host ports 80/443/5432 are already in use:
2998
- ${details}
2999
-
3000
- Mitigation:
3001
- 1) Stop the conflicting process/container
3002
- 2) Re-run: dev up
3003
-
3004
- Debug commands:
3005
- - lsof -nP -iTCP:80 -sTCP:LISTEN
3006
- - lsof -nP -iTCP:443 -sTCP:LISTEN
3007
- - lsof -nP -iTCP:5432 -sTCP:LISTEN`;
3162
+ runOrThrow("brew", ["install", "mkcert"]);
3008
3163
  }
3009
- async function runUpCommand() {
3010
- await ensureNetwork(DEVNET_NAME);
3011
- ensureRouterFiles();
3012
- const routerRunning = await isContainerRunning(ROUTER_CONTAINER_NAME);
3013
- if (!routerRunning) {
3014
- const message = buildPortConflictMessage();
3015
- if (message) {
3016
- throw new Error(message);
3164
+ function normalizeHost(host) {
3165
+ return host.trim().toLowerCase();
3166
+ }
3167
+ function normalizeUniqueHosts(hosts) {
3168
+ const set = /* @__PURE__ */ new Set();
3169
+ for (const host of hosts) {
3170
+ const normalized = normalizeHost(host);
3171
+ if (normalized.length === 0) {
3172
+ continue;
3017
3173
  }
3174
+ set.add(normalized);
3018
3175
  }
3019
- startRouterStack();
3020
- process.stdout.write("devrouter is up.\n");
3176
+ return Array.from(set.values()).sort();
3021
3177
  }
3022
- var init_up = __esm({
3023
- "src/commands/up.ts"() {
3024
- "use strict";
3025
- init_docker();
3026
- init_router();
3027
- init_ports();
3178
+ function parseDnsHostsFromSubjectAltName(subjectAltName) {
3179
+ const names = [];
3180
+ const matches = subjectAltName.matchAll(/DNS:([^,\n]+)/g);
3181
+ for (const match of matches) {
3182
+ const host = normalizeHost(match[1] ?? "");
3183
+ if (host.length > 0) {
3184
+ names.push(host);
3185
+ }
3028
3186
  }
3029
- });
3030
-
3031
- // src/commands/down.ts
3032
- var down_exports = {};
3033
- __export(down_exports, {
3034
- runDownCommand: () => runDownCommand
3035
- });
3036
- async function runDownCommand() {
3037
- stopRouterStack();
3038
- clearActiveTcpProtocols();
3039
- process.stdout.write("devrouter is down.\n");
3187
+ return normalizeUniqueHosts(names);
3040
3188
  }
3041
- var init_down = __esm({
3042
- "src/commands/down.ts"() {
3043
- "use strict";
3044
- init_router();
3045
- }
3046
- });
3047
-
3048
- // src/core/status.ts
3049
- function hasPortBinding(ports, privatePort, publicPort) {
3050
- if (!ports) {
3051
- return false;
3189
+ function parseCertificateDnsHosts(pem) {
3190
+ const certificate = new import_node_crypto.X509Certificate(pem);
3191
+ const subjectAltName = certificate.subjectAltName ?? "";
3192
+ if (subjectAltName.length === 0) {
3193
+ return [];
3052
3194
  }
3053
- return ports.some((port) => port.PrivatePort === privatePort && port.PublicPort === publicPort);
3195
+ return parseDnsHostsFromSubjectAltName(subjectAltName);
3054
3196
  }
3055
- function toRepoStatus(repoPath) {
3056
- const resolvedRepoPath = resolveRepoPath(repoPath);
3057
- const configPath = getRepoConfigPath(resolvedRepoPath);
3058
- const explicitRepo = typeof repoPath === "string" && repoPath.trim().length > 0;
3059
- const configExists = import_node_fs7.default.existsSync(configPath);
3060
- if (!explicitRepo && !configExists) {
3061
- return void 0;
3197
+ function isHostCoveredByCertificateHost(host, certificateHost) {
3198
+ const normalizedHost = normalizeHost(host);
3199
+ const normalizedCertificateHost = normalizeHost(certificateHost);
3200
+ if (normalizedCertificateHost.startsWith("*.")) {
3201
+ const suffix = normalizedCertificateHost.slice(1);
3202
+ if (!normalizedHost.endsWith(suffix)) {
3203
+ return false;
3204
+ }
3205
+ const wildcardPart = normalizedHost.slice(0, normalizedHost.length - suffix.length);
3206
+ return wildcardPart.length > 0 && !wildcardPart.includes(".");
3062
3207
  }
3063
- if (!configExists) {
3064
- return {
3065
- path: resolvedRepoPath,
3066
- configPath,
3067
- exists: false,
3068
- valid: false,
3069
- appCount: 0,
3070
- tcpAppCount: 0,
3071
- error: `Missing .devrouter.yml in ${resolvedRepoPath}`
3072
- };
3208
+ return normalizedHost === normalizedCertificateHost;
3209
+ }
3210
+ function findUncoveredCertificateHosts(requiredHosts, certificateHosts) {
3211
+ const normalizedRequired = normalizeUniqueHosts(requiredHosts);
3212
+ const normalizedCertificateHosts = normalizeUniqueHosts(certificateHosts);
3213
+ return normalizedRequired.filter(
3214
+ (requiredHost) => !normalizedCertificateHosts.some(
3215
+ (certificateHost) => isHostCoveredByCertificateHost(requiredHost, certificateHost)
3216
+ )
3217
+ );
3218
+ }
3219
+ function readCurrentCertificateHosts() {
3220
+ if (!import_node_fs7.default.existsSync(CERT_FILE)) {
3221
+ return [];
3073
3222
  }
3223
+ const pem = import_node_fs7.default.readFileSync(CERT_FILE, "utf-8");
3224
+ return parseCertificateDnsHosts(pem);
3225
+ }
3226
+ function currentCertificateHostsOrEmpty() {
3074
3227
  try {
3075
- const config = loadRuntimeConfig(resolvedRepoPath).config;
3076
- const tcpAppCount = config.apps.filter(
3077
- (app) => app.kind !== "dependency" && app.protocol === "tcp"
3078
- ).length;
3079
- return {
3080
- path: resolvedRepoPath,
3228
+ return readCurrentCertificateHosts();
3229
+ } catch {
3230
+ return [];
3231
+ }
3232
+ }
3233
+ function buildDesiredTLSCertificateHosts(requestedHosts, existingCertificateHosts) {
3234
+ return normalizeUniqueHosts([
3235
+ ...DEFAULT_TLS_CERT_HOSTS,
3236
+ ...existingCertificateHosts,
3237
+ ...requestedHosts
3238
+ ]);
3239
+ }
3240
+ function getTLSHostCoverage(hosts) {
3241
+ const requiredHosts = normalizeUniqueHosts([...DEFAULT_TLS_CERT_HOSTS, ...hosts]);
3242
+ const certificateHosts = readCurrentCertificateHosts();
3243
+ const uncoveredHosts = findUncoveredCertificateHosts(requiredHosts, certificateHosts);
3244
+ return {
3245
+ requiredHosts,
3246
+ certificateHosts,
3247
+ uncoveredHosts
3248
+ };
3249
+ }
3250
+ async function installTLS(options = {}) {
3251
+ ensureRouterFiles();
3252
+ const alreadyEnabled = isTLSEnabled();
3253
+ const desiredHosts = buildDesiredTLSCertificateHosts(
3254
+ options.hosts ?? [],
3255
+ currentCertificateHostsOrEmpty()
3256
+ );
3257
+ ensureMkcert();
3258
+ runOrThrow("mkcert", ["-install"]);
3259
+ runOrThrow("mkcert", [
3260
+ "-cert-file",
3261
+ CERT_FILE,
3262
+ "-key-file",
3263
+ CERT_KEY_FILE,
3264
+ ...desiredHosts
3265
+ ]);
3266
+ setTLSEnabled(true);
3267
+ refreshHostRoutesDynamicFile();
3268
+ const routerContainer = await findContainerByName("devrouter-traefik");
3269
+ if (routerContainer && await isContainerRunning("devrouter-traefik")) {
3270
+ startRouterStack();
3271
+ }
3272
+ return { alreadyEnabled, hosts: desiredHosts };
3273
+ }
3274
+ async function ensureTLSHostsCovered(hosts) {
3275
+ if (!isTLSEnabled()) {
3276
+ return {
3277
+ refreshed: false,
3278
+ uncoveredHosts: [],
3279
+ certificateHosts: []
3280
+ };
3281
+ }
3282
+ const coverage = getTLSHostCoverage(hosts);
3283
+ if (coverage.uncoveredHosts.length === 0) {
3284
+ return {
3285
+ refreshed: false,
3286
+ uncoveredHosts: [],
3287
+ certificateHosts: coverage.certificateHosts
3288
+ };
3289
+ }
3290
+ try {
3291
+ const refreshed = await installTLS({ hosts: coverage.requiredHosts });
3292
+ return {
3293
+ refreshed: true,
3294
+ uncoveredHosts: coverage.uncoveredHosts,
3295
+ certificateHosts: refreshed.hosts
3296
+ };
3297
+ } catch (error) {
3298
+ const message = error instanceof Error ? error.message : String(error);
3299
+ throw new Error(
3300
+ `TLS cert does not currently cover host(s): ${coverage.uncoveredHosts.join(", ")}. Automatic refresh failed: ${message}
3301
+ Run: dev tls install`
3302
+ );
3303
+ }
3304
+ }
3305
+ var import_node_fs7, import_node_child_process4, import_node_crypto, DEFAULT_TLS_CERT_HOSTS;
3306
+ var init_tls = __esm({
3307
+ "src/core/tls.ts"() {
3308
+ "use strict";
3309
+ import_node_fs7 = __toESM(require("fs"));
3310
+ import_node_child_process4 = require("child_process");
3311
+ import_node_crypto = require("crypto");
3312
+ init_router();
3313
+ init_docker();
3314
+ init_host_routes();
3315
+ DEFAULT_TLS_CERT_HOSTS = ["localhost", "*.localhost"];
3316
+ }
3317
+ });
3318
+
3319
+ // src/core/status.ts
3320
+ function hasPortBinding(ports, privatePort, publicPort) {
3321
+ if (!ports) {
3322
+ return false;
3323
+ }
3324
+ return ports.some((port) => port.PrivatePort === privatePort && port.PublicPort === publicPort);
3325
+ }
3326
+ function toRepoStatus(repoPath) {
3327
+ const resolvedRepoPath = resolveRepoPath(repoPath);
3328
+ const configPath = getRepoConfigPath(resolvedRepoPath);
3329
+ const explicitRepo = typeof repoPath === "string" && repoPath.trim().length > 0;
3330
+ const configExists = import_node_fs8.default.existsSync(configPath);
3331
+ if (!explicitRepo && !configExists) {
3332
+ return void 0;
3333
+ }
3334
+ if (!configExists) {
3335
+ return {
3336
+ path: resolvedRepoPath,
3337
+ configPath,
3338
+ exists: false,
3339
+ valid: false,
3340
+ appCount: 0,
3341
+ tcpAppCount: 0,
3342
+ error: `Missing .devrouter.yml in ${resolvedRepoPath}`
3343
+ };
3344
+ }
3345
+ try {
3346
+ const config = loadRuntimeConfig(resolvedRepoPath).config;
3347
+ const tcpAppCount = config.apps.filter(
3348
+ (app) => app.kind !== "dependency" && app.protocol === "tcp"
3349
+ ).length;
3350
+ return {
3351
+ path: resolvedRepoPath,
3081
3352
  configPath,
3082
3353
  exists: true,
3083
3354
  valid: true,
@@ -3181,141 +3452,17 @@ async function collectRouterStatus(repoPath) {
3181
3452
  }
3182
3453
  };
3183
3454
  }
3184
- var import_node_fs7;
3455
+ var import_node_fs8;
3185
3456
  var init_status = __esm({
3186
3457
  "src/core/status.ts"() {
3187
3458
  "use strict";
3188
- import_node_fs7 = __toESM(require("fs"));
3459
+ import_node_fs8 = __toESM(require("fs"));
3189
3460
  init_docker();
3190
3461
  init_router();
3191
3462
  init_repo_config();
3192
3463
  }
3193
3464
  });
3194
3465
 
3195
- // src/commands/status.ts
3196
- var status_exports = {};
3197
- __export(status_exports, {
3198
- runStatusCommand: () => runStatusCommand
3199
- });
3200
- async function runStatusCommand(options) {
3201
- const status = await collectRouterStatus(options.repo);
3202
- if (options.json) {
3203
- printJSON(status);
3204
- return;
3205
- }
3206
- printStatus(status);
3207
- }
3208
- var init_status2 = __esm({
3209
- "src/commands/status.ts"() {
3210
- "use strict";
3211
- init_output();
3212
- init_status();
3213
- }
3214
- });
3215
-
3216
- // src/core/concurrency.ts
3217
- function routeUrl(host) {
3218
- const scheme = isTLSEnabled() ? "https" : "http";
3219
- return `${scheme}://${host}`;
3220
- }
3221
- function evictIfStale(route) {
3222
- if (route.mode === "proxy") {
3223
- return false;
3224
- }
3225
- if (isPidRunning(route.pid)) {
3226
- return false;
3227
- }
3228
- removeHostRouteById(route.id);
3229
- return true;
3230
- }
3231
- function assertAppNotRunning(repoPath, app) {
3232
- const routes = listHostRouteState();
3233
- const targetId = buildHostRouteId(repoPath, app.name);
3234
- for (const route of routes) {
3235
- if (route.id === targetId) {
3236
- if (evictIfStale(route)) {
3237
- continue;
3238
- }
3239
- throw new AppAlreadyRunningError(
3240
- app.name,
3241
- routeUrl(route.host),
3242
- route.pid,
3243
- route.repoPath
3244
- );
3245
- }
3246
- if (route.host === app.host) {
3247
- if (evictIfStale(route)) {
3248
- continue;
3249
- }
3250
- throw new HostnameConflictError(
3251
- app.host,
3252
- route.name,
3253
- route.repoPath,
3254
- route.pid
3255
- );
3256
- }
3257
- }
3258
- }
3259
- function evictStaleHostRoutes() {
3260
- const routes = listHostRouteState();
3261
- let evicted = 0;
3262
- for (const route of routes) {
3263
- if (evictIfStale(route)) {
3264
- evicted += 1;
3265
- }
3266
- }
3267
- return evicted;
3268
- }
3269
- function evictOrphanedWorkspaceRoutes() {
3270
- const orphans = listHostRouteState().filter(
3271
- (route) => route.mode === "proxy" && route.workspace !== void 0 && !import_node_fs8.default.existsSync(route.repoPath)
3272
- );
3273
- for (const route of orphans) {
3274
- removeHostRouteById(route.id);
3275
- }
3276
- return orphans.length;
3277
- }
3278
- var import_node_fs8, AppAlreadyRunningError, HostnameConflictError;
3279
- var init_concurrency = __esm({
3280
- "src/core/concurrency.ts"() {
3281
- "use strict";
3282
- import_node_fs8 = __toESM(require("fs"));
3283
- init_router();
3284
- init_host_routes();
3285
- AppAlreadyRunningError = class extends Error {
3286
- constructor(appName, url, pid, repoPath) {
3287
- const lines = [
3288
- `App "${appName}" is already running.`,
3289
- ` URL: ${url}`,
3290
- pid ? ` PID: ${pid}` : null,
3291
- ` Repo: ${repoPath}`
3292
- ].filter(Boolean);
3293
- super(lines.join("\n"));
3294
- this.appName = appName;
3295
- this.url = url;
3296
- this.pid = pid;
3297
- this.repoPath = repoPath;
3298
- this.name = "AppAlreadyRunningError";
3299
- }
3300
- };
3301
- HostnameConflictError = class extends Error {
3302
- constructor(hostname, existingApp, existingRepoPath, existingPid) {
3303
- const lines = [
3304
- `Hostname "${hostname}" is already claimed by app "${existingApp}".`,
3305
- existingPid ? ` PID: ${existingPid}` : null,
3306
- ` Repo: ${existingRepoPath}`
3307
- ].filter(Boolean);
3308
- super(lines.join("\n"));
3309
- this.hostname = hostname;
3310
- this.existingApp = existingApp;
3311
- this.existingRepoPath = existingRepoPath;
3312
- this.existingPid = existingPid;
3313
- this.name = "HostnameConflictError";
3314
- }
3315
- };
3316
- }
3317
- });
3318
-
3319
3466
  // src/core/routes.ts
3320
3467
  function resolveEntrypointToProtocol(entrypoint) {
3321
3468
  for (const [protocol, entry] of Object.entries(TCP_PROTOCOL_REGISTRY)) {
@@ -3491,186 +3638,475 @@ var init_paths = __esm({
3491
3638
  }
3492
3639
  });
3493
3640
 
3494
- // src/core/tls.ts
3495
- function runOrThrow(command, args) {
3496
- const result = (0, import_node_child_process5.spawnSync)(command, args, {
3497
- encoding: "utf-8"
3498
- });
3499
- if (result.status !== 0) {
3500
- const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
3501
- throw new Error(`${command} ${args.join(" ")} failed: ${details || "unknown error"}`);
3641
+ // src/core/route-state.ts
3642
+ function comparablePath(filePath) {
3643
+ const resolved = import_node_path8.default.resolve(filePath);
3644
+ try {
3645
+ return import_node_fs9.default.realpathSync.native(resolved);
3646
+ } catch {
3647
+ return resolved;
3502
3648
  }
3503
3649
  }
3504
- function commandExists(command) {
3505
- const result = (0, import_node_child_process5.spawnSync)("sh", ["-c", `command -v ${command}`], { encoding: "utf-8" });
3506
- return result.status === 0;
3650
+ function sameRoutePath(left, right) {
3651
+ return comparablePath(left) === comparablePath(right);
3507
3652
  }
3508
- function ensureMkcert() {
3509
- if (commandExists("mkcert")) {
3510
- return;
3653
+ function listRoutesForWorktreePaths(worktreePaths) {
3654
+ const routes = listHostRouteState();
3655
+ const byWorktreePath = /* @__PURE__ */ new Map();
3656
+ for (const worktreePath of worktreePaths) {
3657
+ byWorktreePath.set(worktreePath, []);
3511
3658
  }
3512
- if (!commandExists("brew")) {
3513
- throw new Error("mkcert is missing and Homebrew is not available.");
3659
+ for (const route of routes) {
3660
+ for (const worktreePath of worktreePaths) {
3661
+ if (sameRoutePath(route.repoPath, worktreePath)) {
3662
+ byWorktreePath.get(worktreePath)?.push(route);
3663
+ break;
3664
+ }
3665
+ }
3514
3666
  }
3515
- runOrThrow("brew", ["install", "mkcert"]);
3667
+ return byWorktreePath;
3516
3668
  }
3517
- function normalizeHost(host) {
3518
- return host.trim().toLowerCase();
3669
+ function removeWorkspaceRoutesForWorktree(workspace, worktreePath) {
3670
+ return removeHostRoutesWhere(
3671
+ (route) => route.workspace === workspace && sameRoutePath(route.repoPath, worktreePath)
3672
+ );
3519
3673
  }
3520
- function normalizeUniqueHosts(hosts) {
3521
- const set = /* @__PURE__ */ new Set();
3522
- for (const host of hosts) {
3523
- const normalized = normalizeHost(host);
3524
- if (normalized.length === 0) {
3525
- continue;
3526
- }
3527
- set.add(normalized);
3528
- }
3529
- return Array.from(set.values()).sort();
3674
+ function removeRouteForApp(repoPath, appName) {
3675
+ return removeHostRoutesWhere((route) => route.name === appName && sameRoutePath(route.repoPath, repoPath));
3530
3676
  }
3531
- function parseDnsHostsFromSubjectAltName(subjectAltName) {
3532
- const names = [];
3533
- const matches = subjectAltName.matchAll(/DNS:([^,\n]+)/g);
3534
- for (const match of matches) {
3535
- const host = normalizeHost(match[1] ?? "");
3536
- if (host.length > 0) {
3537
- names.push(host);
3538
- }
3677
+ function isStaleProcessRoute(route) {
3678
+ if (route.mode === "proxy") {
3679
+ return false;
3539
3680
  }
3540
- return normalizeUniqueHosts(names);
3681
+ if (!route.pid) {
3682
+ return true;
3683
+ }
3684
+ return !isPidRunning(route.pid);
3541
3685
  }
3542
- function parseCertificateDnsHosts(pem) {
3543
- const certificate = new import_node_crypto.X509Certificate(pem);
3544
- const subjectAltName = certificate.subjectAltName ?? "";
3545
- if (subjectAltName.length === 0) {
3546
- return [];
3686
+ function findStaleProcessRoutes(routes = listHostRouteState()) {
3687
+ return routes.filter((route) => isStaleProcessRoute(route));
3688
+ }
3689
+ function evictStaleRouteIfNeeded(route) {
3690
+ if (!isStaleProcessRoute(route)) {
3691
+ return "not-stale";
3547
3692
  }
3548
- return parseDnsHostsFromSubjectAltName(subjectAltName);
3693
+ const removed = removeHostRoutesWhere(
3694
+ (candidate) => candidate.id === route.id && isStaleProcessRoute(candidate)
3695
+ );
3696
+ return removed.length > 0 ? "evicted" : "changed";
3549
3697
  }
3550
- function isHostCoveredByCertificateHost(host, certificateHost) {
3551
- const normalizedHost = normalizeHost(host);
3552
- const normalizedCertificateHost = normalizeHost(certificateHost);
3553
- if (normalizedCertificateHost.startsWith("*.")) {
3554
- const suffix = normalizedCertificateHost.slice(1);
3555
- if (!normalizedHost.endsWith(suffix)) {
3556
- return false;
3698
+ function reconcileRouteRunConflict(repoPath, app) {
3699
+ for (; ; ) {
3700
+ const routes = listHostRouteState();
3701
+ let shouldRetry = false;
3702
+ for (const route of routes) {
3703
+ if (route.name === app.name && sameRoutePath(route.repoPath, repoPath)) {
3704
+ const staleEviction = evictStaleRouteIfNeeded(route);
3705
+ if (staleEviction === "evicted") {
3706
+ continue;
3707
+ }
3708
+ if (staleEviction === "changed") {
3709
+ shouldRetry = true;
3710
+ break;
3711
+ }
3712
+ return { kind: "same-app", route };
3713
+ }
3714
+ if (route.host === app.host) {
3715
+ const staleEviction = evictStaleRouteIfNeeded(route);
3716
+ if (staleEviction === "evicted") {
3717
+ continue;
3718
+ }
3719
+ if (staleEviction === "changed") {
3720
+ shouldRetry = true;
3721
+ break;
3722
+ }
3723
+ return { kind: "hostname", route };
3724
+ }
3725
+ }
3726
+ if (!shouldRetry) {
3727
+ return void 0;
3557
3728
  }
3558
- const wildcardPart = normalizedHost.slice(0, normalizedHost.length - suffix.length);
3559
- return wildcardPart.length > 0 && !wildcardPart.includes(".");
3560
3729
  }
3561
- return normalizedHost === normalizedCertificateHost;
3562
3730
  }
3563
- function findUncoveredCertificateHosts(requiredHosts, certificateHosts) {
3564
- const normalizedRequired = normalizeUniqueHosts(requiredHosts);
3565
- const normalizedCertificateHosts = normalizeUniqueHosts(certificateHosts);
3566
- return normalizedRequired.filter(
3567
- (requiredHost) => !normalizedCertificateHosts.some(
3568
- (certificateHost) => isHostCoveredByCertificateHost(requiredHost, certificateHost)
3569
- )
3570
- );
3731
+ function isOrphanedWorkspaceProxyRoute(route) {
3732
+ return route.mode === "proxy" && route.workspace !== void 0 && !import_node_fs9.default.existsSync(route.repoPath);
3571
3733
  }
3572
- function readCurrentCertificateHosts() {
3573
- if (!import_node_fs9.default.existsSync(CERT_FILE)) {
3574
- return [];
3734
+ function findOrphanedWorkspaceProxyRoutes(routes = listHostRouteState()) {
3735
+ return routes.filter((route) => isOrphanedWorkspaceProxyRoute(route));
3736
+ }
3737
+ var import_node_fs9, import_node_path8;
3738
+ var init_route_state = __esm({
3739
+ "src/core/route-state.ts"() {
3740
+ "use strict";
3741
+ import_node_fs9 = __toESM(require("fs"));
3742
+ import_node_path8 = __toESM(require("path"));
3743
+ init_host_routes();
3575
3744
  }
3576
- const pem = import_node_fs9.default.readFileSync(CERT_FILE, "utf-8");
3577
- return parseCertificateDnsHosts(pem);
3745
+ });
3746
+
3747
+ // src/core/tool-diagnostics.ts
3748
+ function outputFromResult(result) {
3749
+ const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
3750
+ const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
3751
+ const output3 = [stdout, stderr].filter(Boolean).join("\n").trim();
3752
+ return output3.length > 0 ? output3 : void 0;
3578
3753
  }
3579
- function currentCertificateHostsOrEmpty() {
3580
- try {
3581
- return readCurrentCertificateHosts();
3582
- } catch {
3583
- return [];
3754
+ function runTool(command, args = []) {
3755
+ const result = (0, import_node_child_process5.spawnSync)(command, args, {
3756
+ encoding: "utf-8"
3757
+ });
3758
+ if (result.error) {
3759
+ return {
3760
+ ok: false,
3761
+ error: result.error.message
3762
+ };
3763
+ }
3764
+ const output3 = outputFromResult(result);
3765
+ if (result.status === 0) {
3766
+ return { ok: true, output: output3 };
3584
3767
  }
3768
+ return {
3769
+ ok: false,
3770
+ output: output3,
3771
+ error: output3 ?? `${command} ${args.join(" ")} exited with status ${result.status ?? "unknown"}`
3772
+ };
3585
3773
  }
3586
- function buildDesiredTLSCertificateHosts(requestedHosts, existingCertificateHosts) {
3587
- return normalizeUniqueHosts([
3588
- ...DEFAULT_TLS_CERT_HOSTS,
3589
- ...existingCertificateHosts,
3590
- ...requestedHosts
3591
- ]);
3774
+ function firstLine(value) {
3775
+ return value?.split(/\r?\n/).find((line) => line.trim().length > 0)?.trim();
3592
3776
  }
3593
- function getTLSHostCoverage(hosts) {
3594
- const requiredHosts = normalizeUniqueHosts([...DEFAULT_TLS_CERT_HOSTS, ...hosts]);
3595
- const certificateHosts = readCurrentCertificateHosts();
3596
- const uncoveredHosts = findUncoveredCertificateHosts(requiredHosts, certificateHosts);
3777
+ function parsePackageManager(value) {
3778
+ if (typeof value !== "string") {
3779
+ return void 0;
3780
+ }
3781
+ const trimmed = value.trim();
3782
+ if (trimmed.length === 0) {
3783
+ return void 0;
3784
+ }
3785
+ const separator = trimmed.lastIndexOf("@");
3786
+ if (separator <= 0) {
3787
+ return { name: trimmed };
3788
+ }
3597
3789
  return {
3598
- requiredHosts,
3599
- certificateHosts,
3600
- uncoveredHosts
3790
+ name: trimmed.slice(0, separator),
3791
+ version: trimmed.slice(separator + 1)
3601
3792
  };
3602
3793
  }
3603
- async function installTLS(options = {}) {
3604
- ensureRouterFiles();
3605
- const alreadyEnabled = isTLSEnabled();
3606
- const desiredHosts = buildDesiredTLSCertificateHosts(
3607
- options.hosts ?? [],
3608
- currentCertificateHostsOrEmpty()
3609
- );
3610
- ensureMkcert();
3611
- runOrThrow("mkcert", ["-install"]);
3612
- runOrThrow("mkcert", [
3613
- "-cert-file",
3614
- CERT_FILE,
3615
- "-key-file",
3616
- CERT_KEY_FILE,
3617
- ...desiredHosts
3618
- ]);
3619
- setTLSEnabled(true);
3620
- refreshHostRoutesDynamicFile();
3621
- const routerContainer = await findContainerByName("devrouter-traefik");
3622
- if (routerContainer && await isContainerRunning("devrouter-traefik")) {
3623
- startRouterStack();
3794
+ function parseMajor(value) {
3795
+ if (!value) {
3796
+ return void 0;
3624
3797
  }
3625
- return { alreadyEnabled, hosts: desiredHosts };
3798
+ const match = value.match(/(\d+)/);
3799
+ return match ? Number(match[1]) : void 0;
3626
3800
  }
3627
- async function ensureTLSHostsCovered(hosts) {
3628
- if (!isTLSEnabled()) {
3801
+ function parseMinimumNodeMajor(value) {
3802
+ if (typeof value !== "string") {
3803
+ return void 0;
3804
+ }
3805
+ const match = value.match(/>=\s*(\d+)/);
3806
+ if (!match) {
3807
+ return void 0;
3808
+ }
3809
+ return Number(match[1]);
3810
+ }
3811
+ function readPackageJson(repoPath) {
3812
+ const packagePath = import_node_path9.default.join(repoPath, "package.json");
3813
+ if (!import_node_fs10.default.existsSync(packagePath)) {
3814
+ return void 0;
3815
+ }
3816
+ try {
3817
+ return JSON.parse(import_node_fs10.default.readFileSync(packagePath, "utf-8"));
3818
+ } catch {
3819
+ return void 0;
3820
+ }
3821
+ }
3822
+ function nodeToolchainCheck(repoPath) {
3823
+ const pkg = readPackageJson(repoPath);
3824
+ if (!pkg) {
3629
3825
  return {
3630
- refreshed: false,
3631
- uncoveredHosts: [],
3632
- certificateHosts: []
3826
+ id: "global.node-toolchain",
3827
+ level: "ok",
3828
+ summary: "No package.json found; Node toolchain check is not applicable."
3633
3829
  };
3634
3830
  }
3635
- const coverage = getTLSHostCoverage(hosts);
3636
- if (coverage.uncoveredHosts.length === 0) {
3831
+ const engines = typeof pkg.engines === "object" && pkg.engines ? pkg.engines : {};
3832
+ const volta = typeof pkg.volta === "object" && pkg.volta ? pkg.volta : {};
3833
+ const nodeRequirement = typeof volta.node === "string" ? volta.node : engines.node;
3834
+ const minimumNodeMajor = parseMinimumNodeMajor(nodeRequirement) ?? parseMajor(String(nodeRequirement ?? ""));
3835
+ const currentNodeMajor = parseMajor(process.versions.node);
3836
+ const packageManager = parsePackageManager(pkg.packageManager);
3837
+ const details = [`node=${process.versions.node}`];
3838
+ const problems = [];
3839
+ if (minimumNodeMajor !== void 0) {
3840
+ details.push(`expectedNode=${String(nodeRequirement)}`);
3841
+ if (currentNodeMajor !== void 0 && currentNodeMajor < minimumNodeMajor) {
3842
+ problems.push(`Node ${process.versions.node} is older than ${String(nodeRequirement)}`);
3843
+ }
3844
+ }
3845
+ if (packageManager?.name === "pnpm") {
3846
+ const pnpm = runTool("pnpm", ["--version"]);
3847
+ if (!pnpm.ok) {
3848
+ problems.push(`pnpm is missing (${pnpm.error ?? "not found"})`);
3849
+ } else {
3850
+ const actualPnpm = firstLine(pnpm.output) ?? "unknown";
3851
+ details.push(`pnpm=${actualPnpm}`);
3852
+ if (packageManager.version) {
3853
+ details.push(`expectedPnpm=${packageManager.version}`);
3854
+ const expectedMajor = parseMajor(packageManager.version);
3855
+ const actualMajor = parseMajor(actualPnpm);
3856
+ if (expectedMajor !== void 0 && actualMajor !== void 0 && expectedMajor !== actualMajor) {
3857
+ problems.push(`pnpm major ${actualMajor} does not match expected ${expectedMajor}`);
3858
+ }
3859
+ }
3860
+ }
3861
+ } else if (packageManager) {
3862
+ details.push(`packageManager=${packageManager.name}${packageManager.version ? `@${packageManager.version}` : ""}`);
3863
+ }
3864
+ if (problems.length > 0) {
3637
3865
  return {
3638
- refreshed: false,
3639
- uncoveredHosts: [],
3640
- certificateHosts: coverage.certificateHosts
3866
+ id: "global.node-toolchain",
3867
+ level: "warn",
3868
+ summary: "Node package toolchain may not match this repo.",
3869
+ details: [...details, ...problems].join(", "),
3870
+ suggestion: packageManager?.name === "pnpm" && packageManager.version ? `Install pnpm ${packageManager.version}: npm install -g pnpm@${packageManager.version}` : "Install the Node/package-manager versions declared by this repo."
3871
+ };
3872
+ }
3873
+ return {
3874
+ id: "global.node-toolchain",
3875
+ level: "ok",
3876
+ summary: "Node package toolchain is available for this repo.",
3877
+ details: details.join(", ")
3878
+ };
3879
+ }
3880
+ function buildGlobalToolChecks(repoPath) {
3881
+ const checks = [];
3882
+ const compose = runTool("docker", ["compose", "version"]);
3883
+ checks.push({
3884
+ id: "global.docker-compose",
3885
+ level: compose.ok ? "ok" : "error",
3886
+ summary: compose.ok ? "Docker Compose v2 is reachable." : "Docker Compose v2 is not reachable.",
3887
+ details: firstLine(compose.output) ?? compose.error,
3888
+ suggestion: compose.ok ? void 0 : "Install/start Docker with Compose v2, then run: dev setup --yes"
3889
+ });
3890
+ const mkcert = runTool("mkcert", ["-version"]);
3891
+ const brew = runTool("brew", ["--version"]);
3892
+ checks.push({
3893
+ id: "global.mkcert",
3894
+ level: mkcert.ok ? "ok" : "warn",
3895
+ summary: mkcert.ok ? "mkcert is installed." : "mkcert is not installed.",
3896
+ details: mkcert.ok ? firstLine(mkcert.output) : mkcert.error,
3897
+ suggestion: mkcert.ok ? void 0 : brew.ok ? "Install mkcert: brew install mkcert" : "Install mkcert for local HTTPS, then run: dev setup --yes"
3898
+ });
3899
+ const devpod = runTool("devpod", ["version"]);
3900
+ checks.push({
3901
+ id: "global.devpod",
3902
+ level: devpod.ok ? "ok" : "warn",
3903
+ summary: devpod.ok ? "DevPod is installed." : "DevPod is not installed.",
3904
+ details: firstLine(devpod.output) ?? devpod.error,
3905
+ suggestion: devpod.ok ? void 0 : "Install DevPod for devcontainer workspace flows: brew install devpod"
3906
+ });
3907
+ checks.push(nodeToolchainCheck(repoPath));
3908
+ return checks;
3909
+ }
3910
+ var import_node_fs10, import_node_path9, import_node_child_process5;
3911
+ var init_tool_diagnostics = __esm({
3912
+ "src/core/tool-diagnostics.ts"() {
3913
+ "use strict";
3914
+ import_node_fs10 = __toESM(require("fs"));
3915
+ import_node_path9 = __toESM(require("path"));
3916
+ import_node_child_process5 = require("child_process");
3917
+ }
3918
+ });
3919
+
3920
+ // src/core/devcontainer-diagnostics.ts
3921
+ function asRecord(value) {
3922
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
3923
+ return void 0;
3924
+ }
3925
+ return value;
3926
+ }
3927
+ function asStringArray(value) {
3928
+ if (!Array.isArray(value)) {
3929
+ return [];
3930
+ }
3931
+ return value.filter((entry) => typeof entry === "string");
3932
+ }
3933
+ function normalizeWorkspaceToken(value) {
3934
+ return value.replace(/\$\{WORKSPACE(?::-[^}]+)?\}/g, "${WORKSPACE}");
3935
+ }
3936
+ function expandAliasCandidates(value, workspace) {
3937
+ const candidates = /* @__PURE__ */ new Set([normalizeWorkspaceToken(value)]);
3938
+ const defaulted = value.replace(/\$\{WORKSPACE:-([^}]+)\}/g, "$1");
3939
+ if (defaulted !== value) {
3940
+ candidates.add(defaulted);
3941
+ }
3942
+ if (workspace) {
3943
+ const workspaceValue = value.replace(/\$\{WORKSPACE:-[^}]+\}/g, workspace).replace(/\$\{WORKSPACE\}/g, workspace);
3944
+ candidates.add(workspaceValue);
3945
+ }
3946
+ return Array.from(candidates.values());
3947
+ }
3948
+ function formatPublishedPort(serviceName, value) {
3949
+ if (typeof value === "string") {
3950
+ return `${serviceName}: ${value}`;
3951
+ }
3952
+ const record = asRecord(value);
3953
+ if (!record) {
3954
+ return void 0;
3955
+ }
3956
+ const target = record.target;
3957
+ const published = record.published;
3958
+ if (published !== void 0 && target !== void 0) {
3959
+ return `${serviceName}: ${String(published)}:${String(target)}`;
3960
+ }
3961
+ if (target !== void 0) {
3962
+ return `${serviceName}: target ${String(target)}`;
3963
+ }
3964
+ return `${serviceName}: ${JSON.stringify(record)}`;
3965
+ }
3966
+ function upstreamHost(upstream) {
3967
+ const separator = upstream.lastIndexOf(":");
3968
+ return separator > 0 ? upstream.slice(0, separator) : upstream;
3969
+ }
3970
+ function isLoopbackHost(host) {
3971
+ return host === "127.0.0.1" || host === "localhost" || host === "host.docker.internal";
3972
+ }
3973
+ function inspectCompose(composeFile, workspace) {
3974
+ if (!import_node_fs11.default.existsSync(composeFile)) {
3975
+ return {
3976
+ aliases: [],
3977
+ publishedPorts: [],
3978
+ devnetExternal: false,
3979
+ parseError: `.devcontainer/docker-compose.yml is missing.`
3641
3980
  };
3642
3981
  }
3643
3982
  try {
3644
- const refreshed = await installTLS({ hosts: coverage.requiredHosts });
3983
+ const raw = import_node_fs11.default.readFileSync(composeFile, "utf-8");
3984
+ const parsed = import_yaml3.default.parse(raw);
3985
+ const root = asRecord(parsed);
3986
+ const services = asRecord(root?.services);
3987
+ const networks = asRecord(root?.networks);
3988
+ const devnet = asRecord(networks?.devnet);
3989
+ const external = devnet?.external;
3990
+ const devnetExternal = external === true || asRecord(external) !== void 0;
3991
+ const aliases = /* @__PURE__ */ new Set();
3992
+ const publishedPorts = [];
3993
+ for (const [serviceName, serviceValue] of Object.entries(services ?? {})) {
3994
+ const service = asRecord(serviceValue);
3995
+ if (!service) {
3996
+ continue;
3997
+ }
3998
+ if (Array.isArray(service.ports)) {
3999
+ for (const port of service.ports) {
4000
+ const formatted = formatPublishedPort(serviceName, port);
4001
+ if (formatted) {
4002
+ publishedPorts.push(formatted);
4003
+ }
4004
+ }
4005
+ }
4006
+ const networks2 = service.networks;
4007
+ if (Array.isArray(networks2)) {
4008
+ continue;
4009
+ }
4010
+ const networkMap = asRecord(networks2);
4011
+ const devnet2 = asRecord(networkMap?.devnet);
4012
+ for (const alias of asStringArray(devnet2?.aliases)) {
4013
+ aliases.add(alias);
4014
+ }
4015
+ }
3645
4016
  return {
3646
- refreshed: true,
3647
- uncoveredHosts: coverage.uncoveredHosts,
3648
- certificateHosts: refreshed.hosts
4017
+ aliases: Array.from(aliases.values()).sort(),
4018
+ publishedPorts,
4019
+ devnetExternal
3649
4020
  };
3650
4021
  } catch (error) {
3651
4022
  const message = error instanceof Error ? error.message : String(error);
3652
- throw new Error(
3653
- `TLS cert does not currently cover host(s): ${coverage.uncoveredHosts.join(", ")}. Automatic refresh failed: ${message}
3654
- Run: dev tls install`
3655
- );
4023
+ return {
4024
+ aliases: [],
4025
+ publishedPorts: [],
4026
+ devnetExternal: false,
4027
+ parseError: message
4028
+ };
3656
4029
  }
3657
4030
  }
3658
- var import_node_fs9, import_node_child_process5, import_node_crypto, DEFAULT_TLS_CERT_HOSTS;
3659
- var init_tls = __esm({
3660
- "src/core/tls.ts"() {
4031
+ function routedProxyApps(config) {
4032
+ if (!config) {
4033
+ return [];
4034
+ }
4035
+ return config.apps.filter(
4036
+ (app) => app.kind !== "dependency" && app.runtime === "proxy"
4037
+ );
4038
+ }
4039
+ function buildDevcontainerChecks(repoPath, config, workspace) {
4040
+ const devcontainerDir = import_node_path10.default.join(repoPath, ".devcontainer");
4041
+ if (!import_node_fs11.default.existsSync(devcontainerDir)) {
4042
+ return [];
4043
+ }
4044
+ const compose = inspectCompose(import_node_path10.default.join(devcontainerDir, "docker-compose.yml"), workspace);
4045
+ const checks = [];
4046
+ if (compose.parseError) {
4047
+ checks.push({
4048
+ id: "repo.devcontainer.aliases",
4049
+ level: "warn",
4050
+ summary: "Could not inspect devcontainer devnet aliases.",
4051
+ details: compose.parseError,
4052
+ suggestion: "Add .devcontainer/docker-compose.yml services on the external devnet network with aliases."
4053
+ });
4054
+ } else {
4055
+ const aliasesReady = compose.aliases.length > 0 && compose.devnetExternal;
4056
+ checks.push({
4057
+ id: "repo.devcontainer.aliases",
4058
+ level: aliasesReady ? "ok" : "warn",
4059
+ summary: aliasesReady ? `Found ${compose.aliases.length} devnet alias(es) on the external devnet network.` : compose.aliases.length > 0 ? "Devcontainer aliases exist, but top-level devnet is not marked external." : "No devnet aliases found in the devcontainer compose file.",
4060
+ details: compose.aliases.length > 0 ? `aliases=${compose.aliases.join(", ")}, devnetExternal=${String(compose.devnetExternal)}` : void 0,
4061
+ suggestion: aliasesReady ? void 0 : "Attach routable devcontainer services to an external devnet network and add stable aliases."
4062
+ });
4063
+ }
4064
+ checks.push({
4065
+ id: "repo.devcontainer.no-published-ports",
4066
+ level: compose.parseError ? "warn" : compose.publishedPorts.length === 0 ? "ok" : "error",
4067
+ summary: compose.parseError ? "Could not inspect devcontainer compose file for published host ports." : compose.publishedPorts.length === 0 ? "Devcontainer compose file does not publish host ports." : `Devcontainer compose file publishes ${compose.publishedPorts.length} host port(s).`,
4068
+ details: compose.publishedPorts.length > 0 ? compose.publishedPorts.join(", ") : void 0,
4069
+ suggestion: compose.parseError || compose.publishedPorts.length > 0 ? "Remove published ports and route services through devnet aliases with devrouter proxy apps." : void 0
4070
+ });
4071
+ if (!config) {
4072
+ checks.push({
4073
+ id: "repo.devcontainer.upstream-alias-match",
4074
+ level: "warn",
4075
+ summary: "Cannot compare devcontainer aliases to .devrouter.yml upstreams.",
4076
+ suggestion: "Add or fix .devrouter.yml proxy entries for the devcontainer services."
4077
+ });
4078
+ return checks;
4079
+ }
4080
+ const aliasSet = new Set(
4081
+ compose.aliases.flatMap((alias) => expandAliasCandidates(alias, workspace)).map(normalizeWorkspaceToken)
4082
+ );
4083
+ const proxyApps2 = routedProxyApps(config);
4084
+ const nonLoopbackUpstreams = proxyApps2.map((app) => ({
4085
+ app: app.name,
4086
+ host: normalizeWorkspaceToken(upstreamHost(app.upstream))
4087
+ })).filter((entry) => !isLoopbackHost(entry.host));
4088
+ const missing = nonLoopbackUpstreams.filter((entry) => !aliasSet.has(entry.host));
4089
+ checks.push({
4090
+ id: "repo.devcontainer.upstream-alias-match",
4091
+ level: proxyApps2.length > 0 && missing.length === 0 ? "ok" : "warn",
4092
+ summary: proxyApps2.length === 0 ? "No devrouter proxy apps found for the devcontainer." : missing.length === 0 ? "Devrouter proxy upstreams match devcontainer devnet aliases." : `${missing.length} devrouter proxy upstream(s) do not match devcontainer aliases.`,
4093
+ details: missing.length > 0 ? missing.map((entry) => `${entry.app}: ${entry.host}`).join(", ") : void 0,
4094
+ suggestion: proxyApps2.length === 0 || missing.length > 0 ? "Align .devrouter.yml proxy upstream hosts with .devcontainer/docker-compose.yml devnet aliases." : void 0
4095
+ });
4096
+ return checks;
4097
+ }
4098
+ var import_node_fs11, import_node_path10, import_yaml3;
4099
+ var init_devcontainer_diagnostics = __esm({
4100
+ "src/core/devcontainer-diagnostics.ts"() {
3661
4101
  "use strict";
3662
- import_node_fs9 = __toESM(require("fs"));
3663
- import_node_child_process5 = require("child_process");
3664
- import_node_crypto = require("crypto");
3665
- init_router();
3666
- init_docker();
3667
- init_host_routes();
3668
- DEFAULT_TLS_CERT_HOSTS = ["localhost", "*.localhost"];
4102
+ import_node_fs11 = __toESM(require("fs"));
4103
+ import_node_path10 = __toESM(require("path"));
4104
+ import_yaml3 = __toESM(require("yaml"));
3669
4105
  }
3670
4106
  });
3671
4107
 
3672
4108
  // src/core/doctor.ts
3673
- function asRecord(value) {
4109
+ function asRecord2(value) {
3674
4110
  if (!value || typeof value !== "object" || Array.isArray(value)) {
3675
4111
  return void 0;
3676
4112
  }
@@ -3695,7 +4131,7 @@ function parseComposeEnvironment(value) {
3695
4131
  }
3696
4132
  return env;
3697
4133
  }
3698
- const objectValue = asRecord(value);
4134
+ const objectValue = asRecord2(value);
3699
4135
  if (!objectValue) {
3700
4136
  return env;
3701
4137
  }
@@ -3728,15 +4164,15 @@ function inspectPostgresCredentials(repoPath, config) {
3728
4164
  parseErrors.push(`${app.name}: ${composeFile} (${message})`);
3729
4165
  continue;
3730
4166
  }
3731
- if (!import_node_fs10.default.existsSync(absolutePath)) {
4167
+ if (!import_node_fs12.default.existsSync(absolutePath)) {
3732
4168
  continue;
3733
4169
  }
3734
4170
  try {
3735
- const raw = import_node_fs10.default.readFileSync(absolutePath, "utf-8");
3736
- const parsed = import_yaml3.default.parse(raw);
3737
- const root = asRecord(parsed);
3738
- const services = asRecord(root?.services);
3739
- const service = asRecord(services?.[app.docker.service]);
4171
+ const raw = import_node_fs12.default.readFileSync(absolutePath, "utf-8");
4172
+ const parsed = import_yaml4.default.parse(raw);
4173
+ const root = asRecord2(parsed);
4174
+ const services = asRecord2(root?.services);
4175
+ const service = asRecord2(services?.[app.docker.service]);
3740
4176
  if (!service) {
3741
4177
  continue;
3742
4178
  }
@@ -3835,17 +4271,6 @@ function inspectHostCommandPrecedence(config) {
3835
4271
  }
3836
4272
  return { inspectedCount, riskyApps };
3837
4273
  }
3838
- function isPidRunning2(pid) {
3839
- if (!pid || pid <= 0) {
3840
- return false;
3841
- }
3842
- try {
3843
- process.kill(pid, 0);
3844
- return true;
3845
- } catch {
3846
- return false;
3847
- }
3848
- }
3849
4274
  function addCheck(checks, check) {
3850
4275
  checks.push(check);
3851
4276
  }
@@ -3878,6 +4303,8 @@ async function buildDoctorReport(options = {}) {
3878
4303
  const fileLayout = getRouterFileLayout();
3879
4304
  const resolvedRepoPath = resolveRepoPath(options.repo);
3880
4305
  const explicitRepo = typeof options.repo === "string" && options.repo.trim().length > 0;
4306
+ let loadedConfig;
4307
+ let loadedWorkspace;
3881
4308
  if (fileLayout.missing.length === 0) {
3882
4309
  addCheck(checks, {
3883
4310
  id: "global.router-files",
@@ -3893,6 +4320,9 @@ async function buildDoctorReport(options = {}) {
3893
4320
  suggestion: "Run: dev up"
3894
4321
  });
3895
4322
  }
4323
+ for (const check of buildGlobalToolChecks(resolvedRepoPath)) {
4324
+ addCheck(checks, check);
4325
+ }
3896
4326
  let statusNextSteps = [];
3897
4327
  try {
3898
4328
  const status = await collectRouterStatus(options.repo);
@@ -3995,7 +4425,10 @@ async function buildDoctorReport(options = {}) {
3995
4425
  suggestion: "Run: dev app add --name <name> --host <name>.localhost --protocol http --runtime host"
3996
4426
  });
3997
4427
  }
3998
- const config = loadRuntimeConfig(repo.path).config;
4428
+ const runtimeConfig = loadRuntimeConfig(repo.path);
4429
+ const config = runtimeConfig.config;
4430
+ loadedConfig = config;
4431
+ loadedWorkspace = runtimeConfig.workspace;
3999
4432
  const appNames = new Set(config.apps.map((app) => app.name));
4000
4433
  const missingDependencies = config.apps.flatMap(
4001
4434
  (app) => app.dependencies.filter((dependency) => !appNames.has(dependency.app)).map((dependency) => `${app.name}->${dependency.app}`)
@@ -4009,8 +4442,8 @@ async function buildDoctorReport(options = {}) {
4009
4442
  const missingComposeFiles = config.apps.filter((app) => app.runtime === "docker").flatMap((app) => app.docker.composeFiles.map((filePath) => ({
4010
4443
  app: app.name,
4011
4444
  filePath,
4012
- absolutePath: import_node_path8.default.resolve(repo.path, filePath)
4013
- }))).filter((entry) => !import_node_fs10.default.existsSync(entry.absolutePath));
4445
+ absolutePath: import_node_path11.default.resolve(repo.path, filePath)
4446
+ }))).filter((entry) => !import_node_fs12.default.existsSync(entry.absolutePath));
4014
4447
  addCheck(checks, {
4015
4448
  id: "repo.compose-files",
4016
4449
  level: missingComposeFiles.length === 0 ? "ok" : "error",
@@ -4053,8 +4486,8 @@ async function buildDoctorReport(options = {}) {
4053
4486
  const missingHostCwds = config.apps.filter((app) => app.runtime === "host").map((app) => ({
4054
4487
  app: app.name,
4055
4488
  cwd: app.hostRun.cwd,
4056
- absolutePath: import_node_path8.default.resolve(repo.path, app.hostRun.cwd)
4057
- })).filter((entry) => !import_node_fs10.default.existsSync(entry.absolutePath));
4489
+ absolutePath: import_node_path11.default.resolve(repo.path, app.hostRun.cwd)
4490
+ })).filter((entry) => !import_node_fs12.default.existsSync(entry.absolutePath));
4058
4491
  addCheck(checks, {
4059
4492
  id: "repo.host-cwd",
4060
4493
  level: missingHostCwds.length === 0 ? "ok" : "error",
@@ -4123,6 +4556,9 @@ async function buildDoctorReport(options = {}) {
4123
4556
  details: message
4124
4557
  });
4125
4558
  }
4559
+ for (const check of buildDevcontainerChecks(resolvedRepoPath, loadedConfig, loadedWorkspace)) {
4560
+ addCheck(checks, check);
4561
+ }
4126
4562
  try {
4127
4563
  const tlsEnabled = isTLSEnabled();
4128
4564
  const containers = await listContainers(true);
@@ -4144,29 +4580,20 @@ async function buildDoctorReport(options = {}) {
4144
4580
  details: message
4145
4581
  });
4146
4582
  }
4147
- const staleHostRoutes = listHostRouteState().filter(
4148
- (route) => (
4149
- // Proxy routes have no backing process; they are never "stale".
4150
- route.mode === "proxy" ? false : route.pid ? !isPidRunning2(route.pid) : true
4151
- )
4152
- );
4153
- addCheck(checks, {
4154
- id: "routes.host-state",
4155
- level: staleHostRoutes.length === 0 ? "ok" : "warn",
4156
- summary: staleHostRoutes.length === 0 ? "Host route state contains only running process entries." : `${staleHostRoutes.length} host route entr${staleHostRoutes.length === 1 ? "y is" : "ies are"} stale (process not running).`,
4157
- suggestion: staleHostRoutes.length === 0 ? void 0 : "Re-run the affected host app(s): dev app run <name> --repo <path>"
4158
- });
4159
- const evictedCount = evictStaleHostRoutes();
4583
+ const staleHostRoutes = findStaleProcessRoutes();
4584
+ const staleCount = staleHostRoutes.length;
4160
4585
  addCheck(checks, {
4161
4586
  id: "routes.stale-host-routes",
4162
- level: evictedCount === 0 ? "ok" : "warn",
4163
- summary: evictedCount === 0 ? "No stale host route entries to clean up." : `Evicted ${evictedCount} stale host route entr${evictedCount === 1 ? "y" : "ies"} (dead PID).`
4587
+ level: staleCount === 0 ? "ok" : "warn",
4588
+ summary: staleCount === 0 ? "No stale host route entries detected." : `${staleCount} stale host route entr${staleCount === 1 ? "y" : "ies"} detected (dead PID).`,
4589
+ suggestion: staleCount === 0 ? void 0 : "Re-run the affected host app(s) or remove routes with: dev app rm <name> --repo <path> --keep-config"
4164
4590
  });
4165
- const evictedOrphanCount = evictOrphanedWorkspaceRoutes();
4591
+ const orphanedWorkspaceRoutes = findOrphanedWorkspaceProxyRoutes();
4166
4592
  addCheck(checks, {
4167
4593
  id: "routes.orphaned-workspace-routes",
4168
- level: evictedOrphanCount === 0 ? "ok" : "warn",
4169
- summary: evictedOrphanCount === 0 ? "No orphaned workspace proxy routes to clean up." : `Evicted ${evictedOrphanCount} orphaned workspace proxy route entr${evictedOrphanCount === 1 ? "y" : "ies"} (worktree removed without 'dev workspace down').`
4594
+ level: orphanedWorkspaceRoutes.length === 0 ? "ok" : "warn",
4595
+ summary: orphanedWorkspaceRoutes.length === 0 ? "No orphaned workspace proxy routes detected." : `${orphanedWorkspaceRoutes.length} orphaned workspace proxy route entr${orphanedWorkspaceRoutes.length === 1 ? "y" : "ies"} detected (worktree removed without 'dev workspace down').`,
4596
+ suggestion: orphanedWorkspaceRoutes.length === 0 ? void 0 : "Run: dev workspace down <workspace>"
4170
4597
  });
4171
4598
  const summary = collectSummary(checks);
4172
4599
  const nextSteps = collectNextSteps(checks, statusNextSteps);
@@ -4178,22 +4605,24 @@ async function buildDoctorReport(options = {}) {
4178
4605
  nextSteps
4179
4606
  };
4180
4607
  }
4181
- var import_node_fs10, import_node_path8, import_yaml3, POSTGRES_DEFAULTS;
4608
+ var import_node_fs12, import_node_path11, import_yaml4, POSTGRES_DEFAULTS;
4182
4609
  var init_doctor = __esm({
4183
4610
  "src/core/doctor.ts"() {
4184
4611
  "use strict";
4185
- import_node_fs10 = __toESM(require("fs"));
4186
- import_node_path8 = __toESM(require("path"));
4187
- import_yaml3 = __toESM(require("yaml"));
4612
+ import_node_fs12 = __toESM(require("fs"));
4613
+ import_node_path11 = __toESM(require("path"));
4614
+ import_yaml4 = __toESM(require("yaml"));
4188
4615
  init_docker();
4189
4616
  init_host_routes();
4190
- init_concurrency();
4191
4617
  init_repo_config();
4192
4618
  init_router();
4193
4619
  init_status();
4194
4620
  init_routes();
4195
4621
  init_paths();
4196
4622
  init_tls();
4623
+ init_route_state();
4624
+ init_tool_diagnostics();
4625
+ init_devcontainer_diagnostics();
4197
4626
  POSTGRES_DEFAULTS = {
4198
4627
  POSTGRES_USER: "prisma",
4199
4628
  POSTGRES_PASSWORD: "prisma",
@@ -4202,6 +4631,305 @@ var init_doctor = __esm({
4202
4631
  }
4203
4632
  });
4204
4633
 
4634
+ // src/core/setup.ts
4635
+ function action(status, entry) {
4636
+ return { status, ...entry };
4637
+ }
4638
+ function actionStatusCounts(actions) {
4639
+ return actions.reduce(
4640
+ (acc, entry) => {
4641
+ acc[entry.status] += 1;
4642
+ return acc;
4643
+ },
4644
+ { performed: 0, skipped: 0, failed: 0 }
4645
+ );
4646
+ }
4647
+ function collectNextSteps2(report, doctorNextSteps) {
4648
+ const steps = /* @__PURE__ */ new Set();
4649
+ for (const entry of report.actions) {
4650
+ if (entry.status === "failed" || entry.status === "skipped") {
4651
+ if (entry.suggestion) {
4652
+ steps.add(entry.suggestion);
4653
+ }
4654
+ }
4655
+ }
4656
+ for (const check of report.checks) {
4657
+ if (check.level !== "ok" && check.suggestion) {
4658
+ steps.add(check.suggestion);
4659
+ }
4660
+ }
4661
+ for (const step of doctorNextSteps) {
4662
+ steps.add(step);
4663
+ }
4664
+ return Array.from(steps.values());
4665
+ }
4666
+ async function runSetup(options = {}) {
4667
+ const repoPath = resolveRepoPath(options.repo);
4668
+ const actions = [];
4669
+ if (!options.yes) {
4670
+ actions.push(action("failed", {
4671
+ id: "setup.confirmation",
4672
+ summary: "Setup requires --yes before mutating devrouter-owned machine state.",
4673
+ suggestion: "Run: dev setup --yes"
4674
+ }));
4675
+ const doctor2 = await buildDoctorReport({ repo: options.repo });
4676
+ const partialReport2 = { actions, checks: doctor2.checks };
4677
+ return {
4678
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4679
+ repoPath,
4680
+ actions,
4681
+ checks: doctor2.checks,
4682
+ summary: {
4683
+ actions: actionStatusCounts(actions),
4684
+ checks: doctor2.summary
4685
+ },
4686
+ nextSteps: collectNextSteps2(partialReport2, doctor2.nextSteps)
4687
+ };
4688
+ }
4689
+ try {
4690
+ const missingBefore = getRouterFileLayout().missing;
4691
+ ensureRouterFiles();
4692
+ actions.push(action(missingBefore.length === 0 ? "skipped" : "performed", {
4693
+ id: "global.router-files",
4694
+ summary: missingBefore.length === 0 ? "Global router files were already present." : `Created missing global router file(s): ${missingBefore.join(", ")}`
4695
+ }));
4696
+ } catch (error) {
4697
+ const message = error instanceof Error ? error.message : String(error);
4698
+ actions.push(action("failed", {
4699
+ id: "global.router-files",
4700
+ summary: "Failed to ensure global router files.",
4701
+ details: message,
4702
+ suggestion: "Check write access to ~/.config/devrouter, then run: dev setup --yes"
4703
+ }));
4704
+ }
4705
+ try {
4706
+ const existed = await networkExists(DEVNET_NAME);
4707
+ await ensureNetwork(DEVNET_NAME);
4708
+ actions.push(action(existed ? "skipped" : "performed", {
4709
+ id: "global.devnet",
4710
+ summary: existed ? "Shared Docker network devnet already exists." : "Created shared Docker network devnet."
4711
+ }));
4712
+ } catch (error) {
4713
+ const message = error instanceof Error ? error.message : String(error);
4714
+ actions.push(action("failed", {
4715
+ id: "global.devnet",
4716
+ summary: "Failed to ensure shared Docker network devnet.",
4717
+ details: message,
4718
+ suggestion: "Start Docker and verify Docker context, then run: dev setup --yes"
4719
+ }));
4720
+ }
4721
+ try {
4722
+ const wasRunning = await isContainerRunning(ROUTER_CONTAINER_NAME);
4723
+ startRouterStack();
4724
+ actions.push(action(wasRunning ? "skipped" : "performed", {
4725
+ id: "global.router-stack",
4726
+ summary: wasRunning ? "Shared Traefik router was already running." : "Started shared Traefik router."
4727
+ }));
4728
+ } catch (error) {
4729
+ const message = error instanceof Error ? error.message : String(error);
4730
+ actions.push(action("failed", {
4731
+ id: "global.router-stack",
4732
+ summary: "Failed to start shared Traefik router.",
4733
+ details: message,
4734
+ suggestion: "Resolve Docker/port conflicts on 80, 443, or 5432, then run: dev setup --yes"
4735
+ }));
4736
+ }
4737
+ const mkcert = runTool("mkcert", ["-version"]);
4738
+ if (!mkcert.ok) {
4739
+ actions.push(action("skipped", {
4740
+ id: "global.tls",
4741
+ summary: "Skipped TLS setup because mkcert is not installed.",
4742
+ details: mkcert.error,
4743
+ suggestion: "Install mkcert, then run: dev setup --yes"
4744
+ }));
4745
+ } else {
4746
+ try {
4747
+ const tls = await installTLS();
4748
+ actions.push(action(tls.alreadyEnabled ? "skipped" : "performed", {
4749
+ id: "global.tls",
4750
+ summary: tls.alreadyEnabled ? "TLS was already enabled; certificates were refreshed." : "Installed local TLS certificates and enabled TLS routing.",
4751
+ details: `hosts=${tls.hosts.join(", ")}`
4752
+ }));
4753
+ } catch (error) {
4754
+ const message = error instanceof Error ? error.message : String(error);
4755
+ actions.push(action("failed", {
4756
+ id: "global.tls",
4757
+ summary: "Failed to install local TLS certificates.",
4758
+ details: message,
4759
+ suggestion: "Run: dev tls install"
4760
+ }));
4761
+ }
4762
+ }
4763
+ const doctor = await buildDoctorReport({ repo: options.repo });
4764
+ const partialReport = { actions, checks: doctor.checks };
4765
+ return {
4766
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4767
+ repoPath,
4768
+ actions,
4769
+ checks: doctor.checks,
4770
+ summary: {
4771
+ actions: actionStatusCounts(actions),
4772
+ checks: doctor.summary
4773
+ },
4774
+ nextSteps: collectNextSteps2(partialReport, doctor.nextSteps)
4775
+ };
4776
+ }
4777
+ var init_setup = __esm({
4778
+ "src/core/setup.ts"() {
4779
+ "use strict";
4780
+ init_docker();
4781
+ init_router();
4782
+ init_tls();
4783
+ init_doctor();
4784
+ init_tool_diagnostics();
4785
+ init_repo_config();
4786
+ }
4787
+ });
4788
+
4789
+ // src/commands/setup.ts
4790
+ var setup_exports = {};
4791
+ __export(setup_exports, {
4792
+ runSetupCommand: () => runSetupCommand
4793
+ });
4794
+ async function runSetupCommand(options) {
4795
+ const report = await runSetup({ repo: options.repo, yes: Boolean(options.yes) });
4796
+ if (options.json) {
4797
+ printJSON(report);
4798
+ } else {
4799
+ printSetupReport(report);
4800
+ }
4801
+ if (report.summary.actions.failed > 0 || report.summary.checks.error > 0) {
4802
+ process.exitCode = 1;
4803
+ }
4804
+ }
4805
+ var init_setup2 = __esm({
4806
+ "src/commands/setup.ts"() {
4807
+ "use strict";
4808
+ init_setup();
4809
+ init_output();
4810
+ }
4811
+ });
4812
+
4813
+ // src/util/ports.ts
4814
+ function findPortListeners(port) {
4815
+ const result = (0, import_node_child_process6.spawnSync)("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], {
4816
+ encoding: "utf-8"
4817
+ });
4818
+ if (result.status !== 0 || !result.stdout.trim()) {
4819
+ return [];
4820
+ }
4821
+ const lines = result.stdout.trim().split(/\r?\n/);
4822
+ if (lines.length <= 1) {
4823
+ return [];
4824
+ }
4825
+ return lines.slice(1).map((line) => {
4826
+ const parts = line.trim().split(/\s+/);
4827
+ const command = parts[0] ?? "?";
4828
+ const pid = parts[1] ?? "?";
4829
+ const user = parts[2] ?? "?";
4830
+ const address = parts.slice(-2).join(" ") || "?";
4831
+ return {
4832
+ port,
4833
+ command,
4834
+ pid,
4835
+ user,
4836
+ address
4837
+ };
4838
+ });
4839
+ }
4840
+ var import_node_child_process6;
4841
+ var init_ports = __esm({
4842
+ "src/util/ports.ts"() {
4843
+ "use strict";
4844
+ import_node_child_process6 = require("child_process");
4845
+ }
4846
+ });
4847
+
4848
+ // src/commands/up.ts
4849
+ var up_exports = {};
4850
+ __export(up_exports, {
4851
+ runUpCommand: () => runUpCommand
4852
+ });
4853
+ function buildPortConflictMessage() {
4854
+ const listeners = [...findPortListeners(80), ...findPortListeners(443), ...findPortListeners(5432)];
4855
+ if (listeners.length === 0) {
4856
+ return "";
4857
+ }
4858
+ const details = listeners.map((listener) => {
4859
+ return `- port ${listener.port}: ${listener.command} (pid ${listener.pid}, user ${listener.user}, ${listener.address})`;
4860
+ }).join("\n");
4861
+ return `Cannot start devrouter because host ports 80/443/5432 are already in use:
4862
+ ${details}
4863
+
4864
+ Mitigation:
4865
+ 1) Stop the conflicting process/container
4866
+ 2) Re-run: dev up
4867
+
4868
+ Debug commands:
4869
+ - lsof -nP -iTCP:80 -sTCP:LISTEN
4870
+ - lsof -nP -iTCP:443 -sTCP:LISTEN
4871
+ - lsof -nP -iTCP:5432 -sTCP:LISTEN`;
4872
+ }
4873
+ async function runUpCommand() {
4874
+ await ensureNetwork(DEVNET_NAME);
4875
+ ensureRouterFiles();
4876
+ const routerRunning = await isContainerRunning(ROUTER_CONTAINER_NAME);
4877
+ if (!routerRunning) {
4878
+ const message = buildPortConflictMessage();
4879
+ if (message) {
4880
+ throw new Error(message);
4881
+ }
4882
+ }
4883
+ startRouterStack();
4884
+ process.stdout.write("devrouter is up.\n");
4885
+ }
4886
+ var init_up = __esm({
4887
+ "src/commands/up.ts"() {
4888
+ "use strict";
4889
+ init_docker();
4890
+ init_router();
4891
+ init_ports();
4892
+ }
4893
+ });
4894
+
4895
+ // src/commands/down.ts
4896
+ var down_exports = {};
4897
+ __export(down_exports, {
4898
+ runDownCommand: () => runDownCommand
4899
+ });
4900
+ async function runDownCommand() {
4901
+ stopRouterStack();
4902
+ clearActiveTcpProtocols();
4903
+ process.stdout.write("devrouter is down.\n");
4904
+ }
4905
+ var init_down = __esm({
4906
+ "src/commands/down.ts"() {
4907
+ "use strict";
4908
+ init_router();
4909
+ }
4910
+ });
4911
+
4912
+ // src/commands/status.ts
4913
+ var status_exports = {};
4914
+ __export(status_exports, {
4915
+ runStatusCommand: () => runStatusCommand
4916
+ });
4917
+ async function runStatusCommand(options) {
4918
+ const status = await collectRouterStatus(options.repo);
4919
+ if (options.json) {
4920
+ printJSON(status);
4921
+ return;
4922
+ }
4923
+ printStatus(status);
4924
+ }
4925
+ var init_status2 = __esm({
4926
+ "src/commands/status.ts"() {
4927
+ "use strict";
4928
+ init_output();
4929
+ init_status();
4930
+ }
4931
+ });
4932
+
4205
4933
  // src/commands/doctor.ts
4206
4934
  var doctor_exports = {};
4207
4935
  __export(doctor_exports, {
@@ -4211,9 +4939,12 @@ async function runDoctorCommand(options) {
4211
4939
  const report = await buildDoctorReport({ repo: options.repo });
4212
4940
  if (options.json) {
4213
4941
  printJSON(report);
4214
- return;
4942
+ } else {
4943
+ printDoctorReport(report);
4944
+ }
4945
+ if (report.summary.error > 0) {
4946
+ process.exitCode = 1;
4215
4947
  }
4216
- printDoctorReport(report);
4217
4948
  }
4218
4949
  var init_doctor2 = __esm({
4219
4950
  "src/commands/doctor.ts"() {
@@ -4302,7 +5033,7 @@ async function runOpenCommand(name) {
4302
5033
  );
4303
5034
  return;
4304
5035
  }
4305
- const result = (0, import_node_child_process6.spawnSync)("open", [url], { encoding: "utf-8" });
5036
+ const result = (0, import_node_child_process7.spawnSync)("open", [url], { encoding: "utf-8" });
4306
5037
  if (result.status !== 0) {
4307
5038
  const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
4308
5039
  throw new Error(`Unable to open '${url}': ${details || "unknown error"}`);
@@ -4310,11 +5041,11 @@ async function runOpenCommand(name) {
4310
5041
  process.stdout.write(`Opened ${url}
4311
5042
  `);
4312
5043
  }
4313
- var import_node_child_process6;
5044
+ var import_node_child_process7;
4314
5045
  var init_open = __esm({
4315
5046
  "src/commands/open.ts"() {
4316
5047
  "use strict";
4317
- import_node_child_process6 = require("child_process");
5048
+ import_node_child_process7 = require("child_process");
4318
5049
  init_docker();
4319
5050
  init_host_routes();
4320
5051
  init_repo_config();
@@ -4337,7 +5068,7 @@ async function runLogsCommand(options) {
4337
5068
  const args = ["logs", "--tail", tail, ROUTER_CONTAINER_NAME];
4338
5069
  if (options.follow) {
4339
5070
  args.splice(1, 0, "-f");
4340
- const child = (0, import_node_child_process7.spawn)("docker", args, { stdio: "inherit" });
5071
+ const child = (0, import_node_child_process8.spawn)("docker", args, { stdio: "inherit" });
4341
5072
  const onSignal = () => {
4342
5073
  child.kill("SIGTERM");
4343
5074
  };
@@ -4351,17 +5082,17 @@ async function runLogsCommand(options) {
4351
5082
  });
4352
5083
  });
4353
5084
  } else {
4354
- const result = (0, import_node_child_process7.spawnSync)("docker", args, { stdio: "inherit" });
5085
+ const result = (0, import_node_child_process8.spawnSync)("docker", args, { stdio: "inherit" });
4355
5086
  if (result.status !== 0) {
4356
5087
  throw new Error("Failed to retrieve router logs.");
4357
5088
  }
4358
5089
  }
4359
5090
  }
4360
- var import_node_child_process7;
5091
+ var import_node_child_process8;
4361
5092
  var init_logs = __esm({
4362
5093
  "src/commands/logs.ts"() {
4363
5094
  "use strict";
4364
- import_node_child_process7 = require("child_process");
5095
+ import_node_child_process8 = require("child_process");
4365
5096
  init_router();
4366
5097
  init_docker();
4367
5098
  }
@@ -4384,65 +5115,1327 @@ async function runRepoInitCommand(options) {
4384
5115
  `);
4385
5116
  process.stdout.write("Next: dev app add --name <name> --host <host.localhost> --protocol <http|tcp> --runtime <host|docker>\n");
4386
5117
  }
4387
- var init_repo_init = __esm({
4388
- "src/commands/repo-init.ts"() {
5118
+ var init_repo_init = __esm({
5119
+ "src/commands/repo-init.ts"() {
5120
+ "use strict";
5121
+ init_repo_config();
5122
+ }
5123
+ });
5124
+
5125
+ // src/core/repo-inspect.ts
5126
+ function asRecord3(value) {
5127
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
5128
+ return void 0;
5129
+ }
5130
+ return value;
5131
+ }
5132
+ function readJson(filePath) {
5133
+ if (!import_node_fs13.default.existsSync(filePath)) {
5134
+ return void 0;
5135
+ }
5136
+ try {
5137
+ return JSON.parse(import_node_fs13.default.readFileSync(filePath, "utf-8"));
5138
+ } catch {
5139
+ return void 0;
5140
+ }
5141
+ }
5142
+ function relative(repoPath, filePath) {
5143
+ return import_node_path12.default.relative(repoPath, filePath) || ".";
5144
+ }
5145
+ function redactEnvAssignments(value) {
5146
+ return value.replace(
5147
+ /(^|\s)([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|'[^']*'|[^\s]+)/g,
5148
+ "$1$2=<redacted>"
5149
+ );
5150
+ }
5151
+ function sanitizeDiagnosticText(value) {
5152
+ return redactEnvAssignments(value).replace(/\bvalue\s+'[^']+'/g, "value '<redacted>'").replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s'"]+/gi, "<redacted-url>");
5153
+ }
5154
+ function parsePackageManager2(value) {
5155
+ if (typeof value !== "string" || value.trim().length === 0) {
5156
+ return void 0;
5157
+ }
5158
+ const trimmed = value.trim();
5159
+ const separator = trimmed.lastIndexOf("@");
5160
+ if (separator <= 0) {
5161
+ return { name: trimmed };
5162
+ }
5163
+ return {
5164
+ name: trimmed.slice(0, separator),
5165
+ version: trimmed.slice(separator + 1)
5166
+ };
5167
+ }
5168
+ function inspectPackageManager(repoPath, pkg) {
5169
+ const packageManager = parsePackageManager2(pkg?.packageManager);
5170
+ if (packageManager) {
5171
+ return {
5172
+ ...packageManager,
5173
+ source: "package.json:packageManager"
5174
+ };
5175
+ }
5176
+ const lockfiles = [
5177
+ ["pnpm-lock.yaml", "pnpm"],
5178
+ ["package-lock.json", "npm"],
5179
+ ["yarn.lock", "yarn"],
5180
+ ["bun.lockb", "bun"],
5181
+ ["bun.lock", "bun"]
5182
+ ];
5183
+ for (const [fileName, name] of lockfiles) {
5184
+ if (import_node_fs13.default.existsSync(import_node_path12.default.join(repoPath, fileName))) {
5185
+ return { name, source: fileName };
5186
+ }
5187
+ }
5188
+ return void 0;
5189
+ }
5190
+ function inspectNode(repoPath, pkg) {
5191
+ const volta = asRecord3(pkg?.volta);
5192
+ if (typeof volta?.node === "string") {
5193
+ return { version: volta.node, source: "package.json:volta.node" };
5194
+ }
5195
+ const engines = asRecord3(pkg?.engines);
5196
+ if (typeof engines?.node === "string") {
5197
+ return { version: engines.node, source: "package.json:engines.node" };
5198
+ }
5199
+ const nvmrc = import_node_path12.default.join(repoPath, ".nvmrc");
5200
+ if (import_node_fs13.default.existsSync(nvmrc)) {
5201
+ const version = import_node_fs13.default.readFileSync(nvmrc, "utf-8").trim();
5202
+ return { version, source: ".nvmrc" };
5203
+ }
5204
+ return void 0;
5205
+ }
5206
+ function inspectScripts(pkg) {
5207
+ const scripts = asRecord3(pkg?.scripts);
5208
+ if (!scripts) {
5209
+ return [];
5210
+ }
5211
+ return Object.entries(scripts).filter((entry) => typeof entry[1] === "string").map(([name, command]) => ({
5212
+ name,
5213
+ command: redactEnvAssignments(command),
5214
+ evidence: [`package.json:scripts.${name}`]
5215
+ }));
5216
+ }
5217
+ function inferPort(command) {
5218
+ const patterns = [
5219
+ /\bPORT=(\d{2,5})\b/,
5220
+ /--port[=\s]+(\d{2,5})\b/,
5221
+ /(?:^|\s)-p\s+(\d{2,5})\b/
5222
+ ];
5223
+ for (const pattern of patterns) {
5224
+ const match = command.match(pattern);
5225
+ if (match) {
5226
+ return { port: Number(match[1]), confidence: "high", evidence: [`script command: ${match[0]}`] };
5227
+ }
5228
+ }
5229
+ const frameworkDefaults = [
5230
+ { pattern: /\bnext\b/, port: 3e3, label: "next default port" },
5231
+ { pattern: /\bvite\b/, port: 5173, label: "vite default port" },
5232
+ { pattern: /\bastro\b/, port: 4321, label: "astro default port" },
5233
+ { pattern: /\bnuxt\b/, port: 3e3, label: "nuxt default port" }
5234
+ ];
5235
+ for (const entry of frameworkDefaults) {
5236
+ if (entry.pattern.test(command)) {
5237
+ return { port: entry.port, confidence: "medium", evidence: [entry.label] };
5238
+ }
5239
+ }
5240
+ return { confidence: "low", evidence: ["No explicit port detected"] };
5241
+ }
5242
+ function inspectAppCandidates(scripts) {
5243
+ const candidates = scripts.filter(
5244
+ (script) => /(^dev$|:dev$|dev:|^start$|web|app|serve)/.test(script.name)
5245
+ );
5246
+ return candidates.map((script) => {
5247
+ const port = inferPort(script.command);
5248
+ return {
5249
+ name: script.name === "dev" || script.name === "start" ? "app" : script.name.replace(/[:_]/g, "-"),
5250
+ port: port.port,
5251
+ confidence: port.confidence,
5252
+ evidence: [...script.evidence, ...port.evidence]
5253
+ };
5254
+ });
5255
+ }
5256
+ function configuredComposeFiles(repoPath) {
5257
+ try {
5258
+ const config = loadRepoConfig(repoPath);
5259
+ const files = config.apps.filter((app) => app.runtime === "docker").flatMap((app) => app.docker.composeFiles).filter((fileName) => !import_node_path12.default.isAbsolute(fileName) && !import_node_path12.default.normalize(fileName).startsWith(".."));
5260
+ return Array.from(new Set(files));
5261
+ } catch {
5262
+ return [];
5263
+ }
5264
+ }
5265
+ function composeFiles(repoPath) {
5266
+ const candidates = [
5267
+ "docker-compose.yml",
5268
+ "docker-compose.yaml",
5269
+ "compose.yml",
5270
+ "compose.yaml",
5271
+ ".devcontainer/docker-compose.yml",
5272
+ ".devcontainer/docker-compose.yaml",
5273
+ ...configuredComposeFiles(repoPath)
5274
+ ];
5275
+ return Array.from(new Set(candidates)).filter((fileName) => import_node_fs13.default.existsSync(import_node_path12.default.join(repoPath, fileName)));
5276
+ }
5277
+ function stringArray(value) {
5278
+ if (!Array.isArray(value)) {
5279
+ return [];
5280
+ }
5281
+ return value.map((entry) => typeof entry === "string" ? entry : JSON.stringify(entry));
5282
+ }
5283
+ function envNames(value) {
5284
+ if (Array.isArray(value)) {
5285
+ return value.filter((entry) => typeof entry === "string").map((entry) => entry.split("=")[0]?.trim()).filter((entry) => Boolean(entry));
5286
+ }
5287
+ const record = asRecord3(value);
5288
+ return record ? Object.keys(record).sort() : [];
5289
+ }
5290
+ function inferServiceKind(name, image) {
5291
+ const source = `${name} ${image ?? ""}`.toLowerCase();
5292
+ const rules = [
5293
+ ["postgres", "postgres"],
5294
+ ["redis", "redis"],
5295
+ ["mysql", "mysql"],
5296
+ ["mariadb", "mariadb"],
5297
+ ["mock-oauth2", "oidc"],
5298
+ ["oidc", "oidc"],
5299
+ ["mailhog", "mail"]
5300
+ ];
5301
+ for (const [needle, kind] of rules) {
5302
+ if (source.includes(needle)) {
5303
+ return { kind, confidence: "high" };
5304
+ }
5305
+ }
5306
+ return { kind: "unknown", confidence: "low" };
5307
+ }
5308
+ function inspectServices(repoPath) {
5309
+ const services = [];
5310
+ for (const fileName of composeFiles(repoPath)) {
5311
+ try {
5312
+ const parsed = import_yaml5.default.parse(import_node_fs13.default.readFileSync(import_node_path12.default.join(repoPath, fileName), "utf-8"));
5313
+ const serviceMap = asRecord3(asRecord3(parsed)?.services);
5314
+ for (const [name, value] of Object.entries(serviceMap ?? {})) {
5315
+ const service = asRecord3(value);
5316
+ if (!service) {
5317
+ continue;
5318
+ }
5319
+ const image = typeof service.image === "string" ? service.image : void 0;
5320
+ const kind = inferServiceKind(name, image);
5321
+ services.push({
5322
+ name,
5323
+ kind: kind.kind,
5324
+ source: fileName,
5325
+ image,
5326
+ ports: stringArray(service.ports),
5327
+ hasHealthcheck: service.healthcheck !== void 0,
5328
+ envNames: envNames(service.environment),
5329
+ confidence: kind.confidence
5330
+ });
5331
+ }
5332
+ } catch {
5333
+ services.push({
5334
+ name: "(parse-error)",
5335
+ kind: "unknown",
5336
+ source: fileName,
5337
+ ports: [],
5338
+ hasHealthcheck: false,
5339
+ envNames: [],
5340
+ confidence: "low"
5341
+ });
5342
+ }
5343
+ }
5344
+ return services;
5345
+ }
5346
+ function inspectEnvFiles(repoPath) {
5347
+ const files = import_node_fs13.default.readdirSync(repoPath).filter((fileName) => /^\.env(\.|$)/.test(fileName)).sort().map((fileName) => {
5348
+ const content = import_node_fs13.default.readFileSync(import_node_path12.default.join(repoPath, fileName), "utf-8");
5349
+ const names = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#") && line.includes("=")).map((line) => line.split("=")[0]?.trim()).filter((name) => Boolean(name)).sort();
5350
+ return { path: fileName, names };
5351
+ });
5352
+ const allNames = Array.from(new Set(files.flatMap((file) => file.names))).sort();
5353
+ return {
5354
+ files,
5355
+ authLikeNames: allNames.filter((name) => /(AUTH|OIDC|ISSUER|CLERK|NEXTAUTH)/i.test(name)),
5356
+ databaseLikeNames: allNames.filter((name) => /(DATABASE|POSTGRES|REDIS|MYSQL|MARIADB|DB_)/i.test(name))
5357
+ };
5358
+ }
5359
+ function inspectDevcontainer(repoPath) {
5360
+ const dir = import_node_path12.default.join(repoPath, ".devcontainer");
5361
+ if (!import_node_fs13.default.existsSync(dir)) {
5362
+ return { exists: false, files: [] };
5363
+ }
5364
+ return {
5365
+ exists: true,
5366
+ files: import_node_fs13.default.readdirSync(dir).filter((fileName) => import_node_fs13.default.statSync(import_node_path12.default.join(dir, fileName)).isFile()).sort().map((fileName) => `.devcontainer/${fileName}`)
5367
+ };
5368
+ }
5369
+ function inspectDevrouter(repoPath) {
5370
+ const configPath = getRepoConfigPath(repoPath);
5371
+ if (!import_node_fs13.default.existsSync(configPath)) {
5372
+ return {
5373
+ exists: false,
5374
+ configPath,
5375
+ valid: false,
5376
+ appCount: 0,
5377
+ tcpAppCount: 0,
5378
+ apps: []
5379
+ };
5380
+ }
5381
+ try {
5382
+ const config = loadRepoConfig(repoPath);
5383
+ return {
5384
+ exists: true,
5385
+ configPath,
5386
+ valid: true,
5387
+ appCount: config.apps.length,
5388
+ tcpAppCount: config.apps.filter((app) => app.kind !== "dependency" && app.protocol === "tcp").length,
5389
+ apps: config.apps.map((app) => ({
5390
+ name: app.name,
5391
+ runtime: app.runtime,
5392
+ protocol: app.kind === "dependency" ? void 0 : app.protocol,
5393
+ host: app.kind === "dependency" ? void 0 : app.host
5394
+ }))
5395
+ };
5396
+ } catch (error) {
5397
+ const message = error instanceof Error ? error.message : String(error);
5398
+ return {
5399
+ exists: true,
5400
+ configPath,
5401
+ valid: false,
5402
+ appCount: 0,
5403
+ tcpAppCount: 0,
5404
+ apps: [],
5405
+ error: sanitizeDiagnosticText(message)
5406
+ };
5407
+ }
5408
+ }
5409
+ function inspectAgentGuidance(repoPath) {
5410
+ const results = [];
5411
+ for (const [fileName, kind] of [["AGENTS.md", "agents"], ["CLAUDE.md", "claude"]]) {
5412
+ if (import_node_fs13.default.existsSync(import_node_path12.default.join(repoPath, fileName))) {
5413
+ results.push({ path: fileName, kind });
5414
+ }
5415
+ }
5416
+ const skillsDir = import_node_path12.default.join(repoPath, ".agents", "skills");
5417
+ if (import_node_fs13.default.existsSync(skillsDir)) {
5418
+ for (const name of import_node_fs13.default.readdirSync(skillsDir).sort()) {
5419
+ const skillPath = import_node_path12.default.join(skillsDir, name, "SKILL.md");
5420
+ if (import_node_fs13.default.existsSync(skillPath)) {
5421
+ results.push({ path: relative(repoPath, skillPath), kind: "skill" });
5422
+ }
5423
+ }
5424
+ }
5425
+ return results;
5426
+ }
5427
+ function buildIssues(report) {
5428
+ const issues = [];
5429
+ if (!report.packageManager) {
5430
+ issues.push({
5431
+ id: "repo.package-manager.missing",
5432
+ level: "warn",
5433
+ summary: "No package manager metadata or lockfile detected."
5434
+ });
5435
+ }
5436
+ if (!report.devcontainer.exists) {
5437
+ issues.push({
5438
+ id: "repo.devcontainer.missing",
5439
+ level: "warn",
5440
+ summary: "No .devcontainer directory found."
5441
+ });
5442
+ }
5443
+ if (!report.devrouter.exists) {
5444
+ issues.push({
5445
+ id: "repo.devrouter.missing",
5446
+ level: "warn",
5447
+ summary: "No .devrouter.yml found.",
5448
+ suggestion: `Run: dev repo init --repo ${report.repoPath}`
5449
+ });
5450
+ } else if (!report.devrouter.valid) {
5451
+ issues.push({
5452
+ id: "repo.devrouter.invalid",
5453
+ level: "error",
5454
+ summary: ".devrouter.yml exists but is invalid.",
5455
+ details: report.devrouter.error,
5456
+ suggestion: "Fix .devrouter.yml validation errors."
5457
+ });
5458
+ }
5459
+ return issues;
5460
+ }
5461
+ function inspectRepo(options = {}) {
5462
+ const repoPath = resolveRepoPath(options.repo);
5463
+ const pkg = readJson(import_node_path12.default.join(repoPath, "package.json"));
5464
+ const scripts = inspectScripts(pkg);
5465
+ const reportWithoutIssues = {
5466
+ repoPath,
5467
+ packageManager: inspectPackageManager(repoPath, pkg),
5468
+ node: inspectNode(repoPath, pkg),
5469
+ scripts,
5470
+ apps: inspectAppCandidates(scripts),
5471
+ services: inspectServices(repoPath),
5472
+ env: inspectEnvFiles(repoPath),
5473
+ devcontainer: inspectDevcontainer(repoPath),
5474
+ devrouter: inspectDevrouter(repoPath),
5475
+ agentGuidance: inspectAgentGuidance(repoPath)
5476
+ };
5477
+ return {
5478
+ ...reportWithoutIssues,
5479
+ issues: buildIssues(reportWithoutIssues)
5480
+ };
5481
+ }
5482
+ var import_node_fs13, import_node_path12, import_yaml5;
5483
+ var init_repo_inspect = __esm({
5484
+ "src/core/repo-inspect.ts"() {
5485
+ "use strict";
5486
+ import_node_fs13 = __toESM(require("fs"));
5487
+ import_node_path12 = __toESM(require("path"));
5488
+ import_yaml5 = __toESM(require("yaml"));
5489
+ init_repo_config();
5490
+ }
5491
+ });
5492
+
5493
+ // src/commands/repo-inspect.ts
5494
+ var repo_inspect_exports = {};
5495
+ __export(repo_inspect_exports, {
5496
+ runRepoInspectCommand: () => runRepoInspectCommand
5497
+ });
5498
+ async function runRepoInspectCommand(options) {
5499
+ const report = inspectRepo({ repo: options.repo });
5500
+ if (options.json) {
5501
+ printJSON(report);
5502
+ return;
5503
+ }
5504
+ printRepoInspectionSummary(report);
5505
+ }
5506
+ function printRepoInspectionSummary(report) {
5507
+ const packageManager = report.packageManager ? `${report.packageManager.name}${report.packageManager.version ? `@${report.packageManager.version}` : ""}` : "not detected";
5508
+ const devrouter = report.devrouter.exists ? report.devrouter.valid ? `valid (${report.devrouter.appCount} app(s))` : "invalid" : "missing";
5509
+ process.stdout.write(`Repo: ${report.repoPath}
5510
+ `);
5511
+ process.stdout.write(`Package manager: ${packageManager}
5512
+ `);
5513
+ process.stdout.write(`Scripts: ${report.scripts.length}
5514
+ `);
5515
+ process.stdout.write(`App candidates: ${report.apps.length}
5516
+ `);
5517
+ process.stdout.write(`Compose services: ${report.services.length}
5518
+ `);
5519
+ process.stdout.write(`Devcontainer: ${report.devcontainer.exists ? "yes" : "no"}
5520
+ `);
5521
+ process.stdout.write(`Devrouter config: ${devrouter}
5522
+ `);
5523
+ if (report.issues.length > 0) {
5524
+ process.stdout.write("\nIssues:\n");
5525
+ for (const issue of report.issues) {
5526
+ process.stdout.write(`- ${issue.id} [${issue.level}]: ${issue.summary}
5527
+ `);
5528
+ }
5529
+ }
5530
+ process.stdout.write("\nFor full agent-readable output, run: dev repo inspect --json\n");
5531
+ }
5532
+ var init_repo_inspect2 = __esm({
5533
+ "src/commands/repo-inspect.ts"() {
5534
+ "use strict";
5535
+ init_repo_inspect();
5536
+ init_output();
5537
+ }
5538
+ });
5539
+
5540
+ // src/commands/repo-agents.ts
5541
+ var repo_agents_exports = {};
5542
+ __export(repo_agents_exports, {
5543
+ runRepoAgentsCommand: () => runRepoAgentsCommand
5544
+ });
5545
+ async function runRepoAgentsCommand(options, deps = {}) {
5546
+ const repoPath = resolveRepoPath(options.repo);
5547
+ let linearMetadata = null;
5548
+ const skill = ensureSkillFile(repoPath);
5549
+ process.stdout.write(`Wrote skill to ${skill.path}
5550
+ `);
5551
+ if (options.withLinear) {
5552
+ linearMetadata = await (deps.collectLinearMetadata ?? collectLinearWorkflowMetadata)();
5553
+ if (linearMetadata.captureMode === "placeholder") {
5554
+ process.stdout.write(
5555
+ "Warning: non-interactive mode detected; wrote placeholder Linear mapping values. Re-run in a TTY to capture workspace/team/project.\n"
5556
+ );
5557
+ }
5558
+ const linearSkills = ensureLinearWorkflowSkillFiles(repoPath);
5559
+ for (const filePath of linearSkills.paths) {
5560
+ process.stdout.write(`Wrote Linear workflow artifact to ${filePath}
5561
+ `);
5562
+ }
5563
+ }
5564
+ const result = ensureAgentsMdSection(repoPath);
5565
+ if (!result.written) {
5566
+ process.stdout.write(`devrouter section already present: ${result.path}
5567
+ `);
5568
+ } else {
5569
+ process.stdout.write(`Wrote devrouter section to ${result.path}
5570
+ `);
5571
+ }
5572
+ if (options.withLinear) {
5573
+ if (!linearMetadata) {
5574
+ throw new Error("Linear metadata was not collected.");
5575
+ }
5576
+ const linearAgents = ensureLinearWorkflowAgentsSection(repoPath, linearMetadata);
5577
+ if (!linearAgents.written) {
5578
+ process.stdout.write(`Linear workflow section already present: ${linearAgents.path}
5579
+ `);
5580
+ return;
5581
+ }
5582
+ process.stdout.write(`Wrote Linear workflow section to ${linearAgents.path}
5583
+ `);
5584
+ }
5585
+ }
5586
+ var init_repo_agents = __esm({
5587
+ "src/commands/repo-agents.ts"() {
5588
+ "use strict";
5589
+ init_repo_config();
5590
+ init_agents_md();
5591
+ init_linear_onboarding();
5592
+ }
5593
+ });
5594
+
5595
+ // src/core/devcontainer-write.ts
5596
+ function sanitizeProjectName(value) {
5597
+ const sanitized = value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
5598
+ return sanitized || "app";
5599
+ }
5600
+ function majorVersion(value, fallback) {
5601
+ const match = value?.match(/(\d+)/);
5602
+ return match ? match[1] : fallback;
5603
+ }
5604
+ function devrouterVersion(value) {
5605
+ return value && /^\d+\.\d+\.\d+$/.test(value) ? value : DEFAULT_DEVROUTER_VERSION;
5606
+ }
5607
+ function inferDevScript(repo) {
5608
+ const script = repo.scripts.find((entry) => entry.name === "dev") ?? repo.scripts.find((entry) => entry.name.endsWith(":dev"));
5609
+ if (!script) {
5610
+ return "pnpm dev";
5611
+ }
5612
+ return script.name === "dev" ? "pnpm dev" : `pnpm run -- ${shellSingleQuote(script.name)}`;
5613
+ }
5614
+ function inferPort2(repo) {
5615
+ return repo.apps.find((app) => app.port)?.port ?? 3e3;
5616
+ }
5617
+ function renderDockerfile(nodeMajor, pnpmVersion) {
5618
+ const pnpmPackageSpec = `pnpm@${pnpmVersion}`;
5619
+ return `# ${MANAGED_MARKER}
5620
+ FROM node:${nodeMajor}-bookworm-slim
5621
+
5622
+ RUN apt-get update \\
5623
+ && apt-get install -y --no-install-recommends git ca-certificates curl procps openssl \\
5624
+ && rm -rf /var/lib/apt/lists/*
5625
+
5626
+ RUN npm install -g ${shellSingleQuote(pnpmPackageSpec)}
5627
+
5628
+ WORKDIR /workspaces/app
5629
+ `;
5630
+ }
5631
+ function renderCompose(projectName) {
5632
+ return `# ${MANAGED_MARKER}
5633
+ services:
5634
+ postgres:
5635
+ image: postgres:16-alpine
5636
+ environment:
5637
+ POSTGRES_USER: prisma
5638
+ POSTGRES_PASSWORD: prisma
5639
+ POSTGRES_DB: prisma
5640
+ healthcheck:
5641
+ test: ["CMD-SHELL", "pg_isready -U prisma -d prisma"]
5642
+ interval: 5s
5643
+ timeout: 3s
5644
+ retries: 20
5645
+ networks:
5646
+ default: {}
5647
+ devnet:
5648
+ aliases:
5649
+ - \${WORKSPACE:-${projectName}}-db
5650
+ volumes:
5651
+ - ./init-db.sh:/docker-entrypoint-initdb.d/10-create-shadow-db.sh:ro
5652
+ - pgdata:/var/lib/postgresql/data
5653
+
5654
+ app:
5655
+ build:
5656
+ context: .
5657
+ dockerfile: Dockerfile
5658
+ init: true
5659
+ env_file:
5660
+ - devcontainer.env
5661
+ command: sleep infinity
5662
+ networks:
5663
+ default: {}
5664
+ devnet:
5665
+ aliases:
5666
+ - \${WORKSPACE:-${projectName}}-app
5667
+ volumes:
5668
+ - ..:/workspaces/${projectName}:cached
5669
+ - node_modules:/workspaces/${projectName}/node_modules
5670
+ working_dir: /workspaces/${projectName}
5671
+ depends_on:
5672
+ postgres:
5673
+ condition: service_healthy
5674
+
5675
+ networks:
5676
+ devnet:
5677
+ external: true
5678
+
5679
+ volumes:
5680
+ pgdata:
5681
+ node_modules:
5682
+ `;
5683
+ }
5684
+ function renderInitDb() {
5685
+ return `#!/usr/bin/env bash
5686
+ # ${MANAGED_MARKER}
5687
+ set -euo pipefail
5688
+
5689
+ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<'SQL'
5690
+ SELECT 'CREATE DATABASE shadow'
5691
+ WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'shadow')\\gexec
5692
+ SQL
5693
+ `;
5694
+ }
5695
+ function renderDevcontainerJson(projectName) {
5696
+ return `{
5697
+ "name": "${projectName}",
5698
+ "dockerComposeFile": "docker-compose.yml",
5699
+ "service": "app",
5700
+ "workspaceFolder": "/workspaces/${projectName}",
5701
+ "postCreateCommand": "bash .devcontainer/post-create.sh",
5702
+ "postStartCommand": "bash .devcontainer/post-start.sh",
5703
+ "customizations": {
5704
+ "devrouter": {
5705
+ "managed": "${MANAGED_MARKER}"
5706
+ },
5707
+ "vscode": {
5708
+ "extensions": []
5709
+ }
5710
+ }
5711
+ }
5712
+ `;
5713
+ }
5714
+ function renderEnv(projectName, port) {
5715
+ return `# ${MANAGED_MARKER}
5716
+ WORKSPACE=${projectName}
5717
+ HOST=0.0.0.0
5718
+ HOSTNAME=0.0.0.0
5719
+ PORT=${port}
5720
+ DATABASE_URL=postgres://prisma:prisma@postgres:5432/prisma
5721
+ SHADOW_DATABASE_URL=postgres://prisma:prisma@postgres:5432/shadow
5722
+ `;
5723
+ }
5724
+ function renderPostCreate() {
5725
+ return `#!/usr/bin/env bash
5726
+ # ${MANAGED_MARKER}
5727
+ set -euo pipefail
5728
+
5729
+ export CI=true
5730
+ export npm_config_verify_deps_before_run=false
5731
+
5732
+ if [ -f package.json ]; then
5733
+ pnpm install --no-frozen-lockfile
5734
+ fi
5735
+ `;
5736
+ }
5737
+ function shellSingleQuote(value) {
5738
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
5739
+ }
5740
+ function renderPostStart(devCommand) {
5741
+ return `#!/usr/bin/env bash
5742
+ # ${MANAGED_MARKER}
5743
+ set -euo pipefail
5744
+
5745
+ export CI=true
5746
+ export npm_config_verify_deps_before_run=false
5747
+ set -a
5748
+ . .devcontainer/devcontainer.env
5749
+ set +a
5750
+
5751
+ if pgrep -f ${shellSingleQuote(devCommand)} >/dev/null 2>&1; then
5752
+ exit 0
5753
+ fi
5754
+
5755
+ setsid bash -lc ${shellSingleQuote(devCommand)} >/tmp/devrouter-app.log 2>&1 </dev/null &
5756
+ `;
5757
+ }
5758
+ function renderDevrouter(projectName, port, version) {
5759
+ return `# ${MANAGED_MARKER}
5760
+ version: 1
5761
+ devrouter:
5762
+ version: ${version}
5763
+ project:
5764
+ name: ${projectName}
5765
+ apps:
5766
+ - name: app
5767
+ host: ${projectName}.localhost
5768
+ protocol: http
5769
+ runtime: proxy
5770
+ upstream: \${WORKSPACE}-app:${port}
5771
+
5772
+ - name: db
5773
+ host: db.${projectName}.localhost
5774
+ protocol: tcp
5775
+ tcpProtocol: postgres
5776
+ runtime: proxy
5777
+ upstream: \${WORKSPACE}-db:5432
5778
+ `;
5779
+ }
5780
+ function renderReadme(projectName) {
5781
+ return `<!-- ${MANAGED_MARKER} -->
5782
+ # Devcontainer
5783
+
5784
+ Use this repo through the devcontainer, with devrouter providing stable local routes.
5785
+
5786
+ \`\`\`bash
5787
+ dev setup --yes
5788
+ devpod up .
5789
+ dev app run app --repo . --yes
5790
+ dev app run db --repo . --yes
5791
+ \`\`\`
5792
+
5793
+ - App: https://${projectName}.localhost
5794
+ - Postgres: db.${projectName}.localhost:5432 (TLS/SNI)
5795
+ `;
5796
+ }
5797
+ function postWriteNextSteps(repoPath) {
5798
+ const quotedRepoPath = shellSingleQuote(repoPath);
5799
+ return [
5800
+ `Run: dev setup --repo ${quotedRepoPath} --yes`,
5801
+ `Run: cd ${quotedRepoPath} && devpod up .`,
5802
+ `Run: dev app run app --repo ${quotedRepoPath} --yes`,
5803
+ `Run: dev app run db --repo ${quotedRepoPath} --yes`,
5804
+ `Optional: dev repo agents --repo ${quotedRepoPath}`
5805
+ ];
5806
+ }
5807
+ function issueNextSteps(issues) {
5808
+ const steps = issues.filter((issue) => issue.level === "error").map((issue) => issue.suggestion).filter((suggestion) => Boolean(suggestion));
5809
+ if (steps.length > 0) {
5810
+ return [...steps, "Re-run: dev repo devcontainer write --dry-run --json"];
5811
+ }
5812
+ return ["Resolve reported errors, then re-run: dev repo devcontainer write --dry-run --json"];
5813
+ }
5814
+ function packageManagerIssues(repo) {
5815
+ if (!repo.packageManager) {
5816
+ return [
5817
+ {
5818
+ id: "repo.devcontainer.package-manager-unknown",
5819
+ level: "warn",
5820
+ summary: "Package manager could not be detected; pnpm scaffold will be generated.",
5821
+ suggestion: "Add packageManager: pnpm@<version> to package.json or add pnpm-lock.yaml before writing."
5822
+ }
5823
+ ];
5824
+ }
5825
+ if (repo.packageManager.name !== "pnpm") {
5826
+ return [
5827
+ {
5828
+ id: "repo.devcontainer.package-manager-unsupported",
5829
+ level: "error",
5830
+ summary: `Only pnpm repositories are supported by this devcontainer scaffold; detected ${repo.packageManager.name}.`,
5831
+ details: repo.packageManager.source,
5832
+ suggestion: "Use a pnpm repo for this scaffold, or adapt the generated plan manually before writing."
5833
+ }
5834
+ ];
5835
+ }
5836
+ if (repo.packageManager.version && !VALID_PACKAGE_VERSION_RE.test(repo.packageManager.version)) {
5837
+ return [
5838
+ {
5839
+ id: "repo.devcontainer.package-manager-version-unsupported",
5840
+ level: "error",
5841
+ summary: `Unsupported pnpm version '${repo.packageManager.version}' in packageManager.`,
5842
+ details: repo.packageManager.source,
5843
+ suggestion: "Use a pinned semver packageManager value such as pnpm@11.6.0 before writing the scaffold."
5844
+ }
5845
+ ];
5846
+ }
5847
+ return [];
5848
+ }
5849
+ function plannedFiles(repoPath, version) {
5850
+ const repo = inspectRepo({ repo: repoPath });
5851
+ const projectName = sanitizeProjectName(import_node_path13.default.basename(repo.repoPath));
5852
+ const nodeMajor = majorVersion(repo.node?.version, "24");
5853
+ const pnpmVersion = repo.packageManager?.name === "pnpm" && repo.packageManager.version ? repo.packageManager.version : DEFAULT_PNPM_VERSION;
5854
+ const port = inferPort2(repo);
5855
+ const devCommand = inferDevScript(repo);
5856
+ const issues = packageManagerIssues(repo);
5857
+ return {
5858
+ projectName,
5859
+ issues,
5860
+ files: [
5861
+ { relativePath: ".devcontainer/Dockerfile", content: renderDockerfile(nodeMajor, pnpmVersion) },
5862
+ { relativePath: ".devcontainer/docker-compose.yml", content: renderCompose(projectName) },
5863
+ { relativePath: ".devcontainer/init-db.sh", content: renderInitDb(), executable: true },
5864
+ { relativePath: ".devcontainer/devcontainer.json", content: renderDevcontainerJson(projectName) },
5865
+ { relativePath: ".devcontainer/devcontainer.env", content: renderEnv(projectName, port) },
5866
+ { relativePath: ".devcontainer/post-create.sh", content: renderPostCreate(), executable: true },
5867
+ { relativePath: ".devcontainer/post-start.sh", content: renderPostStart(devCommand), executable: true },
5868
+ { relativePath: ".devcontainer/README.md", content: renderReadme(projectName) },
5869
+ { relativePath: ".devrouter.yml", content: renderDevrouter(projectName, port, version) }
5870
+ ]
5871
+ };
5872
+ }
5873
+ function classifyFile(repoPath, file) {
5874
+ const absolutePath = import_node_path13.default.join(repoPath, file.relativePath);
5875
+ if (!import_node_fs14.default.existsSync(absolutePath)) {
5876
+ return {
5877
+ path: file.relativePath,
5878
+ action: "create",
5879
+ reason: "file is missing",
5880
+ bytes: Buffer.byteLength(file.content)
5881
+ };
5882
+ }
5883
+ const current = import_node_fs14.default.readFileSync(absolutePath, "utf-8");
5884
+ if (!current.includes(MANAGED_MARKER)) {
5885
+ return {
5886
+ path: file.relativePath,
5887
+ action: "conflict",
5888
+ reason: "existing file is not marked as devrouter-managed"
5889
+ };
5890
+ }
5891
+ if (current === file.content) {
5892
+ return {
5893
+ path: file.relativePath,
5894
+ action: "skip",
5895
+ reason: "managed file already matches",
5896
+ bytes: Buffer.byteLength(file.content)
5897
+ };
5898
+ }
5899
+ return {
5900
+ path: file.relativePath,
5901
+ action: "update",
5902
+ reason: "managed file differs",
5903
+ bytes: Buffer.byteLength(file.content)
5904
+ };
5905
+ }
5906
+ function buildPlan(repoPath, dryRun, version) {
5907
+ const rendered = plannedFiles(repoPath, version);
5908
+ const files = rendered.files;
5909
+ const filePlans = files.map((file) => classifyFile(repoPath, file));
5910
+ const issues = [...rendered.issues];
5911
+ if (filePlans.some((file) => file.action === "conflict")) {
5912
+ issues.push({
5913
+ id: "repo.devcontainer.write-conflict",
5914
+ level: "error",
5915
+ summary: "One or more target files already exist and are not devrouter-managed.",
5916
+ suggestion: "Review the conflicts, move custom files aside, or merge the devrouter-managed section manually."
5917
+ });
5918
+ }
5919
+ filePlans.push({
5920
+ path: "AGENTS.md",
5921
+ action: "suggest",
5922
+ reason: "run dev repo agents after reviewing the scaffold"
5923
+ });
5924
+ return {
5925
+ files,
5926
+ plan: {
5927
+ repoPath,
5928
+ projectName: rendered.projectName,
5929
+ profile: "node-postgres",
5930
+ dryRun,
5931
+ files: filePlans,
5932
+ issues,
5933
+ nextSteps: issues.some((issue) => issue.level === "error") ? issueNextSteps(issues) : dryRun ? [`Review this plan, then run: dev repo devcontainer write --repo ${shellSingleQuote(repoPath)} --yes`] : postWriteNextSteps(repoPath)
5934
+ }
5935
+ };
5936
+ }
5937
+ function writeFile(repoPath, file) {
5938
+ const absolutePath = import_node_path13.default.join(repoPath, file.relativePath);
5939
+ import_node_fs14.default.mkdirSync(import_node_path13.default.dirname(absolutePath), { recursive: true });
5940
+ import_node_fs14.default.writeFileSync(absolutePath, file.content, "utf-8");
5941
+ if (file.executable) {
5942
+ import_node_fs14.default.chmodSync(absolutePath, 493);
5943
+ }
5944
+ }
5945
+ function writeDevcontainer(options = {}) {
5946
+ const repoPath = resolveRepoPath(options.repo);
5947
+ const dryRun = Boolean(options.dryRun);
5948
+ const { plan, files } = buildPlan(repoPath, dryRun, devrouterVersion(options.installedVersion));
5949
+ if (dryRun || plan.issues.some((issue) => issue.level === "error")) {
5950
+ return plan;
5951
+ }
5952
+ if (!options.yes) {
5953
+ return {
5954
+ ...plan,
5955
+ issues: [
5956
+ ...plan.issues,
5957
+ {
5958
+ id: "repo.devcontainer.confirmation",
5959
+ level: "error",
5960
+ summary: "Writing devcontainer files requires --yes.",
5961
+ suggestion: `Run: dev repo devcontainer write --repo ${shellSingleQuote(repoPath)} --yes`
5962
+ }
5963
+ ],
5964
+ nextSteps: [`Run: dev repo devcontainer write --repo ${shellSingleQuote(repoPath)} --yes`]
5965
+ };
5966
+ }
5967
+ for (const file of files) {
5968
+ const classification = classifyFile(repoPath, file);
5969
+ if (classification.action === "create" || classification.action === "update") {
5970
+ writeFile(repoPath, file);
5971
+ }
5972
+ }
5973
+ return {
5974
+ ...plan,
5975
+ nextSteps: postWriteNextSteps(repoPath)
5976
+ };
5977
+ }
5978
+ var import_node_fs14, import_node_path13, MANAGED_MARKER, DEFAULT_DEVROUTER_VERSION, DEFAULT_PNPM_VERSION, VALID_PACKAGE_VERSION_RE;
5979
+ var init_devcontainer_write = __esm({
5980
+ "src/core/devcontainer-write.ts"() {
5981
+ "use strict";
5982
+ import_node_fs14 = __toESM(require("fs"));
5983
+ import_node_path13 = __toESM(require("path"));
5984
+ init_repo_inspect();
5985
+ init_repo_config();
5986
+ MANAGED_MARKER = "devrouter:managed devcontainer";
5987
+ DEFAULT_DEVROUTER_VERSION = "0.0.0";
5988
+ DEFAULT_PNPM_VERSION = "11.6.0";
5989
+ VALID_PACKAGE_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
5990
+ }
5991
+ });
5992
+
5993
+ // src/core/concurrency.ts
5994
+ function routeUrl(host) {
5995
+ const scheme = isTLSEnabled() ? "https" : "http";
5996
+ return `${scheme}://${host}`;
5997
+ }
5998
+ function assertAppNotRunning(repoPath, app) {
5999
+ const conflict = reconcileRouteRunConflict(repoPath, app);
6000
+ if (!conflict) {
6001
+ return;
6002
+ }
6003
+ if (conflict.kind === "same-app") {
6004
+ throw new AppAlreadyRunningError(
6005
+ app.name,
6006
+ routeUrl(conflict.route.host),
6007
+ conflict.route.pid,
6008
+ conflict.route.repoPath
6009
+ );
6010
+ }
6011
+ throw new HostnameConflictError(
6012
+ app.host,
6013
+ conflict.route.name,
6014
+ conflict.route.repoPath,
6015
+ conflict.route.pid
6016
+ );
6017
+ }
6018
+ var AppAlreadyRunningError, HostnameConflictError;
6019
+ var init_concurrency = __esm({
6020
+ "src/core/concurrency.ts"() {
6021
+ "use strict";
6022
+ init_router();
6023
+ init_route_state();
6024
+ AppAlreadyRunningError = class extends Error {
6025
+ constructor(appName, url, pid, repoPath) {
6026
+ const lines = [
6027
+ `App "${appName}" is already running.`,
6028
+ ` URL: ${url}`,
6029
+ pid ? ` PID: ${pid}` : null,
6030
+ ` Repo: ${repoPath}`
6031
+ ].filter(Boolean);
6032
+ super(lines.join("\n"));
6033
+ this.appName = appName;
6034
+ this.url = url;
6035
+ this.pid = pid;
6036
+ this.repoPath = repoPath;
6037
+ this.name = "AppAlreadyRunningError";
6038
+ }
6039
+ };
6040
+ HostnameConflictError = class extends Error {
6041
+ constructor(hostname, existingApp, existingRepoPath, existingPid) {
6042
+ const lines = [
6043
+ `Hostname "${hostname}" is already claimed by app "${existingApp}".`,
6044
+ existingPid ? ` PID: ${existingPid}` : null,
6045
+ ` Repo: ${existingRepoPath}`
6046
+ ].filter(Boolean);
6047
+ super(lines.join("\n"));
6048
+ this.hostname = hostname;
6049
+ this.existingApp = existingApp;
6050
+ this.existingRepoPath = existingRepoPath;
6051
+ this.existingPid = existingPid;
6052
+ this.name = "HostnameConflictError";
6053
+ }
6054
+ };
6055
+ }
6056
+ });
6057
+
6058
+ // src/core/devcontainer-verify.ts
6059
+ function collectSummary2(checks) {
6060
+ return checks.reduce(
6061
+ (acc, check) => {
6062
+ acc[check.level] += 1;
6063
+ return acc;
6064
+ },
6065
+ { ok: 0, warn: 0, error: 0 }
6066
+ );
6067
+ }
6068
+ function collectNextSteps3(checks) {
6069
+ const steps = /* @__PURE__ */ new Set();
6070
+ for (const check of checks) {
6071
+ if (check.level !== "ok" && check.suggestion) {
6072
+ steps.add(check.suggestion);
6073
+ }
6074
+ }
6075
+ return Array.from(steps.values());
6076
+ }
6077
+ function proxyApps(config) {
6078
+ return (config?.apps ?? []).filter(
6079
+ (app) => app.kind !== "dependency" && app.runtime === "proxy"
6080
+ );
6081
+ }
6082
+ function routedApps(config) {
6083
+ return config.apps.filter((app) => app.kind !== "dependency");
6084
+ }
6085
+ function requiredFileChecks(repoPath) {
6086
+ const required = [
6087
+ ".devcontainer/devcontainer.json",
6088
+ ".devcontainer/docker-compose.yml",
6089
+ ".devrouter.yml"
6090
+ ];
6091
+ const missing = required.filter((fileName) => !import_node_fs15.default.existsSync(import_node_path14.default.join(repoPath, fileName)));
6092
+ return {
6093
+ id: "repo.devcontainer.verify-files",
6094
+ level: missing.length === 0 ? "ok" : "error",
6095
+ summary: missing.length === 0 ? "Required devcontainer/devrouter files are present." : `Missing required devcontainer/devrouter file(s): ${missing.join(", ")}.`,
6096
+ suggestion: missing.length === 0 ? void 0 : "Run: dev repo devcontainer write --dry-run --json"
6097
+ };
6098
+ }
6099
+ function proxyConfigCheck(apps) {
6100
+ return {
6101
+ id: "repo.devcontainer.verify-proxy-apps",
6102
+ level: apps.length > 0 ? "ok" : "error",
6103
+ summary: apps.length > 0 ? `Found ${apps.length} proxy app(s) for devcontainer routing.` : "No proxy app entries found for devcontainer routing.",
6104
+ suggestion: apps.length > 0 ? void 0 : "Add runtime: proxy app entries to .devrouter.yml or run: dev repo devcontainer write --dry-run --json"
6105
+ };
6106
+ }
6107
+ function workspaceTemplateCheck(apps) {
6108
+ const templated = apps.filter((app) => app.upstream.includes(WORKSPACE_PLACEHOLDER));
6109
+ return {
6110
+ id: "repo.devcontainer.verify-workspace-upstreams",
6111
+ level: apps.length > 0 && templated.length === apps.length ? "ok" : "warn",
6112
+ summary: apps.length === 0 ? "No proxy upstreams to inspect for workspace templating." : templated.length === apps.length ? "All proxy upstreams use the ${WORKSPACE} placeholder." : `${apps.length - templated.length} proxy upstream(s) do not use the \${WORKSPACE} placeholder.`,
6113
+ details: apps.length > 0 && templated.length !== apps.length ? apps.filter((app) => !app.upstream.includes(WORKSPACE_PLACEHOLDER)).map((app) => app.name).join(", ") : void 0,
6114
+ suggestion: apps.length > 0 && templated.length !== apps.length ? "Use ${WORKSPACE} in devcontainer proxy upstreams so parallel worktrees do not collide." : void 0
6115
+ };
6116
+ }
6117
+ function workspacePreview(repoPath, config) {
6118
+ const preview = applyWorkspace(config, "verify", repoPath);
6119
+ return routedApps(preview).map((app) => ({
6120
+ name: app.name,
6121
+ host: app.host,
6122
+ upstream: app.runtime === "proxy" ? app.upstream : void 0
6123
+ }));
6124
+ }
6125
+ function workspacePreviewCheck(preview) {
6126
+ const missingNamespacedHosts = (preview ?? []).filter((app) => !app.host.endsWith(".verify.localhost"));
6127
+ return {
6128
+ id: "repo.devcontainer.verify-workspace-preview",
6129
+ level: (preview ?? []).length > 0 && missingNamespacedHosts.length === 0 ? "ok" : "warn",
6130
+ summary: (preview ?? []).length === 0 ? "No routed app entries found for workspace preview." : missingNamespacedHosts.length === 0 ? "Workspace preview namespaces configured hosts without rewriting .devrouter.yml." : `${missingNamespacedHosts.length} workspace preview host(s) were not namespaced.`,
6131
+ details: missingNamespacedHosts.length > 0 ? missingNamespacedHosts.map((app) => `${app.name}: ${app.host}`).join(", ") : void 0,
6132
+ suggestion: missingNamespacedHosts.length > 0 ? "Use valid .localhost hosts and let devrouter namespace them at runtime." : void 0
6133
+ };
6134
+ }
6135
+ function blockingDoctorChecks(doctor) {
6136
+ return doctor.checks.filter(
6137
+ (check) => check.level === "error" && (check.id.startsWith("repo.devcontainer") || check.id === "repo.config" || check.id === "repo.tcp-tls" || check.id === "global.devnet")
6138
+ );
6139
+ }
6140
+ function doctorGateCheck(doctor) {
6141
+ const blocking = blockingDoctorChecks(doctor);
6142
+ return {
6143
+ id: "repo.devcontainer.verify-doctor",
6144
+ level: blocking.length === 0 ? "ok" : "error",
6145
+ summary: blocking.length === 0 ? "Doctor has no blocking devcontainer diagnostics." : `Doctor reported ${blocking.length} blocking devcontainer diagnostic(s).`,
6146
+ details: blocking.length > 0 ? blocking.map((check) => check.id).join(", ") : void 0,
6147
+ suggestion: blocking.length > 0 ? "Run: dev doctor --repo <path> --json" : void 0
6148
+ };
6149
+ }
6150
+ function routeUrl2(host) {
6151
+ return `${isTLSEnabled() ? "https" : "http"}://${host}`;
6152
+ }
6153
+ function curlRoute(host) {
6154
+ const result = (0, import_node_child_process9.spawnSync)("curl", ["-k", "-fsS", "--max-time", "5", routeUrl2(host)], {
6155
+ encoding: "utf-8"
6156
+ });
6157
+ if (result.status === 0) {
6158
+ return { ok: true, details: "HTTP route responded successfully." };
6159
+ }
6160
+ return {
6161
+ ok: false,
6162
+ details: (result.stderr || result.stdout || `curl exited with status ${String(result.status)}`).trim()
6163
+ };
6164
+ }
6165
+ function registerProxyRoute(repoPath, app, workspace) {
6166
+ ensureRouterFiles();
6167
+ const { port, upstreamHost: upstreamHost2 } = parseUpstream(app.upstream);
6168
+ if (app.protocol === "tcp" && !isTLSEnabled()) {
6169
+ throw new Error(
6170
+ `App "${app.name}" is a TCP proxy route, which requires TLS (SNI). Run \`dev tls install\` first.`
6171
+ );
6172
+ }
6173
+ if (app.protocol === "tcp") {
6174
+ const needsRestart = activateTcpProtocol(app.tcpProtocol);
6175
+ if (needsRestart) {
6176
+ startRouterStack();
6177
+ }
6178
+ }
6179
+ removeRouteForApp(repoPath, app.name);
6180
+ assertAppNotRunning(repoPath, { name: app.name, host: app.host });
6181
+ upsertHostRoute({
6182
+ name: app.name,
6183
+ host: app.host,
6184
+ protocol: app.protocol,
6185
+ tcpProtocol: app.protocol === "tcp" ? app.tcpProtocol : void 0,
6186
+ repoPath,
6187
+ port,
6188
+ upstreamHost: upstreamHost2,
6189
+ mode: "proxy",
6190
+ workspace
6191
+ });
6192
+ }
6193
+ async function liveChecks(repoPath, yes) {
6194
+ if (!yes) {
6195
+ return {
6196
+ routes: [],
6197
+ checks: [
6198
+ {
6199
+ id: "repo.devcontainer.verify-live-confirmation",
6200
+ level: "error",
6201
+ summary: "Live devcontainer verification requires --yes.",
6202
+ suggestion: "Run: dev repo devcontainer verify --live --yes --json"
6203
+ }
6204
+ ]
6205
+ };
6206
+ }
6207
+ let runtime;
6208
+ try {
6209
+ runtime = loadRuntimeConfig(repoPath);
6210
+ } catch (error) {
6211
+ return {
6212
+ routes: [],
6213
+ checks: [
6214
+ {
6215
+ id: "repo.devcontainer.verify-live-config",
6216
+ level: "error",
6217
+ summary: "Could not load runtime config for live verification.",
6218
+ details: error instanceof Error ? error.message : String(error),
6219
+ suggestion: "Fix .devrouter.yml and re-run: dev repo devcontainer verify --live --yes --json"
6220
+ }
6221
+ ]
6222
+ };
6223
+ }
6224
+ const apps = proxyApps(runtime.config);
6225
+ const checks = [];
6226
+ const routes = [];
6227
+ for (const app of apps) {
6228
+ try {
6229
+ registerProxyRoute(repoPath, app, runtime.workspace);
6230
+ if (app.protocol === "http") {
6231
+ const curl = curlRoute(app.host);
6232
+ routes.push({
6233
+ name: app.name,
6234
+ host: app.host,
6235
+ status: curl.ok ? "reachable" : "failed",
6236
+ details: curl.details
6237
+ });
6238
+ checks.push({
6239
+ id: `repo.devcontainer.verify-live-http.${app.name}`,
6240
+ level: curl.ok ? "ok" : "error",
6241
+ summary: curl.ok ? `HTTP proxy route '${app.name}' responded.` : `HTTP proxy route '${app.name}' did not respond.`,
6242
+ details: curl.ok ? void 0 : curl.details,
6243
+ suggestion: curl.ok ? void 0 : "Start the devcontainer app process, then re-run live verification."
6244
+ });
6245
+ } else {
6246
+ routes.push({
6247
+ name: app.name,
6248
+ host: app.host,
6249
+ status: "registered",
6250
+ details: `${app.tcpProtocol} route registered on port ${String(TCP_PROTOCOL_REGISTRY[app.tcpProtocol]?.port ?? 5432)}.`
6251
+ });
6252
+ checks.push({
6253
+ id: `repo.devcontainer.verify-live-tcp.${app.name}`,
6254
+ level: "ok",
6255
+ summary: `TCP proxy route '${app.name}' registered.`
6256
+ });
6257
+ }
6258
+ } catch (error) {
6259
+ const message = error instanceof Error ? error.message : String(error);
6260
+ routes.push({
6261
+ name: app.name,
6262
+ host: app.host,
6263
+ status: "failed",
6264
+ details: message
6265
+ });
6266
+ checks.push({
6267
+ id: `repo.devcontainer.verify-live-route.${app.name}`,
6268
+ level: "error",
6269
+ summary: `Could not register proxy route '${app.name}'.`,
6270
+ details: message,
6271
+ suggestion: "Run: dev setup --yes, start the devcontainer, then retry live verification."
6272
+ });
6273
+ }
6274
+ }
6275
+ return { checks, routes };
6276
+ }
6277
+ async function verifyDevcontainer(options = {}) {
6278
+ const repoPath = resolveRepoPath(options.repo);
6279
+ const doctor = await buildDoctorReport({ repo: repoPath });
6280
+ const checks = [doctorGateCheck(doctor), requiredFileChecks(repoPath)];
6281
+ let config;
6282
+ let workspaceEvidence;
6283
+ let apps = [];
6284
+ try {
6285
+ config = loadRepoConfig(repoPath);
6286
+ apps = proxyApps(config);
6287
+ workspaceEvidence = workspacePreview(repoPath, config);
6288
+ checks.push(proxyConfigCheck(apps));
6289
+ checks.push(workspaceTemplateCheck(apps));
6290
+ checks.push(workspacePreviewCheck(workspaceEvidence));
6291
+ } catch (error) {
6292
+ checks.push({
6293
+ id: "repo.devcontainer.verify-config",
6294
+ level: "error",
6295
+ summary: "Could not load .devrouter.yml for devcontainer verification.",
6296
+ details: error instanceof Error ? error.message : String(error),
6297
+ suggestion: "Fix .devrouter.yml and re-run: dev repo devcontainer verify --json"
6298
+ });
6299
+ }
6300
+ let liveRoutes;
6301
+ if (options.live) {
6302
+ const live = await liveChecks(repoPath, Boolean(options.yes));
6303
+ checks.push(...live.checks);
6304
+ liveRoutes = live.routes;
6305
+ }
6306
+ const summary = collectSummary2(checks);
6307
+ return {
6308
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
6309
+ repoPath,
6310
+ live: Boolean(options.live),
6311
+ summary,
6312
+ checks,
6313
+ evidence: {
6314
+ doctorSummary: doctor.summary,
6315
+ blockingDoctorChecks: blockingDoctorChecks(doctor).map((check) => check.id),
6316
+ proxyApps: apps.map((app) => ({
6317
+ name: app.name,
6318
+ protocol: app.protocol,
6319
+ host: app.host,
6320
+ upstream: app.upstream
6321
+ })),
6322
+ workspacePreview: workspaceEvidence,
6323
+ liveRoutes
6324
+ },
6325
+ nextSteps: collectNextSteps3(checks)
6326
+ };
6327
+ }
6328
+ var import_node_child_process9, import_node_fs15, import_node_path14;
6329
+ var init_devcontainer_verify = __esm({
6330
+ "src/core/devcontainer-verify.ts"() {
4389
6331
  "use strict";
6332
+ import_node_child_process9 = require("child_process");
6333
+ import_node_fs15 = __toESM(require("fs"));
6334
+ import_node_path14 = __toESM(require("path"));
4390
6335
  init_repo_config();
6336
+ init_concurrency();
6337
+ init_host_routes();
6338
+ init_doctor();
6339
+ init_route_state();
6340
+ init_router();
6341
+ init_capabilities();
4391
6342
  }
4392
6343
  });
4393
6344
 
4394
- // src/commands/repo-agents.ts
4395
- var repo_agents_exports = {};
4396
- __export(repo_agents_exports, {
4397
- runRepoAgentsCommand: () => runRepoAgentsCommand
6345
+ // src/commands/repo-devcontainer.ts
6346
+ var repo_devcontainer_exports = {};
6347
+ __export(repo_devcontainer_exports, {
6348
+ runRepoDevcontainerVerifyCommand: () => runRepoDevcontainerVerifyCommand,
6349
+ runRepoDevcontainerWriteCommand: () => runRepoDevcontainerWriteCommand
4398
6350
  });
4399
- async function runRepoAgentsCommand(options, deps = {}) {
4400
- const repoPath = resolveRepoPath(options.repo);
4401
- let linearMetadata = null;
4402
- const skill = ensureSkillFile(repoPath);
4403
- process.stdout.write(`Wrote skill to ${skill.path}
6351
+ async function runRepoDevcontainerWriteCommand(options) {
6352
+ const report = writeDevcontainer({
6353
+ repo: options.repo,
6354
+ dryRun: Boolean(options.dryRun),
6355
+ yes: Boolean(options.yes),
6356
+ installedVersion: options.installedVersion
6357
+ });
6358
+ if (options.json) {
6359
+ printJSON(report);
6360
+ } else {
6361
+ process.stdout.write(`Devcontainer profile: ${report.profile}
6362
+ `);
6363
+ for (const file of report.files) {
6364
+ process.stdout.write(`- ${file.action}: ${file.path} (${file.reason})
4404
6365
  `);
4405
- if (options.withLinear) {
4406
- linearMetadata = await (deps.collectLinearMetadata ?? collectLinearWorkflowMetadata)();
4407
- if (linearMetadata.captureMode === "placeholder") {
4408
- process.stdout.write(
4409
- "Warning: non-interactive mode detected; wrote placeholder Linear mapping values. Re-run in a TTY to capture workspace/team/project.\n"
4410
- );
4411
6366
  }
4412
- const linearSkills = ensureLinearWorkflowSkillFiles(repoPath);
4413
- for (const filePath of linearSkills.paths) {
4414
- process.stdout.write(`Wrote Linear workflow artifact to ${filePath}
6367
+ if (report.issues.length > 0) {
6368
+ process.stdout.write("\nFindings:\n");
6369
+ for (const issue of report.issues) {
6370
+ process.stdout.write(`- ${issue.id} [${issue.level}]: ${issue.summary}
6371
+ `);
6372
+ if (issue.details) {
6373
+ process.stdout.write(` Details: ${issue.details}
6374
+ `);
6375
+ }
6376
+ if (issue.suggestion) {
6377
+ process.stdout.write(` Suggestion: ${issue.suggestion}
6378
+ `);
6379
+ }
6380
+ }
6381
+ }
6382
+ if (report.nextSteps.length > 0) {
6383
+ process.stdout.write("\nNext steps:\n");
6384
+ for (const step of report.nextSteps) {
6385
+ process.stdout.write(`- ${step}
4415
6386
  `);
6387
+ }
4416
6388
  }
4417
6389
  }
4418
- const result = ensureAgentsMdSection(repoPath);
4419
- if (!result.written) {
4420
- process.stdout.write(`devrouter section already present: ${result.path}
4421
- `);
6390
+ if (report.issues.some((issue) => issue.level === "error")) {
6391
+ process.exitCode = 1;
6392
+ }
6393
+ }
6394
+ async function runRepoDevcontainerVerifyCommand(options) {
6395
+ const report = await verifyDevcontainer({
6396
+ repo: options.repo,
6397
+ live: Boolean(options.live),
6398
+ yes: Boolean(options.yes)
6399
+ });
6400
+ if (options.json) {
6401
+ printJSON(report);
4422
6402
  } else {
4423
- process.stdout.write(`Wrote devrouter section to ${result.path}
4424
- `);
6403
+ printVerifySummary(report);
4425
6404
  }
4426
- if (options.withLinear) {
4427
- if (!linearMetadata) {
4428
- throw new Error("Linear metadata was not collected.");
4429
- }
4430
- const linearAgents = ensureLinearWorkflowAgentsSection(repoPath, linearMetadata);
4431
- if (!linearAgents.written) {
4432
- process.stdout.write(`Linear workflow section already present: ${linearAgents.path}
6405
+ if (report.summary.error > 0) {
6406
+ process.exitCode = 1;
6407
+ }
6408
+ }
6409
+ function printVerifySummary(report) {
6410
+ process.stdout.write(`Repo: ${report.repoPath}
6411
+ `);
6412
+ process.stdout.write(`Mode: ${report.live ? "live" : "static"}
6413
+ `);
6414
+ process.stdout.write(`Checks: ${report.summary.ok} ok, ${report.summary.warn} warn, ${report.summary.error} error
6415
+ `);
6416
+ process.stdout.write(`Proxy apps: ${report.evidence.proxyApps.length}
6417
+ `);
6418
+ if (report.checks.some((check) => check.level !== "ok")) {
6419
+ process.stdout.write("\nFindings:\n");
6420
+ for (const check of report.checks.filter((entry) => entry.level !== "ok")) {
6421
+ process.stdout.write(`- ${check.id} [${check.level}]: ${check.summary}
4433
6422
  `);
4434
- return;
4435
6423
  }
4436
- process.stdout.write(`Wrote Linear workflow section to ${linearAgents.path}
6424
+ }
6425
+ if (report.nextSteps.length > 0) {
6426
+ process.stdout.write("\nNext steps:\n");
6427
+ for (const step of report.nextSteps) {
6428
+ process.stdout.write(`- ${step}
4437
6429
  `);
6430
+ }
4438
6431
  }
4439
6432
  }
4440
- var init_repo_agents = __esm({
4441
- "src/commands/repo-agents.ts"() {
6433
+ var init_repo_devcontainer = __esm({
6434
+ "src/commands/repo-devcontainer.ts"() {
4442
6435
  "use strict";
4443
- init_repo_config();
4444
- init_agents_md();
4445
- init_linear_onboarding();
6436
+ init_output();
6437
+ init_devcontainer_write();
6438
+ init_devcontainer_verify();
4446
6439
  }
4447
6440
  });
4448
6441
 
@@ -4532,7 +6525,7 @@ function sanitizeRouterId(value) {
4532
6525
  return value.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
4533
6526
  }
4534
6527
  function repoHash(repoPath) {
4535
- return (0, import_node_crypto2.createHash)("sha1").update(import_node_path9.default.resolve(repoPath)).digest("hex").slice(0, 12);
6528
+ return (0, import_node_crypto2.createHash)("sha1").update(import_node_path15.default.resolve(repoPath)).digest("hex").slice(0, 12);
4536
6529
  }
4537
6530
  function asDockerApp(app) {
4538
6531
  return app.runtime === "docker";
@@ -4611,25 +6604,25 @@ function prepareDockerOverlay(repoPath, appName, apps, publishTcpPorts = false)
4611
6604
  if (dockerApps.length === 0) {
4612
6605
  throw new Error("No docker apps selected to prepare compose overlay.");
4613
6606
  }
4614
- const cachePath = import_node_path9.default.join(CACHE_DIR, repoHash(repoPath), sanitizeRouterId(appName));
4615
- import_node_fs11.default.mkdirSync(cachePath, { recursive: true });
4616
- const overlayPath = import_node_path9.default.join(cachePath, "compose.devrouter.yml");
6607
+ const cachePath = import_node_path15.default.join(CACHE_DIR, repoHash(repoPath), sanitizeRouterId(appName));
6608
+ import_node_fs16.default.mkdirSync(cachePath, { recursive: true });
6609
+ const overlayPath = import_node_path15.default.join(cachePath, "compose.devrouter.yml");
4617
6610
  const overlayDocument = buildOverlayDocument(dockerApps, publishTcpPorts);
4618
- import_node_fs11.default.writeFileSync(overlayPath, import_yaml4.default.stringify(overlayDocument, { lineWidth: 0 }), "utf-8");
6611
+ import_node_fs16.default.writeFileSync(overlayPath, import_yaml6.default.stringify(overlayDocument, { lineWidth: 0 }), "utf-8");
4619
6612
  return {
4620
6613
  overlayPath,
4621
6614
  composeFiles: ensureComposeFiles(dockerApps),
4622
6615
  dockerApps
4623
6616
  };
4624
6617
  }
4625
- function runDockerComposeUp(repoPath, composeFiles, overlayPath, services) {
6618
+ function runDockerComposeUp(repoPath, composeFiles2, overlayPath, services) {
4626
6619
  const fileArgs = [];
4627
- for (const composeFile of composeFiles) {
6620
+ for (const composeFile of composeFiles2) {
4628
6621
  const resolved = assertPathWithinRepo(composeFile, repoPath, "composeFiles");
4629
6622
  fileArgs.push("-f", resolved);
4630
6623
  }
4631
6624
  const args = ["compose", ...fileArgs, "-f", overlayPath, "up", "-d", "--wait", ...services];
4632
- const result = (0, import_node_child_process8.spawnSync)("docker", args, {
6625
+ const result = (0, import_node_child_process10.spawnSync)("docker", args, {
4633
6626
  encoding: "utf-8",
4634
6627
  cwd: repoPath
4635
6628
  });
@@ -4638,9 +6631,9 @@ function runDockerComposeUp(repoPath, composeFiles, overlayPath, services) {
4638
6631
  throw new Error(`docker compose up failed: ${withDockerFailureGuidance(details || "unknown error")}`);
4639
6632
  }
4640
6633
  }
4641
- function queryRunningComposeServices(repoPath, composeFiles, overlayPath, services) {
6634
+ function queryRunningComposeServices(repoPath, composeFiles2, overlayPath, services) {
4642
6635
  const fileArgs = [];
4643
- for (const composeFile of composeFiles) {
6636
+ for (const composeFile of composeFiles2) {
4644
6637
  const resolved = assertPathWithinRepo(composeFile, repoPath, "composeFiles");
4645
6638
  fileArgs.push("-f", resolved);
4646
6639
  }
@@ -4655,7 +6648,7 @@ function queryRunningComposeServices(repoPath, composeFiles, overlayPath, servic
4655
6648
  "--services",
4656
6649
  ...services
4657
6650
  ];
4658
- const result = (0, import_node_child_process8.spawnSync)("docker", args, {
6651
+ const result = (0, import_node_child_process10.spawnSync)("docker", args, {
4659
6652
  encoding: "utf-8",
4660
6653
  cwd: repoPath
4661
6654
  });
@@ -4674,14 +6667,14 @@ function queryRunningComposeServices(repoPath, composeFiles, overlayPath, servic
4674
6667
  runningServices
4675
6668
  };
4676
6669
  }
4677
- function runDockerComposeStop(repoPath, composeFiles, overlayPath, services) {
6670
+ function runDockerComposeStop(repoPath, composeFiles2, overlayPath, services) {
4678
6671
  const fileArgs = [];
4679
- for (const composeFile of composeFiles) {
6672
+ for (const composeFile of composeFiles2) {
4680
6673
  const resolved = assertPathWithinRepo(composeFile, repoPath, "composeFiles");
4681
6674
  fileArgs.push("-f", resolved);
4682
6675
  }
4683
6676
  const args = ["compose", ...fileArgs, "-f", overlayPath, "stop", ...services];
4684
- const result = (0, import_node_child_process8.spawnSync)("docker", args, {
6677
+ const result = (0, import_node_child_process10.spawnSync)("docker", args, {
4685
6678
  encoding: "utf-8",
4686
6679
  cwd: repoPath
4687
6680
  });
@@ -4691,26 +6684,26 @@ function runDockerComposeStop(repoPath, composeFiles, overlayPath, services) {
4691
6684
  `);
4692
6685
  }
4693
6686
  }
4694
- function runDockerComposeLogs(repoPath, composeFiles, overlayPath, services, tail = 20) {
6687
+ function runDockerComposeLogs(repoPath, composeFiles2, overlayPath, services, tail = 20) {
4695
6688
  const fileArgs = [];
4696
- for (const composeFile of composeFiles) {
6689
+ for (const composeFile of composeFiles2) {
4697
6690
  const resolved = assertPathWithinRepo(composeFile, repoPath, "composeFiles");
4698
6691
  fileArgs.push("-f", resolved);
4699
6692
  }
4700
6693
  const args = ["compose", ...fileArgs, "-f", overlayPath, "logs", "--tail", String(tail), ...services];
4701
- (0, import_node_child_process8.spawnSync)("docker", args, {
6694
+ (0, import_node_child_process10.spawnSync)("docker", args, {
4702
6695
  stdio: "inherit",
4703
6696
  cwd: repoPath
4704
6697
  });
4705
6698
  }
4706
- function queryMappedPort(repoPath, composeFiles, overlayPath, service, internalPort) {
6699
+ function queryMappedPort(repoPath, composeFiles2, overlayPath, service, internalPort) {
4707
6700
  const fileArgs = [];
4708
- for (const composeFile of composeFiles) {
6701
+ for (const composeFile of composeFiles2) {
4709
6702
  const resolved = assertPathWithinRepo(composeFile, repoPath, "composeFiles");
4710
6703
  fileArgs.push("-f", resolved);
4711
6704
  }
4712
6705
  const args = ["compose", ...fileArgs, "-f", overlayPath, "port", service, String(internalPort)];
4713
- const result = (0, import_node_child_process8.spawnSync)("docker", args, {
6706
+ const result = (0, import_node_child_process10.spawnSync)("docker", args, {
4714
6707
  encoding: "utf-8",
4715
6708
  cwd: repoPath
4716
6709
  });
@@ -4724,21 +6717,154 @@ function queryMappedPort(repoPath, composeFiles, overlayPath, service, internalP
4724
6717
  const port = Number(match[1]);
4725
6718
  return Number.isInteger(port) && port > 0 ? port : void 0;
4726
6719
  }
4727
- var import_node_fs11, import_node_path9, import_node_crypto2, import_node_child_process8, import_yaml4;
6720
+ var import_node_fs16, import_node_path15, import_node_crypto2, import_node_child_process10, import_yaml6;
4728
6721
  var init_docker_run = __esm({
4729
6722
  "src/core/docker-run.ts"() {
4730
6723
  "use strict";
4731
- import_node_fs11 = __toESM(require("fs"));
4732
- import_node_path9 = __toESM(require("path"));
6724
+ import_node_fs16 = __toESM(require("fs"));
6725
+ import_node_path15 = __toESM(require("path"));
4733
6726
  import_node_crypto2 = require("crypto");
4734
- import_node_child_process8 = require("child_process");
4735
- import_yaml4 = __toESM(require("yaml"));
6727
+ import_node_child_process10 = require("child_process");
6728
+ import_yaml6 = __toESM(require("yaml"));
4736
6729
  init_router();
4737
6730
  init_paths();
4738
6731
  init_docker_error_guidance();
4739
6732
  }
4740
6733
  });
4741
6734
 
6735
+ // src/core/dependency-runtime-plan.ts
6736
+ function uniqueApps(apps) {
6737
+ const byName = /* @__PURE__ */ new Map();
6738
+ for (const app of apps) {
6739
+ byName.set(app.name, app);
6740
+ }
6741
+ return Array.from(byName.values());
6742
+ }
6743
+ function dependencyNames(apps) {
6744
+ return apps.map((entry) => entry.name).sort();
6745
+ }
6746
+ function planDependencyRuntime(options) {
6747
+ const selectedApps = uniqueApps([options.app, ...options.dependencies]);
6748
+ const selectedDockerApps = selectedApps.filter(
6749
+ (entry) => entry.runtime === "docker"
6750
+ );
6751
+ const services = selectedDockerApps.map((entry) => entry.docker.service);
6752
+ const dependencyServices = options.dependencies.filter((entry) => entry.runtime === "docker").map((entry) => entry.docker.service);
6753
+ const runningServices = options.runningServicesBefore?.status === "known" ? options.runningServicesBefore.runningServices : void 0;
6754
+ const allDependencyServicesRunning = runningServices !== void 0 && dependencyServices.every((service) => runningServices.has(service));
6755
+ const hasTcpDeps = options.app.runtime === "host" && selectedDockerApps.some(
6756
+ (entry) => entry.kind !== "dependency" && entry.protocol === "tcp"
6757
+ );
6758
+ return {
6759
+ app: options.app,
6760
+ dependencies: options.dependencies,
6761
+ selectedApps,
6762
+ selectedDockerApps,
6763
+ services,
6764
+ dependencyServices,
6765
+ stopPolicy: options.stopPolicy ?? "always-stop-selected",
6766
+ runningServicesBefore: options.runningServicesBefore,
6767
+ allDependencyServicesRunning,
6768
+ shouldPromptForDependencies: options.dependencies.length > 0 && !allDependencyServicesRunning,
6769
+ hasTcpDeps
6770
+ };
6771
+ }
6772
+ function planDependencyStart(runtimePlan, startDependencies) {
6773
+ const shouldRunComposeUp = runtimePlan.app.runtime === "docker" || startDependencies;
6774
+ if (!shouldRunComposeUp) {
6775
+ return {
6776
+ shouldRunComposeUp,
6777
+ startedServices: [],
6778
+ dependencyApps: []
6779
+ };
6780
+ }
6781
+ if (runtimePlan.stopPolicy === "stop-only-newly-started") {
6782
+ if (runtimePlan.runningServicesBefore?.status === "unknown") {
6783
+ return {
6784
+ shouldRunComposeUp,
6785
+ startedServices: [],
6786
+ dependencyApps: startDependencies ? dependencyNames(runtimePlan.dependencies) : [],
6787
+ ownershipWarning: `unable to determine which dependencies were already running before 'dev app exec'; leaving dependencies running after command exit to avoid stopping non-owned services. Details: ${runtimePlan.runningServicesBefore.reason}`
6788
+ };
6789
+ }
6790
+ const beforeSet2 = runtimePlan.runningServicesBefore?.status === "known" ? runtimePlan.runningServicesBefore.runningServices : void 0;
6791
+ return {
6792
+ shouldRunComposeUp,
6793
+ startedServices: beforeSet2 ? runtimePlan.services.filter((service) => !beforeSet2.has(service)) : [],
6794
+ dependencyApps: startDependencies ? dependencyNames(runtimePlan.dependencies) : []
6795
+ };
6796
+ }
6797
+ const beforeSet = runtimePlan.app.runtime === "docker" && runtimePlan.runningServicesBefore?.status === "known" ? runtimePlan.runningServicesBefore.runningServices : void 0;
6798
+ return {
6799
+ shouldRunComposeUp,
6800
+ startedServices: runtimePlan.app.runtime === "docker" ? runtimePlan.services.filter((service) => !beforeSet?.has(service)) : [...runtimePlan.services],
6801
+ dependencyApps: startDependencies ? dependencyNames(runtimePlan.dependencies) : []
6802
+ };
6803
+ }
6804
+ function buildTcpDepUrl(tcpProtocol, port) {
6805
+ switch (tcpProtocol) {
6806
+ case "postgres":
6807
+ return buildPostgresDependencyUrl(port);
6808
+ case "redis":
6809
+ return `redis://localhost:${port}`;
6810
+ case "mysql":
6811
+ case "mariadb":
6812
+ return `mysql://root@localhost:${port}`;
6813
+ default:
6814
+ return void 0;
6815
+ }
6816
+ }
6817
+ function buildTcpDepShadowUrl(tcpProtocol, port) {
6818
+ if (tcpProtocol === "postgres") {
6819
+ return buildPostgresDependencyShadowUrl(port);
6820
+ }
6821
+ return void 0;
6822
+ }
6823
+ function buildDependencyEnv(mappedDeps) {
6824
+ const depEnv = {};
6825
+ const [hostSuffix, portSuffix, urlSuffix, shadowUrlSuffix] = DEP_ENV_SUFFIXES;
6826
+ for (const { app, mappedPort } of mappedDeps) {
6827
+ if (mappedPort === void 0) {
6828
+ continue;
6829
+ }
6830
+ const envPrefix = app.name.toUpperCase().replace(/-/g, "_");
6831
+ depEnv[`${envPrefix}_${hostSuffix}`] = "localhost";
6832
+ depEnv[`${envPrefix}_${portSuffix}`] = String(mappedPort);
6833
+ const url = buildTcpDepUrl(app.tcpProtocol, mappedPort);
6834
+ if (url) {
6835
+ depEnv[`${envPrefix}_${urlSuffix}`] = url;
6836
+ }
6837
+ const shadowUrl = buildTcpDepShadowUrl(app.tcpProtocol, mappedPort);
6838
+ if (shadowUrl) {
6839
+ depEnv[`${envPrefix}_${shadowUrlSuffix}`] = shadowUrl;
6840
+ }
6841
+ }
6842
+ return depEnv;
6843
+ }
6844
+ function applyDependencyEnvMap(app, depEnv) {
6845
+ const mappedEnv = { ...depEnv };
6846
+ for (const depRef of app.dependencies) {
6847
+ if (!depRef.envMap) {
6848
+ continue;
6849
+ }
6850
+ for (const [target, source] of Object.entries(depRef.envMap)) {
6851
+ if (!(source in mappedEnv)) {
6852
+ throw new Error(
6853
+ `envMap on dependency '${depRef.app}': source variable '${source}' not found in dependency env. Available: ${Object.keys(mappedEnv).join(", ") || "(none)"}`
6854
+ );
6855
+ }
6856
+ mappedEnv[target] = mappedEnv[source];
6857
+ }
6858
+ }
6859
+ return mappedEnv;
6860
+ }
6861
+ var init_dependency_runtime_plan = __esm({
6862
+ "src/core/dependency-runtime-plan.ts"() {
6863
+ "use strict";
6864
+ init_capabilities();
6865
+ }
6866
+ });
6867
+
4742
6868
  // src/core/app-run.ts
4743
6869
  function wrapWithSecretManager(smCommand, reinjectEnv, userCommand, shell) {
4744
6870
  const envPairs = Object.entries(reinjectEnv).map(([k, v]) => `${k}=${v}`);
@@ -4779,6 +6905,21 @@ function toError(error) {
4779
6905
  }
4780
6906
  return new Error(String(error));
4781
6907
  }
6908
+ function dependencyNames2(apps) {
6909
+ return apps.map((entry) => entry.name).sort();
6910
+ }
6911
+ function observedRuntimeServices(result) {
6912
+ if (!result) {
6913
+ return void 0;
6914
+ }
6915
+ if (result.status === "known") {
6916
+ return { status: "known", runningServices: result.runningServices };
6917
+ }
6918
+ return { status: "unknown", reason: result.reason };
6919
+ }
6920
+ function isDependencyOnlyApp(app) {
6921
+ return app.kind === "dependency";
6922
+ }
4782
6923
  function normalizeProcessEnv(env) {
4783
6924
  const normalized = {};
4784
6925
  for (const [key, value] of Object.entries(env)) {
@@ -4794,25 +6935,6 @@ function buildExecEnvironment(depEnv, processEnv = process.env) {
4794
6935
  ...depEnv
4795
6936
  };
4796
6937
  }
4797
- function buildTcpDepUrl(tcpProtocol, port) {
4798
- switch (tcpProtocol) {
4799
- case "postgres":
4800
- return `postgres://prisma:prisma@localhost:${port}/prisma`;
4801
- case "redis":
4802
- return `redis://localhost:${port}`;
4803
- case "mysql":
4804
- case "mariadb":
4805
- return `mysql://root@localhost:${port}`;
4806
- default:
4807
- return void 0;
4808
- }
4809
- }
4810
- function buildTcpDepShadowUrl(tcpProtocol, port) {
4811
- if (tcpProtocol === "postgres") {
4812
- return `postgres://prisma:prisma@localhost:${port}/shadow`;
4813
- }
4814
- return void 0;
4815
- }
4816
6938
  function isProcessRunning(pid) {
4817
6939
  if (!Number.isInteger(pid) || pid <= 0) {
4818
6940
  return false;
@@ -4825,7 +6947,7 @@ function isProcessRunning(pid) {
4825
6947
  }
4826
6948
  }
4827
6949
  function readProcessTree(rootPid) {
4828
- const result = (0, import_node_child_process9.spawnSync)("ps", ["-ax", "-o", "pid=,ppid="], { encoding: "utf-8" });
6950
+ const result = (0, import_node_child_process11.spawnSync)("ps", ["-ax", "-o", "pid=,ppid="], { encoding: "utf-8" });
4829
6951
  if (result.status !== 0) {
4830
6952
  return [rootPid];
4831
6953
  }
@@ -4907,7 +7029,7 @@ function detectListeningPorts(pids) {
4907
7029
  if (pids.length === 0) {
4908
7030
  return [];
4909
7031
  }
4910
- const result = (0, import_node_child_process9.spawnSync)(
7032
+ const result = (0, import_node_child_process11.spawnSync)(
4911
7033
  "lsof",
4912
7034
  ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", pids.join(",")],
4913
7035
  { encoding: "utf-8" }
@@ -4966,7 +7088,7 @@ async function runHostApp(repoPath, app, extraEnv = {}, secretManager, env, work
4966
7088
  app.hostRun.command,
4967
7089
  true
4968
7090
  ) : app.hostRun.command;
4969
- const child = (0, import_node_child_process9.spawn)(spawnCommand, {
7091
+ const child = (0, import_node_child_process11.spawn)(spawnCommand, {
4970
7092
  cwd: commandCwd,
4971
7093
  stdio: "inherit",
4972
7094
  shell: true,
@@ -5049,19 +7171,6 @@ async function runHostApp(repoPath, app, extraEnv = {}, secretManager, env, work
5049
7171
  throw new Error(`Host command for '${app.name}' exited with code ${exit.code}.`);
5050
7172
  }
5051
7173
  }
5052
- function uniqueApps(apps) {
5053
- const byName = /* @__PURE__ */ new Map();
5054
- for (const app of apps) {
5055
- byName.set(app.name, app);
5056
- }
5057
- return Array.from(byName.values());
5058
- }
5059
- function dependencyNames(apps) {
5060
- return apps.map((entry) => entry.name).sort();
5061
- }
5062
- function isDependencyOnlyApp(app) {
5063
- return app.kind === "dependency";
5064
- }
5065
7174
  async function shouldStartDependencies(appName, dependencies, yes) {
5066
7175
  if (dependencies.length === 0) {
5067
7176
  return false;
@@ -5071,7 +7180,7 @@ async function shouldStartDependencies(appName, dependencies, yes) {
5071
7180
  }
5072
7181
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
5073
7182
  throw new Error(
5074
- `App '${appName}' has dependencies (${dependencyNames(
7183
+ `App '${appName}' has dependencies (${dependencyNames2(
5075
7184
  dependencies
5076
7185
  ).join(", ")}). Re-run with --yes in non-interactive mode.`
5077
7186
  );
@@ -5079,7 +7188,7 @@ async function shouldStartDependencies(appName, dependencies, yes) {
5079
7188
  const rl = (0, import_promises2.createInterface)({ input: import_node_process2.stdin, output: import_node_process2.stdout });
5080
7189
  try {
5081
7190
  const answer = await rl.question(
5082
- `Start dependencies for '${appName}' (${dependencyNames(dependencies).join(", ")})? [y/N] `
7191
+ `Start dependencies for '${appName}' (${dependencyNames2(dependencies).join(", ")})? [y/N] `
5083
7192
  );
5084
7193
  return /^y(es)?$/i.test(answer.trim());
5085
7194
  } finally {
@@ -5110,74 +7219,53 @@ async function startAppDependencies(options) {
5110
7219
  const unsupportedDependencies = dependencies.filter((entry) => entry.runtime !== "docker");
5111
7220
  if (unsupportedDependencies.length > 0) {
5112
7221
  throw new Error(
5113
- `App '${app.name}' has host-runtime dependencies (${dependencyNames(
7222
+ `App '${app.name}' has host-runtime dependencies (${dependencyNames2(
5114
7223
  unsupportedDependencies
5115
7224
  ).join(", ")}). v1 only auto-starts docker dependencies. Start host dependencies manually before running this app.`
5116
7225
  );
5117
7226
  }
5118
- const selectedApps = uniqueApps([app, ...dependencies]);
5119
- const selectedDockerApps = selectedApps.filter(
5120
- (entry) => entry.runtime === "docker"
5121
- );
5122
7227
  const stopPolicy = options.stopPolicy ?? "always-stop-selected";
5123
7228
  const startedServices = [];
5124
7229
  let overlay;
5125
7230
  let startDependencies = false;
5126
- const hasTcpDeps = app.runtime === "host" && selectedDockerApps.some(
5127
- (entry) => entry.kind !== "dependency" && entry.protocol === "tcp"
5128
- );
5129
- if (selectedDockerApps.length > 0) {
5130
- overlay = prepareDockerOverlay(repoPath, app.name, selectedDockerApps, hasTcpDeps);
5131
- const services = selectedDockerApps.map((entry) => entry.docker.service);
5132
- const depServices = dependencies.filter((entry) => entry.runtime === "docker").map((entry) => entry.docker.service);
5133
- let runningServicesBefore = null;
5134
- let ownershipKnown = true;
5135
- const preRunResult = depServices.length > 0 ? queryRunningComposeServices(repoPath, overlay.composeFiles, overlay.overlayPath, depServices) : void 0;
5136
- const allDepsRunning = preRunResult?.status === "known" && depServices.every((s) => preRunResult.runningServices.has(s));
5137
- if (allDepsRunning) {
7231
+ const basePlan = planDependencyRuntime({ app, dependencies, stopPolicy });
7232
+ let observedPlan = basePlan;
7233
+ let startPlan = planDependencyStart(observedPlan, false);
7234
+ if (basePlan.selectedDockerApps.length > 0) {
7235
+ overlay = prepareDockerOverlay(repoPath, app.name, basePlan.selectedDockerApps, basePlan.hasTcpDeps);
7236
+ const preRunResult = basePlan.dependencyServices.length > 0 ? queryRunningComposeServices(repoPath, overlay.composeFiles, overlay.overlayPath, basePlan.dependencyServices) : void 0;
7237
+ observedPlan = planDependencyRuntime({
7238
+ app,
7239
+ dependencies,
7240
+ stopPolicy,
7241
+ runningServicesBefore: observedRuntimeServices(preRunResult)
7242
+ });
7243
+ if (observedPlan.allDependencyServicesRunning) {
5138
7244
  startDependencies = false;
5139
- } else if (dependencies.length > 0) {
7245
+ } else if (observedPlan.shouldPromptForDependencies) {
5140
7246
  startDependencies = await shouldStartDependencies(
5141
7247
  app.name,
5142
7248
  dependencies,
5143
7249
  Boolean(options.yes)
5144
7250
  );
5145
- } else {
5146
- startDependencies = false;
5147
7251
  }
5148
- const shouldRunComposeUp = app.runtime === "docker" || startDependencies;
5149
- if (shouldRunComposeUp) {
5150
- if (stopPolicy === "stop-only-newly-started") {
5151
- if (preRunResult?.status === "known") {
5152
- runningServicesBefore = preRunResult.runningServices;
5153
- } else if (preRunResult?.status === "unknown") {
5154
- ownershipKnown = false;
5155
- process.stderr.write(
5156
- `Warning: unable to determine which dependencies were already running before 'dev app exec'; leaving dependencies running after command exit to avoid stopping non-owned services. Details: ${preRunResult.reason}
5157
- `
5158
- );
5159
- }
5160
- }
5161
- runDockerComposeUp(repoPath, overlay.composeFiles, overlay.overlayPath, services);
5162
- if (stopPolicy === "stop-only-newly-started") {
5163
- const beforeSet = runningServicesBefore;
5164
- if (ownershipKnown && beforeSet) {
5165
- startedServices.push(...services.filter((service) => !beforeSet.has(service)));
5166
- }
5167
- } else if (app.runtime === "docker") {
5168
- const beforeSet = preRunResult?.status === "known" ? preRunResult.runningServices : void 0;
5169
- startedServices.push(...services.filter((service) => !beforeSet?.has(service)));
5170
- } else {
5171
- startedServices.push(...services);
5172
- }
5173
- runDockerComposeLogs(repoPath, overlay.composeFiles, overlay.overlayPath, services);
7252
+ startPlan = planDependencyStart(observedPlan, startDependencies);
7253
+ if (startPlan.ownershipWarning) {
7254
+ process.stderr.write(`Warning: ${startPlan.ownershipWarning}
7255
+ `);
7256
+ }
7257
+ if (startPlan.shouldRunComposeUp) {
7258
+ runDockerComposeUp(repoPath, overlay.composeFiles, overlay.overlayPath, observedPlan.services);
7259
+ startedServices.push(...startPlan.startedServices);
7260
+ runDockerComposeLogs(repoPath, overlay.composeFiles, overlay.overlayPath, observedPlan.services);
5174
7261
  }
5175
7262
  }
5176
- const depEnv = {};
5177
- if (hasTcpDeps && overlay) {
5178
- const tcpDeps = selectedDockerApps.filter(
7263
+ let depEnv = {};
7264
+ if (observedPlan.hasTcpDeps && overlay) {
7265
+ const tcpDeps = observedPlan.selectedDockerApps.filter(
5179
7266
  (entry) => entry.kind !== "dependency" && entry.protocol === "tcp"
5180
7267
  );
7268
+ const mappedDeps = [];
5181
7269
  let routerRestarted = false;
5182
7270
  for (const dep of tcpDeps) {
5183
7271
  const needsRestart = activateTcpProtocol(dep.tcpProtocol);
@@ -5196,18 +7284,11 @@ async function startAppDependencies(options) {
5196
7284
  dep.docker.service,
5197
7285
  dep.docker.internalPort
5198
7286
  );
7287
+ mappedDeps.push({ app: dep, mappedPort });
5199
7288
  if (mappedPort !== void 0) {
5200
7289
  const envPrefix = dep.name.toUpperCase().replace(/-/g, "_");
5201
- depEnv[`${envPrefix}_HOST`] = "localhost";
5202
- depEnv[`${envPrefix}_PORT`] = String(mappedPort);
5203
7290
  const url = buildTcpDepUrl(dep.tcpProtocol, mappedPort);
5204
- if (url) {
5205
- depEnv[`${envPrefix}_URL`] = url;
5206
- }
5207
7291
  const shadowUrl = buildTcpDepShadowUrl(dep.tcpProtocol, mappedPort);
5208
- if (shadowUrl) {
5209
- depEnv[`${envPrefix}_SHADOW_URL`] = shadowUrl;
5210
- }
5211
7292
  process.stdout.write(`Dependency ${dep.name} available at localhost:${mappedPort}
5212
7293
  `);
5213
7294
  if (url) {
@@ -5220,19 +7301,9 @@ async function startAppDependencies(options) {
5220
7301
  }
5221
7302
  }
5222
7303
  }
7304
+ depEnv = buildDependencyEnv(mappedDeps);
5223
7305
  }
5224
- for (const depRef of app.dependencies) {
5225
- if (depRef.envMap) {
5226
- for (const [target, source] of Object.entries(depRef.envMap)) {
5227
- if (!(source in depEnv)) {
5228
- throw new Error(
5229
- `envMap on dependency '${depRef.app}': source variable '${source}' not found in dependency env. Available: ${Object.keys(depEnv).join(", ") || "(none)"}`
5230
- );
5231
- }
5232
- depEnv[target] = depEnv[source];
5233
- }
5234
- }
5235
- }
7306
+ depEnv = applyDependencyEnvMap(app, depEnv);
5236
7307
  const stopDeps = () => {
5237
7308
  if (startedServices.length > 0 && overlay) {
5238
7309
  process.stdout.write(`Stopping dependencies (${startedServices.join(", ")})...
@@ -5254,12 +7325,12 @@ async function startAppDependencies(options) {
5254
7325
  secretManager: config.secretManager,
5255
7326
  overlay,
5256
7327
  startedServices,
5257
- dependencyApps: startDependencies ? dependencyNames(dependencies) : [],
7328
+ dependencyApps: startPlan.dependencyApps,
5258
7329
  stopDeps
5259
7330
  };
5260
7331
  }
5261
- function registerProxyRoute(repoPath, app, workspace) {
5262
- const { port, upstreamHost } = parseUpstream(app.upstream);
7332
+ function registerProxyRoute2(repoPath, app, workspace) {
7333
+ const { port, upstreamHost: upstreamHost2 } = parseUpstream(app.upstream);
5263
7334
  if (app.protocol === "tcp" && !isTLSEnabled()) {
5264
7335
  throw new Error(
5265
7336
  `App "${app.name}" is a TCP proxy route, which requires TLS (SNI). Run \`dev tls install\` first.`
@@ -5273,7 +7344,7 @@ function registerProxyRoute(repoPath, app, workspace) {
5273
7344
  startRouterStack();
5274
7345
  }
5275
7346
  }
5276
- removeHostRouteById(buildHostRouteId(repoPath, app.name));
7347
+ removeRouteForApp(repoPath, app.name);
5277
7348
  assertAppNotRunning(repoPath, { name: app.name, host: app.host });
5278
7349
  upsertHostRoute({
5279
7350
  name: app.name,
@@ -5282,7 +7353,7 @@ function registerProxyRoute(repoPath, app, workspace) {
5282
7353
  tcpProtocol: app.protocol === "tcp" ? app.tcpProtocol : void 0,
5283
7354
  repoPath,
5284
7355
  port,
5285
- upstreamHost,
7356
+ upstreamHost: upstreamHost2,
5286
7357
  mode: "proxy",
5287
7358
  workspace
5288
7359
  });
@@ -5304,7 +7375,7 @@ async function runConfiguredApp(options) {
5304
7375
  if (deps.app.runtime === "host") {
5305
7376
  await runHostApp(deps.repoPath, deps.app, deps.depEnv, deps.secretManager, options.env, deps.workspace);
5306
7377
  } else if (deps.app.runtime === "proxy") {
5307
- registerProxyRoute(deps.repoPath, deps.app, deps.workspace);
7378
+ registerProxyRoute2(deps.repoPath, deps.app, deps.workspace);
5308
7379
  } else if (deps.app.kind !== "dependency" && deps.app.protocol === "tcp") {
5309
7380
  const registryEntry = TCP_PROTOCOL_REGISTRY[deps.app.tcpProtocol];
5310
7381
  const port = registryEntry?.port ?? "?";
@@ -5354,7 +7425,7 @@ async function execWithAppEnv(options) {
5354
7425
  options.command[0],
5355
7426
  true
5356
7427
  );
5357
- child = (0, import_node_child_process9.spawn)(wrapped, {
7428
+ child = (0, import_node_child_process11.spawn)(wrapped, {
5358
7429
  cwd: deps.repoPath,
5359
7430
  stdio: "inherit",
5360
7431
  shell: true,
@@ -5368,7 +7439,7 @@ async function execWithAppEnv(options) {
5368
7439
  false
5369
7440
  );
5370
7441
  const [cmd, ...wrappedArgs] = wrapped;
5371
- child = (0, import_node_child_process9.spawn)(cmd, wrappedArgs, {
7442
+ child = (0, import_node_child_process11.spawn)(cmd, wrappedArgs, {
5372
7443
  cwd: deps.repoPath,
5373
7444
  stdio: "inherit",
5374
7445
  shell: false,
@@ -5376,7 +7447,7 @@ async function execWithAppEnv(options) {
5376
7447
  });
5377
7448
  }
5378
7449
  } else if (options.shell) {
5379
- child = (0, import_node_child_process9.spawn)(options.command[0], {
7450
+ child = (0, import_node_child_process11.spawn)(options.command[0], {
5380
7451
  cwd: deps.repoPath,
5381
7452
  stdio: "inherit",
5382
7453
  shell: true,
@@ -5384,7 +7455,7 @@ async function execWithAppEnv(options) {
5384
7455
  });
5385
7456
  } else {
5386
7457
  const [command, ...args] = options.command;
5387
- child = (0, import_node_child_process9.spawn)(command, args, {
7458
+ child = (0, import_node_child_process11.spawn)(command, args, {
5388
7459
  cwd: deps.repoPath,
5389
7460
  stdio: "inherit",
5390
7461
  shell: false,
@@ -5403,22 +7474,25 @@ async function execWithAppEnv(options) {
5403
7474
  deps.stopDeps();
5404
7475
  }
5405
7476
  }
5406
- var import_node_net, import_promises2, import_node_child_process9, import_node_process2, POLL_INTERVAL_MS, DEFAULT_PORT_TIMEOUT_MS, PROCESS_TERMINATION_GRACE_MS;
7477
+ var import_node_net, import_promises2, import_node_child_process11, import_node_process2, POLL_INTERVAL_MS, DEFAULT_PORT_TIMEOUT_MS, PROCESS_TERMINATION_GRACE_MS;
5407
7478
  var init_app_run = __esm({
5408
7479
  "src/core/app-run.ts"() {
5409
7480
  "use strict";
5410
7481
  import_node_net = __toESM(require("net"));
5411
7482
  import_promises2 = require("readline/promises");
5412
- import_node_child_process9 = require("child_process");
7483
+ import_node_child_process11 = require("child_process");
5413
7484
  import_node_process2 = require("process");
5414
7485
  init_docker_run();
5415
7486
  init_repo_config();
5416
7487
  init_host_routes();
5417
7488
  init_concurrency();
7489
+ init_route_state();
5418
7490
  init_docker();
5419
7491
  init_router();
5420
7492
  init_paths();
5421
7493
  init_tls();
7494
+ init_dependency_runtime_plan();
7495
+ init_dependency_runtime_plan();
5422
7496
  POLL_INTERVAL_MS = 1e3;
5423
7497
  DEFAULT_PORT_TIMEOUT_MS = 12e4;
5424
7498
  PROCESS_TERMINATION_GRACE_MS = 3e3;
@@ -5490,31 +7564,27 @@ __export(app_rm_exports, {
5490
7564
  async function runAppRmCommand(options) {
5491
7565
  const repoPath = resolveRepoPath(options.repo);
5492
7566
  if (options.keepConfig) {
5493
- try {
5494
- removeHostRouteByName(options.name, repoPath);
7567
+ if (removeRouteForApp(repoPath, options.name).length > 0) {
5495
7568
  process.stdout.write(`Freed route for '${options.name}' (config left intact)
5496
7569
  `);
5497
- } catch {
5498
- process.stdout.write(`No active route for '${options.name}' (config left intact)
5499
- `);
7570
+ return;
5500
7571
  }
7572
+ process.stdout.write(`No active route for '${options.name}' (config left intact)
7573
+ `);
5501
7574
  return;
5502
7575
  }
5503
7576
  const result = removeRepoApp(repoPath, options.name);
5504
7577
  if (!result.removed) {
5505
7578
  throw new Error(`App '${options.name}' not found in ${result.configPath}.`);
5506
7579
  }
5507
- try {
5508
- removeHostRouteByName(options.name, repoPath);
5509
- } catch {
5510
- }
7580
+ removeRouteForApp(repoPath, options.name);
5511
7581
  process.stdout.write(`Removed '${options.name}' from ${result.configPath}
5512
7582
  `);
5513
7583
  }
5514
7584
  var init_app_rm = __esm({
5515
7585
  "src/commands/app-rm.ts"() {
5516
7586
  "use strict";
5517
- init_host_routes();
7587
+ init_route_state();
5518
7588
  init_repo_config();
5519
7589
  }
5520
7590
  });
@@ -5541,11 +7611,11 @@ var init_tls2 = __esm({
5541
7611
 
5542
7612
  // src/core/workspace-lifecycle.ts
5543
7613
  function hasDevpod() {
5544
- const result = (0, import_node_child_process10.spawnSync)("devpod", ["version"], { encoding: "utf-8" });
7614
+ const result = (0, import_node_child_process12.spawnSync)("devpod", ["version"], { encoding: "utf-8" });
5545
7615
  return result.status === 0;
5546
7616
  }
5547
7617
  function listGitWorktrees(repoPath) {
5548
- const result = (0, import_node_child_process10.spawnSync)("git", ["-C", repoPath, "worktree", "list", "--porcelain"], {
7618
+ const result = (0, import_node_child_process12.spawnSync)("git", ["-C", repoPath, "worktree", "list", "--porcelain"], {
5549
7619
  encoding: "utf-8"
5550
7620
  });
5551
7621
  if (result.status !== 0) {
@@ -5569,18 +7639,7 @@ function listGitWorktrees(repoPath) {
5569
7639
  return worktrees;
5570
7640
  }
5571
7641
  function defaultWorktreePath(mainRepo, ws) {
5572
- return import_node_path10.default.join(import_node_path10.default.dirname(mainRepo), `${import_node_path10.default.basename(mainRepo)}-${ws}`);
5573
- }
5574
- function comparablePath(filePath) {
5575
- const resolved = import_node_path10.default.resolve(filePath);
5576
- try {
5577
- return import_node_fs12.default.realpathSync.native(resolved);
5578
- } catch {
5579
- return resolved;
5580
- }
5581
- }
5582
- function samePath(left, right) {
5583
- return comparablePath(left) === comparablePath(right);
7642
+ return import_node_path16.default.join(import_node_path16.default.dirname(mainRepo), `${import_node_path16.default.basename(mainRepo)}-${ws}`);
5584
7643
  }
5585
7644
  async function workspaceUp(branch, opts = {}) {
5586
7645
  const mainRepo = resolveRepoPath(opts.repoPath);
@@ -5588,16 +7647,16 @@ async function workspaceUp(branch, opts = {}) {
5588
7647
  if (!ws) {
5589
7648
  throw new Error(`Branch '${branch}' does not yield a valid workspace token.`);
5590
7649
  }
5591
- const worktreePath = opts.path ? import_node_path10.default.resolve(opts.path) : defaultWorktreePath(mainRepo, ws);
5592
- if (import_node_fs12.default.existsSync(worktreePath)) {
7650
+ const worktreePath = opts.path ? import_node_path16.default.resolve(opts.path) : defaultWorktreePath(mainRepo, ws);
7651
+ if (import_node_fs17.default.existsSync(worktreePath)) {
5593
7652
  process.stdout.write(`Worktree already exists: ${worktreePath}
5594
7653
  `);
5595
7654
  } else {
5596
- const add = (0, import_node_child_process10.spawnSync)("git", ["-C", mainRepo, "worktree", "add", worktreePath, branch], {
7655
+ const add = (0, import_node_child_process12.spawnSync)("git", ["-C", mainRepo, "worktree", "add", worktreePath, branch], {
5597
7656
  encoding: "utf-8"
5598
7657
  });
5599
7658
  if (add.status !== 0) {
5600
- const addNew = (0, import_node_child_process10.spawnSync)("git", ["-C", mainRepo, "worktree", "add", "-b", branch, worktreePath], {
7659
+ const addNew = (0, import_node_child_process12.spawnSync)("git", ["-C", mainRepo, "worktree", "add", "-b", branch, worktreePath], {
5601
7660
  encoding: "utf-8"
5602
7661
  });
5603
7662
  if (addNew.status !== 0) {
@@ -5609,7 +7668,7 @@ async function workspaceUp(branch, opts = {}) {
5609
7668
  `);
5610
7669
  }
5611
7670
  if (!opts.noDevpod && hasDevpod()) {
5612
- const dp = (0, import_node_child_process10.spawnSync)("devpod", ["up", worktreePath, "--id", ws, "--open-ide=false"], {
7671
+ const dp = (0, import_node_child_process12.spawnSync)("devpod", ["up", worktreePath, "--id", ws, "--open-ide=false"], {
5613
7672
  stdio: "inherit",
5614
7673
  env: { ...process.env, WORKSPACE: ws }
5615
7674
  });
@@ -5649,7 +7708,7 @@ ${urls.map((u) => ` ${u}`).join("\n")}
5649
7708
  }
5650
7709
  if (opts.open) {
5651
7710
  for (const url of openUrls) {
5652
- const opened = (0, import_node_child_process10.spawnSync)("open", [url], { encoding: "utf-8" });
7711
+ const opened = (0, import_node_child_process12.spawnSync)("open", [url], { encoding: "utf-8" });
5653
7712
  if (opened.status !== 0) {
5654
7713
  const detail = [opened.stdout, opened.stderr].filter(Boolean).join("\n").trim();
5655
7714
  throw new Error(`Unable to open '${url}': ${detail || "unknown error"}`);
@@ -5662,10 +7721,10 @@ ${urls.map((u) => ` ${u}`).join("\n")}
5662
7721
  function workspaceLs(repoPath) {
5663
7722
  const mainRepo = resolveRepoPath(repoPath);
5664
7723
  const worktrees = listGitWorktrees(mainRepo);
5665
- const routes = listHostRouteState();
7724
+ const routesByWorktreePath = listRoutesForWorktreePaths(worktrees.map((worktree) => worktree.path));
5666
7725
  return worktrees.map((wt) => {
5667
7726
  const workspace = isLinkedWorktree(wt.path) && wt.branch ? wsFromBranch(wt.branch) : void 0;
5668
- const wsRoutes = routes.filter((route) => samePath(route.repoPath, wt.path));
7727
+ const wsRoutes = routesByWorktreePath.get(wt.path) ?? [];
5669
7728
  return {
5670
7729
  workspace,
5671
7730
  branch: wt.branch,
@@ -5685,20 +7744,15 @@ function workspaceDown(target, opts = {}) {
5685
7744
  (wt) => wt.branch && wsFromBranch(wt.branch) === ws
5686
7745
  );
5687
7746
  const worktreePath = match?.path ?? defaultWorktreePath(mainRepo, ws);
5688
- const routes = listHostRouteState().filter(
5689
- (route) => route.workspace === ws && samePath(route.repoPath, worktreePath)
5690
- );
5691
- for (const route of routes) {
5692
- removeHostRouteById(route.id);
5693
- }
7747
+ const routes = removeWorkspaceRoutesForWorktree(ws, worktreePath);
5694
7748
  process.stdout.write(`Freed ${routes.length} route(s) for workspace '${ws}'.
5695
7749
  `);
5696
7750
  if (!opts.keepDevpod && hasDevpod()) {
5697
- (0, import_node_child_process10.spawnSync)("devpod", ["stop", ws], { stdio: "inherit" });
7751
+ (0, import_node_child_process12.spawnSync)("devpod", ["stop", ws], { stdio: "inherit" });
5698
7752
  }
5699
7753
  if (!opts.keepWorktree) {
5700
- if (import_node_fs12.default.existsSync(worktreePath) && worktreePath !== mainRepo) {
5701
- const rm = (0, import_node_child_process10.spawnSync)("git", ["-C", mainRepo, "worktree", "remove", worktreePath], {
7754
+ if (import_node_fs17.default.existsSync(worktreePath) && worktreePath !== mainRepo) {
7755
+ const rm = (0, import_node_child_process12.spawnSync)("git", ["-C", mainRepo, "worktree", "remove", worktreePath], {
5702
7756
  encoding: "utf-8"
5703
7757
  });
5704
7758
  if (rm.status === 0) {
@@ -5714,15 +7768,15 @@ function workspaceDown(target, opts = {}) {
5714
7768
  }
5715
7769
  return { freedRoutes: routes.length, workspace: ws };
5716
7770
  }
5717
- var import_node_fs12, import_node_path10, import_node_child_process10;
7771
+ var import_node_fs17, import_node_path16, import_node_child_process12;
5718
7772
  var init_workspace_lifecycle = __esm({
5719
7773
  "src/core/workspace-lifecycle.ts"() {
5720
7774
  "use strict";
5721
- import_node_fs12 = __toESM(require("fs"));
5722
- import_node_path10 = __toESM(require("path"));
5723
- import_node_child_process10 = require("child_process");
7775
+ import_node_fs17 = __toESM(require("fs"));
7776
+ import_node_path16 = __toESM(require("path"));
7777
+ import_node_child_process12 = require("child_process");
5724
7778
  init_app_run();
5725
- init_host_routes();
7779
+ init_route_state();
5726
7780
  init_repo_config();
5727
7781
  init_workspace();
5728
7782
  }
@@ -5812,12 +7866,12 @@ var init_version = __esm({
5812
7866
 
5813
7867
  // src/cli.ts
5814
7868
  var import_commander = require("commander");
5815
- var CLI_VERSION = true ? "0.0.22" : "0.0.0-dev";
7869
+ var CLI_VERSION = true ? "0.0.23" : "0.0.0-dev";
5816
7870
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
5817
- function withErrorHandling(action) {
7871
+ function withErrorHandling(action2) {
5818
7872
  return async (...args) => {
5819
7873
  try {
5820
- await action(...args);
7874
+ await action2(...args);
5821
7875
  } catch (error) {
5822
7876
  const message = error instanceof Error ? error.message : String(error);
5823
7877
  process.stderr.write(`Error: ${message}
@@ -5837,6 +7891,10 @@ program.command("upgrade").description("Show upgrade targets from .devrouter.yml
5837
7891
  const { runUpgradeCommand: runUpgradeCommand2 } = await Promise.resolve().then(() => (init_upgrade2(), upgrade_exports));
5838
7892
  await runUpgradeCommand2({ targetVersion, repo: options.repo });
5839
7893
  }));
7894
+ program.command("setup").description("Run first-time devrouter machine setup and report diagnostics").option("--repo <path>", "Repository path for final diagnostics (defaults to current directory)").option("--yes", "Confirm non-interactive setup actions").option("--json", "Output JSON").action(withErrorHandling(async (options) => {
7895
+ const { runSetupCommand: runSetupCommand2 } = await Promise.resolve().then(() => (init_setup2(), setup_exports));
7896
+ await runSetupCommand2(options);
7897
+ }));
5840
7898
  program.command("up").description("Ensure devnet and start shared Traefik (reserves 80/443/5432)").action(withErrorHandling(async () => {
5841
7899
  const { runUpCommand: runUpCommand2 } = await Promise.resolve().then(() => (init_up(), up_exports));
5842
7900
  await runUpCommand2();
@@ -5870,10 +7928,23 @@ repoCommand.command("init").description("Initialize `.devrouter.yml` in a reposi
5870
7928
  const { runRepoInitCommand: runRepoInitCommand2 } = await Promise.resolve().then(() => (init_repo_init(), repo_init_exports));
5871
7929
  await runRepoInitCommand2({ ...options, installedVersion: CLI_VERSION });
5872
7930
  }));
7931
+ repoCommand.command("inspect").description("Inspect repository stack facts for agent-native onboarding").option("--repo <path>", "Repository path (defaults to current directory)").option("--json", "Output JSON").action(withErrorHandling(async (options) => {
7932
+ const { runRepoInspectCommand: runRepoInspectCommand2 } = await Promise.resolve().then(() => (init_repo_inspect2(), repo_inspect_exports));
7933
+ await runRepoInspectCommand2(options);
7934
+ }));
5873
7935
  repoCommand.command("agents").description("Write/update devrouter section in the repo's AGENTS.md").option("--repo <path>", "Repository path (defaults to current directory)").option("--with-linear", "Also install optional Linear workflow skill/assets and AGENTS section").action(withErrorHandling(async (options) => {
5874
7936
  const { runRepoAgentsCommand: runRepoAgentsCommand2 } = await Promise.resolve().then(() => (init_repo_agents(), repo_agents_exports));
5875
7937
  await runRepoAgentsCommand2(options);
5876
7938
  }));
7939
+ var repoDevcontainerCommand = repoCommand.command("devcontainer").description("Manage devcontainer onboarding");
7940
+ repoDevcontainerCommand.command("write").description("Plan or write a conservative devcontainer scaffold").option("--repo <path>", "Repository path (defaults to current directory)").option("--dry-run", "Print the planned file changes without writing").option("--yes", "Write files when no conflicts are detected").option("--json", "Output JSON").action(withErrorHandling(async (options) => {
7941
+ const { runRepoDevcontainerWriteCommand: runRepoDevcontainerWriteCommand2 } = await Promise.resolve().then(() => (init_repo_devcontainer(), repo_devcontainer_exports));
7942
+ await runRepoDevcontainerWriteCommand2({ ...options, installedVersion: CLI_VERSION });
7943
+ }));
7944
+ repoDevcontainerCommand.command("verify").description("Verify devcontainer onboarding state and emit PR evidence").option("--repo <path>", "Repository path (defaults to current directory)").option("--live", "Register proxy routes and probe HTTP routes").option("--yes", "Confirm live verification actions").option("--json", "Output JSON").action(withErrorHandling(async (options) => {
7945
+ const { runRepoDevcontainerVerifyCommand: runRepoDevcontainerVerifyCommand2 } = await Promise.resolve().then(() => (init_repo_devcontainer(), repo_devcontainer_exports));
7946
+ await runRepoDevcontainerVerifyCommand2(options);
7947
+ }));
5877
7948
  var appCommand = program.command("app").description("Manage app entries and runtime actions from `.devrouter.yml`");
5878
7949
  appCommand.command("add").description("Add or update one app definition in `.devrouter.yml`").requiredOption("--name <name>", "App name").option("--kind <kind>", "app or dependency", "app").option("--host <host>", "Hostname ending with .localhost (required for --kind app)").option("--protocol <protocol>", "http or tcp (required for --kind app)").option("--runtime <runtime>", "host, docker, or proxy (required for --kind app, optional for --kind dependency)").option("--service <service>", "Docker service name (runtime=docker)").option("--port <port>", "Internal port (runtime=docker)", (value) => Number(value)).option("--upstream <host:port>", "Already-running upstream to route to (runtime=proxy)").option("--compose-file <file>", "Compose file path (repeatable)", (value, prev) => {
5879
7950
  const next = prev ?? [];