@devrouter/cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +194 -0
  2. package/dist/dev.js +2772 -0
  3. package/package.json +45 -0
package/dist/dev.js ADDED
@@ -0,0 +1,2772 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __esm = (fn, res) => function __init() {
10
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
11
+ };
12
+ var __export = (target, all) => {
13
+ for (var name in all)
14
+ __defProp(target, name, { get: all[name], enumerable: true });
15
+ };
16
+ var __copyProps = (to, from, except, desc) => {
17
+ if (from && typeof from === "object" || typeof from === "function") {
18
+ for (let key of __getOwnPropNames(from))
19
+ if (!__hasOwnProp.call(to, key) && key !== except)
20
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
21
+ }
22
+ return to;
23
+ };
24
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
25
+ // If the importer is in node compatibility mode or this is not an ESM
26
+ // file that has been converted to a CommonJS file using a Babel-
27
+ // compatible transform (i.e. "__esModule" has not been set), then set
28
+ // "default" to the CommonJS "module.exports" for node compatibility.
29
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
30
+ mod
31
+ ));
32
+
33
+ // src/core/repo-config.ts
34
+ function ensureObject(value, pathLabel) {
35
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
36
+ throw new Error(`${pathLabel} must be an object.`);
37
+ }
38
+ return value;
39
+ }
40
+ function ensureAllowedKeys(value, allowedKeys, pathLabel) {
41
+ const allowed = new Set(allowedKeys);
42
+ for (const key of Object.keys(value)) {
43
+ if (!allowed.has(key)) {
44
+ throw new Error(`${pathLabel}.${key} is not supported.`);
45
+ }
46
+ }
47
+ }
48
+ function toStringOrThrow(value, pathLabel) {
49
+ if (typeof value !== "string" || value.trim().length === 0) {
50
+ throw new Error(`${pathLabel} must be a non-empty string.`);
51
+ }
52
+ return value.trim();
53
+ }
54
+ function toStringArray(value, pathLabel) {
55
+ if (value === void 0) {
56
+ return [];
57
+ }
58
+ if (!Array.isArray(value)) {
59
+ throw new Error(`${pathLabel} must be an array of strings.`);
60
+ }
61
+ return value.map((item, index) => toStringOrThrow(item, `${pathLabel}[${index}]`));
62
+ }
63
+ function toIntegerOrThrow(value, pathLabel) {
64
+ const numberValue = Number(value);
65
+ if (!Number.isInteger(numberValue) || numberValue <= 0) {
66
+ throw new Error(`${pathLabel} must be a positive integer.`);
67
+ }
68
+ return numberValue;
69
+ }
70
+ function parseDependencies(value, pathLabel) {
71
+ if (value === void 0) {
72
+ return [];
73
+ }
74
+ if (!Array.isArray(value)) {
75
+ throw new Error(`${pathLabel} must be an array.`);
76
+ }
77
+ return value.map((entry, index) => {
78
+ const objectValue = ensureObject(entry, `${pathLabel}[${index}]`);
79
+ ensureAllowedKeys(objectValue, ["app"], `${pathLabel}[${index}]`);
80
+ return { app: toStringOrThrow(objectValue.app, `${pathLabel}[${index}].app`) };
81
+ });
82
+ }
83
+ function parseHostStrategy(value, pathLabel) {
84
+ if (value === void 0) {
85
+ return { ...DEFAULT_HOST_STRATEGY };
86
+ }
87
+ const objectValue = ensureObject(value, pathLabel);
88
+ ensureAllowedKeys(objectValue, ["type", "denyPorts", "allowPortRange"], pathLabel);
89
+ const type = toStringOrThrow(objectValue.type ?? "auto", `${pathLabel}.type`);
90
+ if (type !== "auto") {
91
+ throw new Error(`${pathLabel}.type must be 'auto'.`);
92
+ }
93
+ let denyPorts = [...DEFAULT_HOST_STRATEGY.denyPorts];
94
+ if (objectValue.denyPorts !== void 0) {
95
+ if (!Array.isArray(objectValue.denyPorts)) {
96
+ throw new Error(`${pathLabel}.denyPorts must be an array.`);
97
+ }
98
+ denyPorts = objectValue.denyPorts.map(
99
+ (entry, index) => toIntegerOrThrow(entry, `${pathLabel}.denyPorts[${index}]`)
100
+ );
101
+ }
102
+ const allowPortRange = objectValue.allowPortRange === void 0 ? DEFAULT_HOST_STRATEGY.allowPortRange : toStringOrThrow(objectValue.allowPortRange, `${pathLabel}.allowPortRange`);
103
+ return {
104
+ type: "auto",
105
+ denyPorts,
106
+ allowPortRange
107
+ };
108
+ }
109
+ function parseDockerConfig(value, pathLabel) {
110
+ const objectValue = ensureObject(value, pathLabel);
111
+ ensureAllowedKeys(objectValue, ["service", "internalPort", "composeFiles", "router"], pathLabel);
112
+ const composeFiles = toStringArray(objectValue.composeFiles, `${pathLabel}.composeFiles`);
113
+ return {
114
+ service: toStringOrThrow(objectValue.service, `${pathLabel}.service`),
115
+ internalPort: toIntegerOrThrow(objectValue.internalPort, `${pathLabel}.internalPort`),
116
+ composeFiles: composeFiles.length > 0 ? composeFiles : ["docker-compose.yml"],
117
+ router: objectValue.router === void 0 ? void 0 : toStringOrThrow(objectValue.router, `${pathLabel}.router`)
118
+ };
119
+ }
120
+ function parseApp(value, index) {
121
+ const pathLabel = `apps[${index}]`;
122
+ const objectValue = ensureObject(value, pathLabel);
123
+ ensureAllowedKeys(
124
+ objectValue,
125
+ ["name", "host", "protocol", "runtime", "hostRun", "docker", "tcpProtocol", "dependencies"],
126
+ pathLabel
127
+ );
128
+ const name = toStringOrThrow(objectValue.name, `${pathLabel}.name`);
129
+ const host = toStringOrThrow(objectValue.host, `${pathLabel}.host`).toLowerCase();
130
+ if (!host.endsWith(".localhost")) {
131
+ throw new Error(`${pathLabel}.host must end with .localhost.`);
132
+ }
133
+ if (!VALID_HOSTNAME_RE.test(host)) {
134
+ throw new Error(`${pathLabel}.host contains invalid characters. Only lowercase alphanumerics and hyphens are allowed.`);
135
+ }
136
+ const protocol = toStringOrThrow(objectValue.protocol, `${pathLabel}.protocol`);
137
+ const runtime = toStringOrThrow(objectValue.runtime, `${pathLabel}.runtime`);
138
+ const dependencies = parseDependencies(objectValue.dependencies, `${pathLabel}.dependencies`);
139
+ if (runtime === "host") {
140
+ if (protocol !== "http") {
141
+ throw new Error(`${pathLabel}: host runtime currently supports only protocol=http.`);
142
+ }
143
+ const hostRun = ensureObject(objectValue.hostRun, `${pathLabel}.hostRun`);
144
+ ensureAllowedKeys(hostRun, ["command", "cwd", "strategy"], `${pathLabel}.hostRun`);
145
+ const command = toStringOrThrow(hostRun.command, `${pathLabel}.hostRun.command`);
146
+ if (command.length > MAX_COMMAND_LENGTH) {
147
+ throw new Error(`${pathLabel}.hostRun.command exceeds maximum length of ${MAX_COMMAND_LENGTH} characters.`);
148
+ }
149
+ return {
150
+ name,
151
+ host,
152
+ protocol: "http",
153
+ runtime: "host",
154
+ dependencies,
155
+ hostRun: {
156
+ command,
157
+ cwd: hostRun.cwd === void 0 ? "." : toStringOrThrow(hostRun.cwd, `${pathLabel}.hostRun.cwd`),
158
+ strategy: parseHostStrategy(hostRun.strategy, `${pathLabel}.hostRun.strategy`)
159
+ }
160
+ };
161
+ }
162
+ if (runtime === "docker") {
163
+ const docker = parseDockerConfig(objectValue.docker, `${pathLabel}.docker`);
164
+ if (protocol === "http") {
165
+ return {
166
+ name,
167
+ host,
168
+ protocol: "http",
169
+ runtime: "docker",
170
+ dependencies,
171
+ docker
172
+ };
173
+ }
174
+ if (protocol === "tcp") {
175
+ const tcpProtocol = toStringOrThrow(objectValue.tcpProtocol, `${pathLabel}.tcpProtocol`);
176
+ if (tcpProtocol !== "postgres") {
177
+ throw new Error(`${pathLabel}.tcpProtocol must be 'postgres' for protocol=tcp.`);
178
+ }
179
+ return {
180
+ name,
181
+ host,
182
+ protocol: "tcp",
183
+ tcpProtocol: "postgres",
184
+ runtime: "docker",
185
+ dependencies,
186
+ docker
187
+ };
188
+ }
189
+ }
190
+ throw new Error(`${pathLabel} has unsupported protocol/runtime combination.`);
191
+ }
192
+ function parseConfig(raw, configPath) {
193
+ const root = ensureObject(raw, configPath);
194
+ ensureAllowedKeys(root, ["version", "project", "apps"], configPath);
195
+ const version = toIntegerOrThrow(root.version, `${configPath}.version`);
196
+ if (version !== 1) {
197
+ throw new Error(`${configPath}.version must be 1.`);
198
+ }
199
+ if (root.project !== void 0) {
200
+ const project = ensureObject(root.project, `${configPath}.project`);
201
+ ensureAllowedKeys(project, ["name"], `${configPath}.project`);
202
+ if (project.name !== void 0 && typeof project.name !== "string") {
203
+ throw new Error(`${configPath}.project.name must be a string.`);
204
+ }
205
+ }
206
+ if (!Array.isArray(root.apps)) {
207
+ throw new Error(`${configPath}.apps must be an array.`);
208
+ }
209
+ const apps = root.apps.map((app, index) => parseApp(app, index));
210
+ const seenNames = /* @__PURE__ */ new Set();
211
+ for (const app of apps) {
212
+ if (seenNames.has(app.name)) {
213
+ throw new Error(`${configPath}.apps has duplicate name '${app.name}'.`);
214
+ }
215
+ seenNames.add(app.name);
216
+ }
217
+ return {
218
+ version: 1,
219
+ project: root.project && typeof root.project === "object" ? { name: root.project.name } : void 0,
220
+ apps
221
+ };
222
+ }
223
+ function renderConfig(config) {
224
+ return import_yaml.default.stringify(config, { lineWidth: 0 });
225
+ }
226
+ function resolveRepoPath(repoPath) {
227
+ return import_node_path.default.resolve(repoPath ?? process.cwd());
228
+ }
229
+ function getRepoConfigPath(repoPath) {
230
+ return import_node_path.default.join(resolveRepoPath(repoPath), CONFIG_FILE_NAME);
231
+ }
232
+ function loadRepoConfig(repoPath) {
233
+ const resolvedRepoPath = resolveRepoPath(repoPath);
234
+ const configPath = getRepoConfigPath(resolvedRepoPath);
235
+ if (!import_node_fs.default.existsSync(configPath)) {
236
+ throw new Error(
237
+ `Missing ${CONFIG_FILE_NAME} in ${resolvedRepoPath}. Run 'dev repo init --repo ${resolvedRepoPath}' first.`
238
+ );
239
+ }
240
+ const raw = import_node_fs.default.readFileSync(configPath, "utf-8");
241
+ const parsed = import_yaml.default.parse(raw);
242
+ return parseConfig(parsed ?? {}, configPath);
243
+ }
244
+ function saveRepoConfig(repoPath, config) {
245
+ const resolvedRepoPath = resolveRepoPath(repoPath);
246
+ const configPath = getRepoConfigPath(resolvedRepoPath);
247
+ const validated = parseConfig(config, configPath);
248
+ import_node_fs.default.writeFileSync(configPath, renderConfig(validated), "utf-8");
249
+ }
250
+ function initRepoConfig(repoPath) {
251
+ const resolvedRepoPath = resolveRepoPath(repoPath);
252
+ const configPath = getRepoConfigPath(resolvedRepoPath);
253
+ if (import_node_fs.default.existsSync(configPath)) {
254
+ return { repoPath: resolvedRepoPath, configPath, created: false };
255
+ }
256
+ const initialConfig = {
257
+ version: 1,
258
+ project: {
259
+ name: import_node_path.default.basename(resolvedRepoPath)
260
+ },
261
+ apps: []
262
+ };
263
+ import_node_fs.default.writeFileSync(configPath, renderConfig(initialConfig), "utf-8");
264
+ return { repoPath: resolvedRepoPath, configPath, created: true };
265
+ }
266
+ function buildAppFromOptions(options) {
267
+ const host = options.host.toLowerCase();
268
+ if (!host.endsWith(".localhost")) {
269
+ throw new Error("--host must end with .localhost");
270
+ }
271
+ if (!VALID_HOSTNAME_RE.test(host)) {
272
+ throw new Error("--host contains invalid characters. Only lowercase alphanumerics and hyphens are allowed.");
273
+ }
274
+ const dependencies = options.dependsOn.map((app) => ({ app }));
275
+ if (options.runtime === "host") {
276
+ if (options.protocol !== "http") {
277
+ throw new Error("--runtime host currently supports only --protocol http");
278
+ }
279
+ if (!options.command) {
280
+ throw new Error("--command is required when --runtime host");
281
+ }
282
+ return {
283
+ name: options.name,
284
+ host,
285
+ protocol: "http",
286
+ runtime: "host",
287
+ dependencies,
288
+ hostRun: {
289
+ command: options.command,
290
+ cwd: options.cwd ?? ".",
291
+ strategy: { ...DEFAULT_HOST_STRATEGY }
292
+ }
293
+ };
294
+ }
295
+ if (!options.service) {
296
+ throw new Error("--service is required when --runtime docker");
297
+ }
298
+ if (!options.port || !Number.isInteger(options.port) || options.port <= 0) {
299
+ throw new Error("--port must be a positive integer when --runtime docker");
300
+ }
301
+ const docker = {
302
+ service: options.service,
303
+ internalPort: options.port,
304
+ composeFiles: options.composeFiles.length > 0 ? options.composeFiles : ["docker-compose.yml"],
305
+ router: options.router
306
+ };
307
+ if (options.protocol === "http") {
308
+ return {
309
+ name: options.name,
310
+ host,
311
+ protocol: "http",
312
+ runtime: "docker",
313
+ dependencies,
314
+ docker
315
+ };
316
+ }
317
+ const tcpProtocol = options.tcpProtocol ?? "postgres";
318
+ if (tcpProtocol !== "postgres") {
319
+ throw new Error("--tcp-protocol must be postgres for --protocol tcp");
320
+ }
321
+ return {
322
+ name: options.name,
323
+ host,
324
+ protocol: "tcp",
325
+ tcpProtocol: "postgres",
326
+ runtime: "docker",
327
+ dependencies,
328
+ docker
329
+ };
330
+ }
331
+ function upsertRepoApp(repoPath, options) {
332
+ const resolvedRepoPath = resolveRepoPath(repoPath);
333
+ const config = loadRepoConfig(resolvedRepoPath);
334
+ const app = buildAppFromOptions(options);
335
+ const apps = config.apps.filter((existing) => existing.name !== app.name);
336
+ apps.push(app);
337
+ apps.sort((a, b) => a.name.localeCompare(b.name));
338
+ const next = {
339
+ ...config,
340
+ apps
341
+ };
342
+ saveRepoConfig(resolvedRepoPath, next);
343
+ return {
344
+ configPath: getRepoConfigPath(resolvedRepoPath),
345
+ app
346
+ };
347
+ }
348
+ function removeRepoApp(repoPath, name) {
349
+ const resolvedRepoPath = resolveRepoPath(repoPath);
350
+ const config = loadRepoConfig(resolvedRepoPath);
351
+ const apps = config.apps.filter((app) => app.name !== name);
352
+ const removed = apps.length !== config.apps.length;
353
+ if (!removed) {
354
+ return {
355
+ configPath: getRepoConfigPath(resolvedRepoPath),
356
+ removed: false
357
+ };
358
+ }
359
+ saveRepoConfig(resolvedRepoPath, {
360
+ ...config,
361
+ apps
362
+ });
363
+ return {
364
+ configPath: getRepoConfigPath(resolvedRepoPath),
365
+ removed: true
366
+ };
367
+ }
368
+ function resolveAppByName(repoPath, name) {
369
+ const config = loadRepoConfig(repoPath);
370
+ const app = config.apps.find((entry) => entry.name === name);
371
+ if (!app) {
372
+ const available = config.apps.map((entry) => entry.name).join(", ");
373
+ throw new Error(`App '${name}' not found in ${getRepoConfigPath(repoPath)}. Available: ${available || "(none)"}`);
374
+ }
375
+ return { config, app };
376
+ }
377
+ function resolveAppDependencies(config, app) {
378
+ const results = [];
379
+ const seen = /* @__PURE__ */ new Set();
380
+ const visiting = /* @__PURE__ */ new Set([app.name]);
381
+ const byName = new Map(config.apps.map((entry) => [entry.name, entry]));
382
+ const visit = (name, chain) => {
383
+ if (visiting.has(name)) {
384
+ throw new Error(`Dependency cycle detected: ${[...chain, name].join(" -> ")}`);
385
+ }
386
+ if (seen.has(name)) {
387
+ return;
388
+ }
389
+ visiting.add(name);
390
+ const dependency = byName.get(name);
391
+ if (!dependency) {
392
+ throw new Error(`Dependency '${name}' referenced by '${app.name}' does not exist in config.`);
393
+ }
394
+ results.push(dependency);
395
+ for (const nested of dependency.dependencies) {
396
+ visit(nested.app, [...chain, name]);
397
+ }
398
+ visiting.delete(name);
399
+ seen.add(name);
400
+ };
401
+ for (const dependency of app.dependencies) {
402
+ visit(dependency.app, [app.name]);
403
+ }
404
+ return results;
405
+ }
406
+ var import_node_fs, import_node_path, import_yaml, CONFIG_FILE_NAME, VALID_HOSTNAME_RE, MAX_COMMAND_LENGTH, DEFAULT_HOST_STRATEGY;
407
+ var init_repo_config = __esm({
408
+ "src/core/repo-config.ts"() {
409
+ "use strict";
410
+ import_node_fs = __toESM(require("fs"));
411
+ import_node_path = __toESM(require("path"));
412
+ import_yaml = __toESM(require("yaml"));
413
+ CONFIG_FILE_NAME = ".devrouter.yml";
414
+ VALID_HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.localhost$/;
415
+ MAX_COMMAND_LENGTH = 4096;
416
+ DEFAULT_HOST_STRATEGY = {
417
+ type: "auto",
418
+ denyPorts: [80, 443, 5432],
419
+ allowPortRange: "1024-65535"
420
+ };
421
+ }
422
+ });
423
+
424
+ // src/core/ai-prompt.ts
425
+ function normalizeEntriesJson(input2) {
426
+ if (!input2) {
427
+ return "<JSON_ARRAY_OF_APP_ENTRIES>";
428
+ }
429
+ let parsed;
430
+ try {
431
+ parsed = JSON.parse(input2);
432
+ } catch {
433
+ throw new Error("--entries-json must be valid JSON.");
434
+ }
435
+ if (!Array.isArray(parsed)) {
436
+ throw new Error("--entries-json must be a JSON array.");
437
+ }
438
+ return JSON.stringify(parsed);
439
+ }
440
+ function renderCommandIntentSection() {
441
+ const lines = COMMAND_INTENTS.map((entry) => `- ${entry.command}: ${entry.purpose}`);
442
+ return ["Command intent reference:", ...lines].join("\n");
443
+ }
444
+ function buildOnboardingPrompt(options = {}) {
445
+ const repoPath = resolveRepoPath(options.repo);
446
+ const entriesJson = normalizeEntriesJson(options.entriesJson);
447
+ return [
448
+ "You are adapting an existing repository to devrouter using the unified .devrouter.yml model.",
449
+ "",
450
+ "Objective:",
451
+ "- Configure stable local hostnames (*.localhost) for app/database access through the shared devrouter.",
452
+ "- Avoid manual/random host ports for app access.",
453
+ "- Keep repo changes minimal, explicit, and reproducible.",
454
+ "",
455
+ "How devrouter works (must respect):",
456
+ "- Shared Traefik router owns host ports 80 (HTTP), 443 (HTTPS), and 5432 (Postgres TCP).",
457
+ "- Per-repo source of truth is REPO_PATH/.devrouter.yml only.",
458
+ "- Global generated/runtime artifacts are managed under ~/.config/devrouter (do not edit these manually).",
459
+ "",
460
+ "Inputs:",
461
+ `- REPO_PATH=${repoPath}`,
462
+ `- ENTRIES_JSON=${entriesJson}`,
463
+ "",
464
+ "Entry schema (each object):",
465
+ "- name: string (unique in repo)",
466
+ "- host: <name>.localhost",
467
+ '- protocol: "http" | "tcp"',
468
+ '- runtime: "host" | "docker"',
469
+ '- dependencies: [{ app: "<name>" }] (optional)',
470
+ "- if runtime=host:",
471
+ " - hostRun.command: string",
472
+ " - hostRun.cwd: string",
473
+ ' - hostRun.strategy.type: "auto"',
474
+ " - hostRun.strategy.denyPorts: [80, 443, 5432]",
475
+ ' - hostRun.strategy.allowPortRange: "1024-65535"',
476
+ "- if runtime=docker:",
477
+ " - docker.service: string",
478
+ " - docker.internalPort: number",
479
+ " - docker.composeFiles: string[]",
480
+ " - optional docker.router: string",
481
+ "- if protocol=tcp:",
482
+ ' - tcpProtocol: "postgres"',
483
+ "",
484
+ "Validation rules to enforce:",
485
+ "- host must end with .localhost",
486
+ "- runtime=host supports protocol=http only",
487
+ "- protocol=tcp requires runtime=docker and tcpProtocol=postgres",
488
+ "- unknown keys are not allowed (strict schema)",
489
+ "",
490
+ "Runtime behavior to account for:",
491
+ "- Docker dependencies can be auto-started by dev app run.",
492
+ "- Host-runtime dependencies are NOT auto-started in v1 (must be started manually).",
493
+ "- Postgres multiplexing on shared :5432 requires TLS/SNI.",
494
+ "- For TCP/Postgres, expect clients to use sslmode=require (or stricter).",
495
+ "",
496
+ "Required workflow:",
497
+ "1) Inspect repository structure first (compose files, scripts, app folders, existing dev docs).",
498
+ "2) Create/update only REPO_PATH/.devrouter.yml.",
499
+ "3) Keep edits minimal and idempotent.",
500
+ "4) Do not modify unrelated files/services.",
501
+ "5) If required info is missing or ambiguous, stop and ask targeted questions.",
502
+ "",
503
+ "Validation commands to run/report:",
504
+ "- dev app ls --repo <REPO_PATH>",
505
+ "- For each entry (when safe): dev app run <name> --repo <REPO_PATH> --yes",
506
+ "- dev doctor --repo <REPO_PATH> --json",
507
+ "- dev ls",
508
+ "- For HTTP entries: curl -I http://<host>",
509
+ '- For TCP postgres entries: provide connection hint (example: psql "... sslmode=require")',
510
+ "",
511
+ "Output format (strict):",
512
+ "1) Repository structure summary relevant to routing.",
513
+ "2) Proposed app mapping (name/host/protocol/runtime/deps) with assumptions.",
514
+ "3) Exact file changes made to .devrouter.yml.",
515
+ "4) Concise diff summary.",
516
+ "5) Validation commands run + key outputs.",
517
+ "6) Unresolved questions/risks (if any).",
518
+ "7) Definition-of-done checklist status:",
519
+ " - .devrouter.yml exists and validates",
520
+ " - dev app ls matches expected entries",
521
+ " - dev ls exposes expected endpoints",
522
+ " - HTTP routes reachable",
523
+ " - TCP Postgres route configured with TLS requirement noted",
524
+ "",
525
+ renderCommandIntentSection()
526
+ ].join("\n");
527
+ }
528
+ var COMMAND_INTENTS;
529
+ var init_ai_prompt = __esm({
530
+ "src/core/ai-prompt.ts"() {
531
+ "use strict";
532
+ init_repo_config();
533
+ COMMAND_INTENTS = [
534
+ { command: "dev init", purpose: "Print the AI onboarding prompt template for a repository." },
535
+ { command: "dev up", purpose: "Start shared Traefik and ensure the shared devnet network." },
536
+ { command: "dev down", purpose: "Stop the shared Traefik router stack." },
537
+ { command: "dev status", purpose: "Show router/container/network/TLS health and bound ports." },
538
+ { command: "dev doctor", purpose: "Run deep diagnostics across global router state and repo config." },
539
+ { command: "dev ls", purpose: "List active HTTP and TCP routes resolved by devrouter." },
540
+ { command: "dev open <name>", purpose: "Open HTTP routes or print connection hints for TCP routes." },
541
+ { command: "dev tls install", purpose: "Install mkcert certs and enable TLS/HTTPS for local routing." },
542
+ { command: "dev repo init", purpose: "Create `.devrouter.yml` in a target repository." },
543
+ { command: "dev app add", purpose: "Add or update one app entry in `.devrouter.yml`." },
544
+ { command: "dev app ls", purpose: "List app entries from `.devrouter.yml`." },
545
+ { command: "dev app run", purpose: "Run one configured app and reconcile its route at runtime." },
546
+ { command: "dev app rm", purpose: "Remove one app entry from `.devrouter.yml`." }
547
+ ];
548
+ }
549
+ });
550
+
551
+ // src/util/timeago.ts
552
+ function formatAge(createdAtSeconds) {
553
+ const ageMs = Date.now() - createdAtSeconds * 1e3;
554
+ if (ageMs < 0) {
555
+ return "0s";
556
+ }
557
+ const seconds = Math.floor(ageMs / 1e3);
558
+ if (seconds < 60) {
559
+ return `${seconds}s`;
560
+ }
561
+ const minutes = Math.floor(seconds / 60);
562
+ if (minutes < 60) {
563
+ return `${minutes}m`;
564
+ }
565
+ const hours = Math.floor(minutes / 60);
566
+ if (hours < 24) {
567
+ return `${hours}h`;
568
+ }
569
+ const days = Math.floor(hours / 24);
570
+ return `${days}d`;
571
+ }
572
+ var init_timeago = __esm({
573
+ "src/util/timeago.ts"() {
574
+ "use strict";
575
+ }
576
+ });
577
+
578
+ // src/util/table.ts
579
+ function pad(value, width) {
580
+ if (value.length >= width) {
581
+ return value;
582
+ }
583
+ return value + " ".repeat(width - value.length);
584
+ }
585
+ function renderTable(headers, rows) {
586
+ if (headers.length === 0) {
587
+ return "";
588
+ }
589
+ const widths = headers.map((header, index) => {
590
+ const rowWidth = rows.reduce((max, row) => {
591
+ const cell = row[index] ?? "";
592
+ return Math.max(max, cell.length);
593
+ }, 0);
594
+ return Math.max(header.length, rowWidth);
595
+ });
596
+ const headerLine = headers.map((header, idx) => pad(header, widths[idx])).join(" ");
597
+ const separator = widths.map((w) => "-".repeat(w)).join(" ");
598
+ const body = rows.map((row) => row.map((cell, idx) => pad(cell ?? "", widths[idx])).join(" ")).join("\n");
599
+ if (!body) {
600
+ return `${headerLine}
601
+ ${separator}`;
602
+ }
603
+ return `${headerLine}
604
+ ${separator}
605
+ ${body}`;
606
+ }
607
+ var init_table = __esm({
608
+ "src/util/table.ts"() {
609
+ "use strict";
610
+ }
611
+ });
612
+
613
+ // src/core/output.ts
614
+ function printJSON(value) {
615
+ process.stdout.write(`${JSON.stringify(value, null, 2)}
616
+ `);
617
+ }
618
+ function printStatus(status) {
619
+ const rows = [
620
+ ["Docker context", status.dockerContext],
621
+ ["Router running", status.routerRunning ? "yes" : "no"],
622
+ ["Router container", status.routerContainerName],
623
+ ["Port 80 bound", status.boundPorts.web80 ? "yes" : "no"],
624
+ ["Port 443 bound", status.boundPorts.web443 ? "yes" : "no"],
625
+ ["Port 5432 bound", status.boundPorts.postgres5432 ? "yes" : "no"],
626
+ ["Dashboard 8080 bound", status.boundPorts.dashboard8080 ? "yes" : "no"],
627
+ ["devnet exists", status.networkExists ? "yes" : "no"],
628
+ ["TLS configured", status.tlsConfigured ? "yes" : "no"],
629
+ ["TLS certs present", status.certPresent ? "yes" : "no"],
630
+ ["TLS enabled", status.tlsEnabled ? "yes" : "no"],
631
+ ["HTTP routing ready", status.insights.httpRoutingReady ? "yes" : "no"],
632
+ ["TCP routing ready", status.insights.tcpRoutingReady ? "yes" : "no"]
633
+ ];
634
+ if (status.repo) {
635
+ rows.push(["Repo path", status.repo.path]);
636
+ rows.push(["Repo config", status.repo.exists ? status.repo.configPath : "missing"]);
637
+ rows.push([
638
+ "Repo config valid",
639
+ status.repo.valid ? "yes" : `no (${status.repo.error ?? "validation failed"})`
640
+ ]);
641
+ rows.push(["Repo apps", String(status.repo.appCount)]);
642
+ }
643
+ process.stdout.write(`${renderTable(["FIELD", "VALUE"], rows)}
644
+ `);
645
+ if (status.insights.nextSteps.length > 0) {
646
+ process.stdout.write("\nNext steps:\n");
647
+ for (const step of status.insights.nextSteps) {
648
+ process.stdout.write(`- ${step}
649
+ `);
650
+ }
651
+ }
652
+ }
653
+ function printRoutes(routes, duplicateHosts) {
654
+ if (routes.length === 0) {
655
+ process.stdout.write("No routes found.\n");
656
+ return;
657
+ }
658
+ const rows = routes.slice().sort((a, b) => a.serviceName.localeCompare(b.serviceName) || a.source.localeCompare(b.source)).map((route) => [
659
+ route.serviceName,
660
+ route.projectName,
661
+ route.protocol,
662
+ route.urls.join(","),
663
+ route.health === "unknown" ? route.status : `${route.status}/${route.health}`,
664
+ formatAge(route.createdAt)
665
+ ]);
666
+ process.stdout.write(
667
+ `${renderTable(["NAME", "PROJECT", "PROTOCOL", "ENDPOINTS", "STATUS", "AGE"], rows)}
668
+ `
669
+ );
670
+ if (duplicateHosts.length > 0) {
671
+ process.stdout.write(`
672
+ Warning: duplicate hostnames detected: ${duplicateHosts.join(", ")}
673
+ `);
674
+ }
675
+ }
676
+ function printConfigApps(repoPath, apps) {
677
+ if (apps.length === 0) {
678
+ process.stdout.write(`No apps configured in ${repoPath}.
679
+ `);
680
+ return;
681
+ }
682
+ const rows = apps.slice().sort((a, b) => a.name.localeCompare(b.name)).map((app) => {
683
+ if (app.runtime === "host") {
684
+ return [
685
+ app.name,
686
+ app.protocol,
687
+ app.runtime,
688
+ app.host,
689
+ app.hostRun.command,
690
+ app.dependencies.map((dependency) => dependency.app).join(",")
691
+ ];
692
+ }
693
+ const protocol = app.protocol === "tcp" ? `tcp/${app.tcpProtocol}` : app.protocol;
694
+ return [
695
+ app.name,
696
+ protocol,
697
+ app.runtime,
698
+ app.host,
699
+ `${app.docker.service}:${app.docker.internalPort}`,
700
+ app.dependencies.map((dependency) => dependency.app).join(",")
701
+ ];
702
+ });
703
+ process.stdout.write(
704
+ `${renderTable(["NAME", "PROTOCOL", "RUNTIME", "HOST", "TARGET", "DEPS"], rows)}
705
+ `
706
+ );
707
+ }
708
+ function printDoctorReport(report) {
709
+ const summaryRows = [
710
+ ["Generated", report.generatedAt],
711
+ ["Repo path", report.repoPath ?? "-"],
712
+ ["OK", String(report.summary.ok)],
713
+ ["WARN", String(report.summary.warn)],
714
+ ["ERROR", String(report.summary.error)]
715
+ ];
716
+ process.stdout.write(`${renderTable(["FIELD", "VALUE"], summaryRows)}
717
+
718
+ `);
719
+ const rows = report.checks.map((check) => [
720
+ check.id,
721
+ check.level.toUpperCase(),
722
+ check.summary,
723
+ check.suggestion ?? "-"
724
+ ]);
725
+ process.stdout.write(
726
+ `${renderTable(["CHECK", "LEVEL", "SUMMARY", "SUGGESTION"], rows)}
727
+ `
728
+ );
729
+ const detailedChecks = report.checks.filter((check) => check.details);
730
+ if (detailedChecks.length > 0) {
731
+ process.stdout.write("\nDetails:\n");
732
+ for (const check of detailedChecks) {
733
+ process.stdout.write(`- ${check.id}: ${check.details}
734
+ `);
735
+ }
736
+ }
737
+ if (report.nextSteps.length > 0) {
738
+ process.stdout.write("\nRecommended next steps:\n");
739
+ for (const step of report.nextSteps) {
740
+ process.stdout.write(`- ${step}
741
+ `);
742
+ }
743
+ }
744
+ }
745
+ var init_output = __esm({
746
+ "src/core/output.ts"() {
747
+ "use strict";
748
+ init_timeago();
749
+ init_table();
750
+ }
751
+ });
752
+
753
+ // src/commands/init.ts
754
+ var init_exports = {};
755
+ __export(init_exports, {
756
+ runInitCommand: () => runInitCommand
757
+ });
758
+ async function runInitCommand(options) {
759
+ const prompt = buildOnboardingPrompt({ repo: options.repo, entriesJson: options.entriesJson });
760
+ if (options.json) {
761
+ printJSON({
762
+ prompt,
763
+ commandIntents: COMMAND_INTENTS
764
+ });
765
+ return;
766
+ }
767
+ process.stdout.write(`${prompt}
768
+ `);
769
+ }
770
+ var init_init = __esm({
771
+ "src/commands/init.ts"() {
772
+ "use strict";
773
+ init_ai_prompt();
774
+ init_output();
775
+ }
776
+ });
777
+
778
+ // src/core/docker.ts
779
+ async function getDockerodeConstructor() {
780
+ if (!dockerodeConstructorPromise) {
781
+ dockerodeConstructorPromise = import("dockerode").then((module2) => {
782
+ const maybeDefault = module2;
783
+ return maybeDefault.default ?? module2;
784
+ });
785
+ }
786
+ return dockerodeConstructorPromise;
787
+ }
788
+ function runDockerContextCommand(args) {
789
+ const result = (0, import_node_child_process.spawnSync)("docker", ["context", ...args], {
790
+ encoding: "utf-8"
791
+ });
792
+ if (result.status !== 0) {
793
+ const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
794
+ throw new Error(`docker context command failed: ${details || "unknown error"}`);
795
+ }
796
+ return result.stdout.trim();
797
+ }
798
+ function getCurrentDockerContext() {
799
+ return runDockerContextCommand(["show"]);
800
+ }
801
+ function getDockerHostFromContext(context) {
802
+ const result = (0, import_node_child_process.spawnSync)(
803
+ "docker",
804
+ ["context", "inspect", context, "--format", "{{ .Endpoints.docker.Host }}"],
805
+ { encoding: "utf-8" }
806
+ );
807
+ if (result.status === 0) {
808
+ const value = result.stdout.trim();
809
+ if (value && value !== "<no value>") {
810
+ return value;
811
+ }
812
+ }
813
+ if (process.env.DOCKER_HOST) {
814
+ return process.env.DOCKER_HOST;
815
+ }
816
+ return "unix:///var/run/docker.sock";
817
+ }
818
+ async function createDockerClient() {
819
+ const DockerodeClass = await getDockerodeConstructor();
820
+ const context = getCurrentDockerContext();
821
+ const host = getDockerHostFromContext(context);
822
+ if (host.startsWith("unix://")) {
823
+ return new DockerodeClass({ socketPath: host.replace("unix://", "") });
824
+ }
825
+ if (host.startsWith("tcp://") || host.startsWith("http://") || host.startsWith("https://")) {
826
+ const normalized = host.startsWith("tcp://") ? host.replace("tcp://", "http://") : host;
827
+ const url = new URL(normalized);
828
+ return new DockerodeClass({
829
+ host: url.hostname,
830
+ port: Number(url.port || 2375),
831
+ protocol: url.protocol.replace(":", "")
832
+ });
833
+ }
834
+ throw new Error(`Unsupported docker host from context: ${host}`);
835
+ }
836
+ async function listContainers(all = true) {
837
+ const docker = await createDockerClient();
838
+ return docker.listContainers({ all });
839
+ }
840
+ async function findContainerByName(name) {
841
+ const containers = await listContainers(true);
842
+ return containers.find((container) => container.Names?.some((n) => n === `/${name}`));
843
+ }
844
+ async function isContainerRunning(name) {
845
+ const container = await findContainerByName(name);
846
+ return container?.State === "running";
847
+ }
848
+ async function ensureNetwork(name) {
849
+ const docker = await createDockerClient();
850
+ try {
851
+ await docker.getNetwork(name).inspect();
852
+ return;
853
+ } catch {
854
+ }
855
+ await docker.createNetwork({
856
+ Name: name,
857
+ Driver: "bridge",
858
+ Attachable: true
859
+ });
860
+ }
861
+ async function networkExists(name) {
862
+ const docker = await createDockerClient();
863
+ try {
864
+ await docker.getNetwork(name).inspect();
865
+ return true;
866
+ } catch {
867
+ return false;
868
+ }
869
+ }
870
+ var import_node_child_process, dockerodeConstructorPromise;
871
+ var init_docker = __esm({
872
+ "src/core/docker.ts"() {
873
+ "use strict";
874
+ import_node_child_process = require("child_process");
875
+ dockerodeConstructorPromise = null;
876
+ }
877
+ });
878
+
879
+ // src/core/router.ts
880
+ function renderComposeYml() {
881
+ return `services:
882
+ traefik:
883
+ image: traefik:v2.11
884
+ container_name: ${ROUTER_CONTAINER_NAME}
885
+ restart: unless-stopped
886
+ ports:
887
+ - "80:80"
888
+ - "443:443"
889
+ - "5432:5432"
890
+ - "127.0.0.1:8080:8080"
891
+ volumes:
892
+ - "/var/run/docker.sock:/var/run/docker.sock:ro"
893
+ - "${TRAEFIK_STATIC_FILE}:/etc/traefik/traefik.yml:ro"
894
+ - "${TRAEFIK_DYNAMIC_DIR}:/etc/traefik/dynamic:ro"
895
+ - "${CERTS_DIR}:/certs:ro"
896
+ networks:
897
+ - ${DEVNET_NAME}
898
+
899
+ networks:
900
+ ${DEVNET_NAME}:
901
+ external: true
902
+ `;
903
+ }
904
+ function renderTraefikStaticYml() {
905
+ return `api:
906
+ dashboard: true
907
+ insecure: true
908
+
909
+ entryPoints:
910
+ web:
911
+ address: ":80"
912
+ websecure:
913
+ address: ":443"
914
+ postgres:
915
+ address: ":5432"
916
+ traefik:
917
+ address: ":8080"
918
+
919
+ providers:
920
+ docker:
921
+ endpoint: "unix:///var/run/docker.sock"
922
+ exposedByDefault: false
923
+ network: ${DEVNET_NAME}
924
+ file:
925
+ directory: /etc/traefik/dynamic
926
+ watch: true
927
+
928
+ accessLog: {}
929
+ `;
930
+ }
931
+ function renderTraefikBaseDynamicYml(tlsEnabled) {
932
+ if (!tlsEnabled) {
933
+ return `http: {}
934
+
935
+ tls: {}
936
+ `;
937
+ }
938
+ return `http:
939
+ middlewares:
940
+ redirect-to-https:
941
+ redirectScheme:
942
+ scheme: https
943
+ permanent: false
944
+ routers:
945
+ redirect-http-to-https:
946
+ entryPoints:
947
+ - web
948
+ rule: "HostRegexp(\`{host:.+\\\\.localhost}\`)"
949
+ middlewares:
950
+ - redirect-to-https
951
+ service: noop@internal
952
+
953
+ tls:
954
+ certificates:
955
+ - certFile: /certs/localhost.pem
956
+ keyFile: /certs/localhost-key.pem
957
+ `;
958
+ }
959
+ function renderHostRoutesDynamicYml() {
960
+ return `http:
961
+ routers: {}
962
+ services: {}
963
+ `;
964
+ }
965
+ function renderHostRouteState() {
966
+ return "[]\n";
967
+ }
968
+ function renderRouterReadme() {
969
+ return `# devrouter state
970
+
971
+ This folder is managed by the devrouter CLI.
972
+
973
+ ## Files
974
+
975
+ - compose.yml: shared Traefik stack
976
+ - traefik/traefik.yml: Traefik static config
977
+ - traefik/dynamic/base.yml: TLS + redirect config
978
+ - traefik/dynamic/host-routes.yml: generated host-run routes
979
+ - host-routes-state.json: host-run route metadata
980
+ - certs/: mkcert output files
981
+
982
+ ## Commands
983
+
984
+ - dev init
985
+ - dev up
986
+ - dev down
987
+ - dev status
988
+ - dev doctor
989
+ - dev ls
990
+ - dev repo init
991
+ - dev app add --name <name> --host <host.localhost> --protocol <http|tcp> --runtime <host|docker>
992
+ - dev app run <name>
993
+ - dev app ls
994
+ - dev app rm <name>
995
+ - dev tls install
996
+
997
+ ## Troubleshooting
998
+
999
+ If dev up fails with port conflicts on 80/443/5432, run:
1000
+
1001
+ - lsof -nP -iTCP:80 -sTCP:LISTEN
1002
+ - lsof -nP -iTCP:443 -sTCP:LISTEN
1003
+ - lsof -nP -iTCP:5432 -sTCP:LISTEN
1004
+ `;
1005
+ }
1006
+ function ensureRouterFiles() {
1007
+ import_node_fs2.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
1008
+ import_node_fs2.default.mkdirSync(TRAEFIK_DIR, { recursive: true });
1009
+ import_node_fs2.default.mkdirSync(TRAEFIK_DYNAMIC_DIR, { recursive: true });
1010
+ import_node_fs2.default.mkdirSync(CERTS_DIR, { recursive: true });
1011
+ import_node_fs2.default.mkdirSync(BIN_DIR, { recursive: true });
1012
+ import_node_fs2.default.mkdirSync(CACHE_DIR, { recursive: true });
1013
+ import_node_fs2.default.writeFileSync(COMPOSE_FILE, renderComposeYml(), "utf-8");
1014
+ import_node_fs2.default.writeFileSync(TRAEFIK_STATIC_FILE, renderTraefikStaticYml(), "utf-8");
1015
+ if (!import_node_fs2.default.existsSync(TRAEFIK_DYNAMIC_BASE_FILE)) {
1016
+ if (import_node_fs2.default.existsSync(LEGACY_TRAEFIK_DYNAMIC_FILE)) {
1017
+ import_node_fs2.default.copyFileSync(LEGACY_TRAEFIK_DYNAMIC_FILE, TRAEFIK_DYNAMIC_BASE_FILE);
1018
+ } else {
1019
+ import_node_fs2.default.writeFileSync(TRAEFIK_DYNAMIC_BASE_FILE, renderTraefikBaseDynamicYml(false), "utf-8");
1020
+ }
1021
+ }
1022
+ if (!import_node_fs2.default.existsSync(TRAEFIK_HOST_ROUTES_FILE)) {
1023
+ import_node_fs2.default.writeFileSync(TRAEFIK_HOST_ROUTES_FILE, renderHostRoutesDynamicYml(), "utf-8");
1024
+ }
1025
+ if (!import_node_fs2.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1026
+ import_node_fs2.default.writeFileSync(HOST_ROUTES_STATE_FILE, renderHostRouteState(), "utf-8");
1027
+ }
1028
+ import_node_fs2.default.writeFileSync(ROUTER_README_FILE, renderRouterReadme(), "utf-8");
1029
+ }
1030
+ function setTLSEnabled(enabled) {
1031
+ ensureRouterFiles();
1032
+ import_node_fs2.default.writeFileSync(TRAEFIK_DYNAMIC_BASE_FILE, renderTraefikBaseDynamicYml(enabled), "utf-8");
1033
+ }
1034
+ function isTLSConfigured() {
1035
+ if (!import_node_fs2.default.existsSync(TRAEFIK_DYNAMIC_BASE_FILE)) {
1036
+ return false;
1037
+ }
1038
+ const content = import_node_fs2.default.readFileSync(TRAEFIK_DYNAMIC_BASE_FILE, "utf-8");
1039
+ return content.includes("certificates:") && content.includes("/certs/localhost.pem");
1040
+ }
1041
+ function areTLSCertsPresent() {
1042
+ return import_node_fs2.default.existsSync(CERT_FILE) && import_node_fs2.default.existsSync(CERT_KEY_FILE);
1043
+ }
1044
+ function isTLSEnabled() {
1045
+ return areTLSCertsPresent() && isTLSConfigured();
1046
+ }
1047
+ function getRouterFileLayout() {
1048
+ const required = [...ROUTER_REQUIRED_FILES];
1049
+ const missing = required.filter((filePath) => !import_node_fs2.default.existsSync(filePath));
1050
+ return { required, missing };
1051
+ }
1052
+ function runDockerCompose(args) {
1053
+ const result = (0, import_node_child_process2.spawnSync)("docker", ["compose", "-f", COMPOSE_FILE, ...args], {
1054
+ encoding: "utf-8"
1055
+ });
1056
+ if (result.status !== 0) {
1057
+ const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
1058
+ throw new Error(`docker compose failed: ${details || "unknown error"}`);
1059
+ }
1060
+ }
1061
+ function startRouterStack() {
1062
+ runDockerCompose(["up", "-d"]);
1063
+ }
1064
+ function stopRouterStack() {
1065
+ runDockerCompose(["down"]);
1066
+ }
1067
+ var import_node_fs2, import_node_os, import_node_path2, import_node_child_process2, DEVNET_NAME, ROUTER_CONTAINER_NAME, DEVROUTER_HOME, TRAEFIK_DIR, TRAEFIK_DYNAMIC_DIR, CERTS_DIR, BIN_DIR, CACHE_DIR, COMPOSE_FILE, TRAEFIK_STATIC_FILE, TRAEFIK_DYNAMIC_BASE_FILE, TRAEFIK_HOST_ROUTES_FILE, HOST_ROUTES_STATE_FILE, ROUTER_README_FILE, LEGACY_TRAEFIK_DYNAMIC_FILE, CERT_FILE, CERT_KEY_FILE, ROUTER_REQUIRED_FILES;
1068
+ var init_router = __esm({
1069
+ "src/core/router.ts"() {
1070
+ "use strict";
1071
+ import_node_fs2 = __toESM(require("fs"));
1072
+ import_node_os = __toESM(require("os"));
1073
+ import_node_path2 = __toESM(require("path"));
1074
+ import_node_child_process2 = require("child_process");
1075
+ DEVNET_NAME = "devnet";
1076
+ ROUTER_CONTAINER_NAME = "devrouter-traefik";
1077
+ DEVROUTER_HOME = import_node_path2.default.join(import_node_os.default.homedir(), ".config", "devrouter");
1078
+ TRAEFIK_DIR = import_node_path2.default.join(DEVROUTER_HOME, "traefik");
1079
+ TRAEFIK_DYNAMIC_DIR = import_node_path2.default.join(TRAEFIK_DIR, "dynamic");
1080
+ CERTS_DIR = import_node_path2.default.join(DEVROUTER_HOME, "certs");
1081
+ BIN_DIR = import_node_path2.default.join(DEVROUTER_HOME, "bin");
1082
+ CACHE_DIR = import_node_path2.default.join(DEVROUTER_HOME, "cache");
1083
+ COMPOSE_FILE = import_node_path2.default.join(DEVROUTER_HOME, "compose.yml");
1084
+ TRAEFIK_STATIC_FILE = import_node_path2.default.join(TRAEFIK_DIR, "traefik.yml");
1085
+ TRAEFIK_DYNAMIC_BASE_FILE = import_node_path2.default.join(TRAEFIK_DYNAMIC_DIR, "base.yml");
1086
+ TRAEFIK_HOST_ROUTES_FILE = import_node_path2.default.join(TRAEFIK_DYNAMIC_DIR, "host-routes.yml");
1087
+ HOST_ROUTES_STATE_FILE = import_node_path2.default.join(DEVROUTER_HOME, "host-routes-state.json");
1088
+ ROUTER_README_FILE = import_node_path2.default.join(DEVROUTER_HOME, "README.md");
1089
+ LEGACY_TRAEFIK_DYNAMIC_FILE = import_node_path2.default.join(TRAEFIK_DIR, "dynamic.yml");
1090
+ CERT_FILE = import_node_path2.default.join(CERTS_DIR, "localhost.pem");
1091
+ CERT_KEY_FILE = import_node_path2.default.join(CERTS_DIR, "localhost-key.pem");
1092
+ ROUTER_REQUIRED_FILES = [
1093
+ COMPOSE_FILE,
1094
+ TRAEFIK_STATIC_FILE,
1095
+ TRAEFIK_DYNAMIC_BASE_FILE,
1096
+ TRAEFIK_HOST_ROUTES_FILE,
1097
+ HOST_ROUTES_STATE_FILE
1098
+ ];
1099
+ }
1100
+ });
1101
+
1102
+ // src/util/ports.ts
1103
+ function findPortListeners(port) {
1104
+ const result = (0, import_node_child_process3.spawnSync)("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], {
1105
+ encoding: "utf-8"
1106
+ });
1107
+ if (result.status !== 0 || !result.stdout.trim()) {
1108
+ return [];
1109
+ }
1110
+ const lines = result.stdout.trim().split(/\r?\n/);
1111
+ if (lines.length <= 1) {
1112
+ return [];
1113
+ }
1114
+ return lines.slice(1).map((line) => {
1115
+ const parts = line.trim().split(/\s+/);
1116
+ const command = parts[0] ?? "?";
1117
+ const pid = parts[1] ?? "?";
1118
+ const user = parts[2] ?? "?";
1119
+ const address = parts.slice(-2).join(" ") || "?";
1120
+ return {
1121
+ port,
1122
+ command,
1123
+ pid,
1124
+ user,
1125
+ address
1126
+ };
1127
+ });
1128
+ }
1129
+ var import_node_child_process3;
1130
+ var init_ports = __esm({
1131
+ "src/util/ports.ts"() {
1132
+ "use strict";
1133
+ import_node_child_process3 = require("child_process");
1134
+ }
1135
+ });
1136
+
1137
+ // src/commands/up.ts
1138
+ var up_exports = {};
1139
+ __export(up_exports, {
1140
+ runUpCommand: () => runUpCommand
1141
+ });
1142
+ function buildPortConflictMessage() {
1143
+ const listeners = [...findPortListeners(80), ...findPortListeners(443), ...findPortListeners(5432)];
1144
+ if (listeners.length === 0) {
1145
+ return "";
1146
+ }
1147
+ const details = listeners.map((listener) => {
1148
+ return `- port ${listener.port}: ${listener.command} (pid ${listener.pid}, user ${listener.user}, ${listener.address})`;
1149
+ }).join("\n");
1150
+ return `Cannot start devrouter because host ports 80/443/5432 are already in use:
1151
+ ${details}
1152
+
1153
+ Mitigation:
1154
+ 1) Stop the conflicting process/container
1155
+ 2) Re-run: dev up
1156
+
1157
+ Debug commands:
1158
+ - lsof -nP -iTCP:80 -sTCP:LISTEN
1159
+ - lsof -nP -iTCP:443 -sTCP:LISTEN
1160
+ - lsof -nP -iTCP:5432 -sTCP:LISTEN`;
1161
+ }
1162
+ async function runUpCommand() {
1163
+ await ensureNetwork(DEVNET_NAME);
1164
+ ensureRouterFiles();
1165
+ const routerRunning = await isContainerRunning(ROUTER_CONTAINER_NAME);
1166
+ if (!routerRunning) {
1167
+ const message = buildPortConflictMessage();
1168
+ if (message) {
1169
+ throw new Error(message);
1170
+ }
1171
+ }
1172
+ startRouterStack();
1173
+ process.stdout.write("devrouter is up.\n");
1174
+ }
1175
+ var init_up = __esm({
1176
+ "src/commands/up.ts"() {
1177
+ "use strict";
1178
+ init_docker();
1179
+ init_router();
1180
+ init_ports();
1181
+ }
1182
+ });
1183
+
1184
+ // src/commands/down.ts
1185
+ var down_exports = {};
1186
+ __export(down_exports, {
1187
+ runDownCommand: () => runDownCommand
1188
+ });
1189
+ async function runDownCommand() {
1190
+ stopRouterStack();
1191
+ process.stdout.write("devrouter is down.\n");
1192
+ }
1193
+ var init_down = __esm({
1194
+ "src/commands/down.ts"() {
1195
+ "use strict";
1196
+ init_router();
1197
+ }
1198
+ });
1199
+
1200
+ // src/core/status.ts
1201
+ function hasPortBinding(ports, privatePort, publicPort) {
1202
+ if (!ports) {
1203
+ return false;
1204
+ }
1205
+ return ports.some((port) => port.PrivatePort === privatePort && port.PublicPort === publicPort);
1206
+ }
1207
+ function toRepoStatus(repoPath) {
1208
+ const resolvedRepoPath = resolveRepoPath(repoPath);
1209
+ const configPath = getRepoConfigPath(resolvedRepoPath);
1210
+ const explicitRepo = typeof repoPath === "string" && repoPath.trim().length > 0;
1211
+ const configExists = import_node_fs3.default.existsSync(configPath);
1212
+ if (!explicitRepo && !configExists) {
1213
+ return void 0;
1214
+ }
1215
+ if (!configExists) {
1216
+ return {
1217
+ path: resolvedRepoPath,
1218
+ configPath,
1219
+ exists: false,
1220
+ valid: false,
1221
+ appCount: 0,
1222
+ tcpAppCount: 0,
1223
+ error: `Missing .devrouter.yml in ${resolvedRepoPath}`
1224
+ };
1225
+ }
1226
+ try {
1227
+ const config = loadRepoConfig(resolvedRepoPath);
1228
+ const tcpAppCount = config.apps.filter((app) => app.protocol === "tcp").length;
1229
+ return {
1230
+ path: resolvedRepoPath,
1231
+ configPath,
1232
+ exists: true,
1233
+ valid: true,
1234
+ appCount: config.apps.length,
1235
+ tcpAppCount
1236
+ };
1237
+ } catch (error) {
1238
+ const message = error instanceof Error ? error.message : String(error);
1239
+ return {
1240
+ path: resolvedRepoPath,
1241
+ configPath,
1242
+ exists: true,
1243
+ valid: false,
1244
+ appCount: 0,
1245
+ tcpAppCount: 0,
1246
+ error: message
1247
+ };
1248
+ }
1249
+ }
1250
+ async function collectRouterStatus(repoPath) {
1251
+ const repo = toRepoStatus(repoPath);
1252
+ const tlsEnabled = isTLSEnabled();
1253
+ let container;
1254
+ let networkIsPresent = false;
1255
+ let dockerContext = "unknown";
1256
+ let dockerUnavailableMessage;
1257
+ try {
1258
+ dockerContext = getCurrentDockerContext();
1259
+ } catch (error) {
1260
+ const message = error instanceof Error ? error.message : String(error);
1261
+ dockerContext = `unavailable (${message})`;
1262
+ dockerUnavailableMessage = message;
1263
+ }
1264
+ try {
1265
+ container = await findContainerByName(ROUTER_CONTAINER_NAME);
1266
+ } catch (error) {
1267
+ const message = error instanceof Error ? error.message : String(error);
1268
+ dockerUnavailableMessage = dockerUnavailableMessage ?? message;
1269
+ }
1270
+ try {
1271
+ networkIsPresent = await networkExists(DEVNET_NAME);
1272
+ } catch (error) {
1273
+ const message = error instanceof Error ? error.message : String(error);
1274
+ dockerUnavailableMessage = dockerUnavailableMessage ?? message;
1275
+ }
1276
+ const boundPorts = {
1277
+ web80: hasPortBinding(container?.Ports, 80, 80),
1278
+ web443: hasPortBinding(container?.Ports, 443, 443),
1279
+ postgres5432: hasPortBinding(container?.Ports, 5432, 5432),
1280
+ dashboard8080: hasPortBinding(container?.Ports, 8080, 8080)
1281
+ };
1282
+ const nextSteps = [];
1283
+ const httpRoutingReady = container?.State === "running" && boundPorts.web80;
1284
+ const tcpRoutingReady = container?.State === "running" && boundPorts.postgres5432 && tlsEnabled;
1285
+ if (!container || container.State !== "running") {
1286
+ nextSteps.push("Run: dev up");
1287
+ }
1288
+ if (dockerUnavailableMessage) {
1289
+ nextSteps.push("Ensure Docker is running and reachable from the active Docker context");
1290
+ }
1291
+ if (!tlsEnabled) {
1292
+ nextSteps.push("Run: dev tls install (required for tcp/postgres, recommended for http)");
1293
+ }
1294
+ if (repo && !repo.exists) {
1295
+ nextSteps.push(`Run: dev repo init --repo ${repo.path}`);
1296
+ } else if (repo && !repo.valid) {
1297
+ nextSteps.push("Fix .devrouter.yml validation errors and re-run `dev doctor --repo <path>`");
1298
+ } else if (repo && repo.valid && repo.appCount === 0) {
1299
+ nextSteps.push(`Run: dev app add --name <name> --host <name>.localhost --protocol http --runtime host --repo ${repo.path}`);
1300
+ } else if (repo && repo.valid) {
1301
+ nextSteps.push(`Run: dev app ls --repo ${repo.path}`);
1302
+ nextSteps.push("Run: dev app run <name> --repo <path> --yes");
1303
+ nextSteps.push("Run: dev ls");
1304
+ }
1305
+ const files = getRouterFileLayout();
1306
+ if (files.missing.length > 0) {
1307
+ nextSteps.push("Run: dev up to re-create missing global router files");
1308
+ }
1309
+ return {
1310
+ dockerContext,
1311
+ routerRunning: container?.State === "running",
1312
+ routerContainerName: ROUTER_CONTAINER_NAME,
1313
+ boundPorts,
1314
+ tlsConfigured: isTLSConfigured(),
1315
+ certPresent: areTLSCertsPresent(),
1316
+ tlsEnabled,
1317
+ networkExists: networkIsPresent,
1318
+ repo,
1319
+ insights: {
1320
+ httpRoutingReady,
1321
+ tcpRoutingReady,
1322
+ nextSteps: Array.from(new Set(nextSteps))
1323
+ }
1324
+ };
1325
+ }
1326
+ var import_node_fs3;
1327
+ var init_status = __esm({
1328
+ "src/core/status.ts"() {
1329
+ "use strict";
1330
+ import_node_fs3 = __toESM(require("fs"));
1331
+ init_docker();
1332
+ init_router();
1333
+ init_repo_config();
1334
+ }
1335
+ });
1336
+
1337
+ // src/commands/status.ts
1338
+ var status_exports = {};
1339
+ __export(status_exports, {
1340
+ runStatusCommand: () => runStatusCommand
1341
+ });
1342
+ async function runStatusCommand(options) {
1343
+ const status = await collectRouterStatus(options.repo);
1344
+ if (options.json) {
1345
+ printJSON(status);
1346
+ return;
1347
+ }
1348
+ printStatus(status);
1349
+ }
1350
+ var init_status2 = __esm({
1351
+ "src/commands/status.ts"() {
1352
+ "use strict";
1353
+ init_output();
1354
+ init_status();
1355
+ }
1356
+ });
1357
+
1358
+ // src/core/host-routes.ts
1359
+ function isPidRunning(pid) {
1360
+ if (!pid || pid <= 0) {
1361
+ return false;
1362
+ }
1363
+ try {
1364
+ process.kill(pid, 0);
1365
+ return true;
1366
+ } catch {
1367
+ return false;
1368
+ }
1369
+ }
1370
+ function buildHostRouteId(repoPath, name) {
1371
+ return `${repoPath}::${name}`;
1372
+ }
1373
+ function sanitizeKey(value) {
1374
+ return value.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1375
+ }
1376
+ function writeHostRoutesDynamicFile(routes, tlsEnabled) {
1377
+ const routers = {};
1378
+ const services = {};
1379
+ for (const route of routes) {
1380
+ const key = `host-${sanitizeKey(route.id)}`;
1381
+ const router = {
1382
+ rule: `Host(\`${route.host}\`)`,
1383
+ entryPoints: tlsEnabled ? ["web", "websecure"] : ["web"],
1384
+ service: key
1385
+ };
1386
+ if (tlsEnabled) {
1387
+ router.tls = true;
1388
+ }
1389
+ routers[key] = router;
1390
+ services[key] = {
1391
+ loadBalancer: {
1392
+ servers: [{ url: `http://host.docker.internal:${route.port}` }]
1393
+ }
1394
+ };
1395
+ }
1396
+ const document = {
1397
+ http: {
1398
+ routers,
1399
+ services
1400
+ }
1401
+ };
1402
+ import_node_fs4.default.writeFileSync(TRAEFIK_HOST_ROUTES_FILE, import_yaml2.default.stringify(document, { lineWidth: 0 }), "utf-8");
1403
+ }
1404
+ function ensureHostRouteStorage() {
1405
+ import_node_fs4.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
1406
+ import_node_fs4.default.mkdirSync(TRAEFIK_DYNAMIC_DIR, { recursive: true });
1407
+ if (!import_node_fs4.default.existsSync(TRAEFIK_HOST_ROUTES_FILE)) {
1408
+ import_node_fs4.default.writeFileSync(
1409
+ TRAEFIK_HOST_ROUTES_FILE,
1410
+ import_yaml2.default.stringify({ http: { routers: {}, services: {} } }, { lineWidth: 0 }),
1411
+ "utf-8"
1412
+ );
1413
+ }
1414
+ if (!import_node_fs4.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1415
+ import_node_fs4.default.writeFileSync(HOST_ROUTES_STATE_FILE, "[]\n", "utf-8");
1416
+ }
1417
+ }
1418
+ function writeState(routes) {
1419
+ ensureHostRouteStorage();
1420
+ import_node_fs4.default.writeFileSync(HOST_ROUTES_STATE_FILE, `${JSON.stringify(routes, null, 2)}
1421
+ `, "utf-8");
1422
+ writeHostRoutesDynamicFile(routes, isTLSEnabled());
1423
+ }
1424
+ function refreshHostRoutesDynamicFile() {
1425
+ const routes = listHostRouteState();
1426
+ writeHostRoutesDynamicFile(routes, isTLSEnabled());
1427
+ }
1428
+ function listHostRouteState() {
1429
+ ensureHostRouteStorage();
1430
+ if (!import_node_fs4.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1431
+ return [];
1432
+ }
1433
+ try {
1434
+ const raw = import_node_fs4.default.readFileSync(HOST_ROUTES_STATE_FILE, "utf-8");
1435
+ const parsed = JSON.parse(raw);
1436
+ if (!Array.isArray(parsed)) {
1437
+ return [];
1438
+ }
1439
+ return parsed.filter((item) => item && typeof item === "object").map((item) => item);
1440
+ } catch {
1441
+ return [];
1442
+ }
1443
+ }
1444
+ function upsertHostRoute(input2) {
1445
+ const routes = listHostRouteState();
1446
+ const id = buildHostRouteId(input2.repoPath, input2.name);
1447
+ const existing = routes.find((route) => route.id === id);
1448
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1449
+ const next = {
1450
+ id,
1451
+ name: input2.name,
1452
+ host: input2.host,
1453
+ protocol: input2.protocol ?? "http",
1454
+ repoPath: input2.repoPath,
1455
+ port: input2.port,
1456
+ mode: input2.mode,
1457
+ pid: input2.pid,
1458
+ command: input2.command,
1459
+ createdAt: existing?.createdAt ?? now,
1460
+ updatedAt: now
1461
+ };
1462
+ const remaining = routes.filter((route) => route.id !== id);
1463
+ remaining.push(next);
1464
+ remaining.sort((a, b) => a.name.localeCompare(b.name) || a.repoPath.localeCompare(b.repoPath));
1465
+ writeState(remaining);
1466
+ return next;
1467
+ }
1468
+ function removeHostRouteById(id) {
1469
+ const routes = listHostRouteState();
1470
+ const next = routes.filter((route) => route.id !== id);
1471
+ if (next.length === routes.length) {
1472
+ return false;
1473
+ }
1474
+ writeState(next);
1475
+ return true;
1476
+ }
1477
+ function removeHostRouteByName(name, repoPath) {
1478
+ const routes = listHostRouteState();
1479
+ const matches = routes.filter((route) => {
1480
+ if (route.name !== name) {
1481
+ return false;
1482
+ }
1483
+ if (repoPath && route.repoPath !== repoPath) {
1484
+ return false;
1485
+ }
1486
+ return true;
1487
+ });
1488
+ if (matches.length === 0) {
1489
+ throw new Error(`No host route named '${name}' found.`);
1490
+ }
1491
+ if (matches.length > 1 && !repoPath) {
1492
+ const refs = matches.map((route) => `${route.name} (${route.repoPath})`).join(", ");
1493
+ throw new Error(
1494
+ `Multiple host routes named '${name}' exist. Re-run with --repo to disambiguate: ${refs}`
1495
+ );
1496
+ }
1497
+ const match = matches[0];
1498
+ const next = routes.filter((route) => route.id !== match.id);
1499
+ writeState(next);
1500
+ return match;
1501
+ }
1502
+ function listHostRoutes(tlsEnabled) {
1503
+ const routes = listHostRouteState();
1504
+ const scheme = tlsEnabled ? "https" : "http";
1505
+ return routes.map((route) => ({
1506
+ id: route.id,
1507
+ source: "host",
1508
+ protocol: "http",
1509
+ serviceName: route.name,
1510
+ projectName: import_node_path3.default.basename(route.repoPath),
1511
+ hosts: [route.host],
1512
+ urls: [`${scheme}://${route.host}`],
1513
+ status: isPidRunning(route.pid) ? "running" : "stopped",
1514
+ health: "unknown",
1515
+ createdAt: Math.floor(new Date(route.createdAt).getTime() / 1e3)
1516
+ }));
1517
+ }
1518
+ var import_node_fs4, import_node_path3, import_yaml2;
1519
+ var init_host_routes = __esm({
1520
+ "src/core/host-routes.ts"() {
1521
+ "use strict";
1522
+ import_node_fs4 = __toESM(require("fs"));
1523
+ import_node_path3 = __toESM(require("path"));
1524
+ import_yaml2 = __toESM(require("yaml"));
1525
+ init_router();
1526
+ }
1527
+ });
1528
+
1529
+ // src/core/routes.ts
1530
+ function normalizeContainerName(name) {
1531
+ if (!name) {
1532
+ return "unknown";
1533
+ }
1534
+ return name.startsWith("/") ? name.slice(1) : name;
1535
+ }
1536
+ function parseHostsFromMatcher(rule, matcherName) {
1537
+ const hosts = [];
1538
+ const matcherRegex = new RegExp(`${matcherName}\\(([^)]+)\\)`, "g");
1539
+ const hostBlocks = rule.matchAll(matcherRegex);
1540
+ for (const block of hostBlocks) {
1541
+ const inner = block[1];
1542
+ const parts = inner.split(",");
1543
+ for (const part of parts) {
1544
+ const clean = part.trim().replaceAll("`", "").replaceAll('"', "").replaceAll("'", "");
1545
+ if (clean.length > 0) {
1546
+ hosts.push(clean);
1547
+ }
1548
+ }
1549
+ }
1550
+ return Array.from(new Set(hosts));
1551
+ }
1552
+ function isOnNetwork(container, networkName) {
1553
+ const networks = container.NetworkSettings?.Networks ?? {};
1554
+ return Object.prototype.hasOwnProperty.call(networks, networkName);
1555
+ }
1556
+ function buildRoute(container, routerId, hosts, protocol, tlsEnabled) {
1557
+ const labels = container.Labels ?? {};
1558
+ const containerName = normalizeContainerName(container.Names?.[0]);
1559
+ const serviceName = labels["com.docker.compose.service"] ?? containerName;
1560
+ const projectName = labels["com.docker.compose.project"] ?? "-";
1561
+ const status = container.State ?? "unknown";
1562
+ const statusText = container.Status ?? "";
1563
+ let health = "unknown";
1564
+ if (statusText.includes("healthy")) {
1565
+ health = "healthy";
1566
+ } else if (statusText.includes("unhealthy")) {
1567
+ health = "unhealthy";
1568
+ }
1569
+ return {
1570
+ id: routerId,
1571
+ source: "docker",
1572
+ protocol,
1573
+ containerId: container.Id,
1574
+ containerName,
1575
+ serviceName,
1576
+ projectName,
1577
+ hosts,
1578
+ urls: protocol === "http" ? hosts.map((host) => `${tlsEnabled ? "https" : "http"}://${host}`) : hosts.map((host) => `postgres://${host}:5432 (tls required)`),
1579
+ status,
1580
+ health,
1581
+ createdAt: container.Created
1582
+ };
1583
+ }
1584
+ function discoverRoutes(containers, tlsEnabled, networkName) {
1585
+ const routes = [];
1586
+ for (const container of containers) {
1587
+ if (!isOnNetwork(container, networkName)) {
1588
+ continue;
1589
+ }
1590
+ const labels = container.Labels ?? {};
1591
+ if (labels["traefik.enable"] !== "true") {
1592
+ continue;
1593
+ }
1594
+ for (const [key, value] of Object.entries(labels)) {
1595
+ if (!value) {
1596
+ continue;
1597
+ }
1598
+ const httpMatch = key.match(HTTP_ROUTER_RULE_KEY);
1599
+ if (httpMatch) {
1600
+ const routerId2 = httpMatch[1];
1601
+ const hosts2 = parseHostsFromMatcher(value, "Host");
1602
+ if (hosts2.length > 0) {
1603
+ routes.push(buildRoute(container, routerId2, hosts2, "http", tlsEnabled));
1604
+ }
1605
+ continue;
1606
+ }
1607
+ const tcpMatch = key.match(TCP_ROUTER_RULE_KEY);
1608
+ if (!tcpMatch) {
1609
+ continue;
1610
+ }
1611
+ const routerId = tcpMatch[1];
1612
+ const hosts = parseHostsFromMatcher(value, "HostSNI").filter((host) => host !== "*");
1613
+ if (hosts.length === 0) {
1614
+ continue;
1615
+ }
1616
+ routes.push(buildRoute(container, routerId, hosts, "tcp/postgres", tlsEnabled));
1617
+ }
1618
+ }
1619
+ return { routes, duplicateHosts: findDuplicateHosts(routes) };
1620
+ }
1621
+ function findDuplicateHosts(routes) {
1622
+ const hostCount = /* @__PURE__ */ new Map();
1623
+ for (const route of routes) {
1624
+ for (const host of route.hosts) {
1625
+ hostCount.set(host, (hostCount.get(host) ?? 0) + 1);
1626
+ }
1627
+ }
1628
+ return Array.from(hostCount.entries()).filter(([, count]) => count > 1).map(([host]) => host).sort();
1629
+ }
1630
+ function resolveRouteByName(routes, name) {
1631
+ const target = name.replace(/^https?:\/\//, "").replace(/\/$/, "");
1632
+ const matches = routes.filter((route) => {
1633
+ if (route.serviceName === target) {
1634
+ return true;
1635
+ }
1636
+ if (route.containerName && route.containerName === target) {
1637
+ return true;
1638
+ }
1639
+ if (route.hosts.includes(target)) {
1640
+ return true;
1641
+ }
1642
+ return route.hosts.some((host) => host.replace(/\.localhost$/, "") === target);
1643
+ });
1644
+ if (matches.length === 0) {
1645
+ throw new Error(`No route found for '${name}'. Run 'dev ls' to view available routes.`);
1646
+ }
1647
+ if (matches.length > 1) {
1648
+ const names = matches.map((route) => `${route.serviceName} (${route.hosts.join(",")})`).join("; ");
1649
+ throw new Error(`Route name '${name}' is ambiguous: ${names}`);
1650
+ }
1651
+ return matches[0];
1652
+ }
1653
+ var HTTP_ROUTER_RULE_KEY, TCP_ROUTER_RULE_KEY;
1654
+ var init_routes = __esm({
1655
+ "src/core/routes.ts"() {
1656
+ "use strict";
1657
+ HTTP_ROUTER_RULE_KEY = /^traefik\.http\.routers\.([^.]+)\.rule$/;
1658
+ TCP_ROUTER_RULE_KEY = /^traefik\.tcp\.routers\.([^.]+)\.rule$/;
1659
+ }
1660
+ });
1661
+
1662
+ // src/core/doctor.ts
1663
+ function isPidRunning2(pid) {
1664
+ if (!pid || pid <= 0) {
1665
+ return false;
1666
+ }
1667
+ try {
1668
+ process.kill(pid, 0);
1669
+ return true;
1670
+ } catch {
1671
+ return false;
1672
+ }
1673
+ }
1674
+ function addCheck(checks, check) {
1675
+ checks.push(check);
1676
+ }
1677
+ function collectSummary(checks) {
1678
+ return checks.reduce(
1679
+ (acc, check) => {
1680
+ acc[check.level] += 1;
1681
+ return acc;
1682
+ },
1683
+ { ok: 0, warn: 0, error: 0 }
1684
+ );
1685
+ }
1686
+ function collectNextSteps(checks, statusNextSteps) {
1687
+ const steps = /* @__PURE__ */ new Set();
1688
+ for (const check of checks) {
1689
+ if (check.level === "ok") {
1690
+ continue;
1691
+ }
1692
+ if (check.suggestion) {
1693
+ steps.add(check.suggestion);
1694
+ }
1695
+ }
1696
+ for (const step of statusNextSteps) {
1697
+ steps.add(step);
1698
+ }
1699
+ return Array.from(steps.values());
1700
+ }
1701
+ async function buildDoctorReport(options = {}) {
1702
+ const checks = [];
1703
+ const fileLayout = getRouterFileLayout();
1704
+ const resolvedRepoPath = resolveRepoPath(options.repo);
1705
+ const explicitRepo = typeof options.repo === "string" && options.repo.trim().length > 0;
1706
+ if (fileLayout.missing.length === 0) {
1707
+ addCheck(checks, {
1708
+ id: "global.router-files",
1709
+ level: "ok",
1710
+ summary: "Global router files are present."
1711
+ });
1712
+ } else {
1713
+ addCheck(checks, {
1714
+ id: "global.router-files",
1715
+ level: "warn",
1716
+ summary: `Missing ${fileLayout.missing.length} global router file(s).`,
1717
+ details: fileLayout.missing.join(", "),
1718
+ suggestion: "Run: dev up"
1719
+ });
1720
+ }
1721
+ let statusNextSteps = [];
1722
+ try {
1723
+ const status = await collectRouterStatus(options.repo);
1724
+ statusNextSteps = status.insights.nextSteps;
1725
+ addCheck(checks, {
1726
+ id: "global.docker-context",
1727
+ level: "ok",
1728
+ summary: `Docker context: ${status.dockerContext}`
1729
+ });
1730
+ addCheck(checks, {
1731
+ id: "global.router-running",
1732
+ level: status.routerRunning ? "ok" : "warn",
1733
+ summary: status.routerRunning ? "Router container is running." : "Router container is not running.",
1734
+ suggestion: status.routerRunning ? void 0 : "Run: dev up"
1735
+ });
1736
+ if (status.routerRunning) {
1737
+ const missingPorts = [];
1738
+ if (!status.boundPorts.web80) {
1739
+ missingPorts.push("80");
1740
+ }
1741
+ if (!status.boundPorts.web443) {
1742
+ missingPorts.push("443");
1743
+ }
1744
+ if (!status.boundPorts.postgres5432) {
1745
+ missingPorts.push("5432");
1746
+ }
1747
+ addCheck(checks, {
1748
+ id: "global.port-bindings",
1749
+ level: missingPorts.length === 0 ? "ok" : "error",
1750
+ summary: missingPorts.length === 0 ? "Router has required port bindings (80/443/5432)." : `Router is running but missing bound port(s): ${missingPorts.join(", ")}.`,
1751
+ suggestion: missingPorts.length === 0 ? void 0 : "Restart router: dev down && dev up"
1752
+ });
1753
+ }
1754
+ addCheck(checks, {
1755
+ id: "global.devnet",
1756
+ level: status.networkExists ? "ok" : "error",
1757
+ summary: status.networkExists ? "Shared network devnet exists." : "Shared network devnet is missing.",
1758
+ suggestion: status.networkExists ? void 0 : "Run: dev up"
1759
+ });
1760
+ if (status.tlsEnabled) {
1761
+ addCheck(checks, {
1762
+ id: "global.tls",
1763
+ level: "ok",
1764
+ summary: "TLS is enabled (certs + Traefik TLS config)."
1765
+ });
1766
+ } else if (status.certPresent || status.tlsConfigured) {
1767
+ addCheck(checks, {
1768
+ id: "global.tls",
1769
+ level: "warn",
1770
+ summary: "TLS is partially configured.",
1771
+ details: `certPresent=${status.certPresent}, tlsConfigured=${status.tlsConfigured}`,
1772
+ suggestion: "Run: dev tls install"
1773
+ });
1774
+ } else {
1775
+ addCheck(checks, {
1776
+ id: "global.tls",
1777
+ level: "warn",
1778
+ summary: "TLS is not enabled.",
1779
+ suggestion: "Run: dev tls install"
1780
+ });
1781
+ }
1782
+ const repo = status.repo;
1783
+ if (!repo) {
1784
+ addCheck(checks, {
1785
+ id: "repo.config",
1786
+ level: "warn",
1787
+ summary: "No .devrouter.yml found in current directory.",
1788
+ suggestion: `Run: dev repo init --repo ${resolvedRepoPath}`
1789
+ });
1790
+ } else if (!repo.exists) {
1791
+ addCheck(checks, {
1792
+ id: "repo.config",
1793
+ level: explicitRepo ? "error" : "warn",
1794
+ summary: `Missing .devrouter.yml in ${repo.path}.`,
1795
+ suggestion: `Run: dev repo init --repo ${repo.path}`
1796
+ });
1797
+ } else if (!repo.valid) {
1798
+ addCheck(checks, {
1799
+ id: "repo.config",
1800
+ level: "error",
1801
+ summary: ".devrouter.yml exists but is invalid.",
1802
+ details: repo.error,
1803
+ suggestion: "Fix config errors and re-run: dev doctor --repo <path>"
1804
+ });
1805
+ } else {
1806
+ addCheck(checks, {
1807
+ id: "repo.config",
1808
+ level: "ok",
1809
+ summary: `.devrouter.yml is valid (${repo.appCount} app(s)).`
1810
+ });
1811
+ if (repo.appCount === 0) {
1812
+ addCheck(checks, {
1813
+ id: "repo.apps",
1814
+ level: "warn",
1815
+ summary: "No apps are configured in .devrouter.yml.",
1816
+ suggestion: "Run: dev app add --name <name> --host <name>.localhost --protocol http --runtime host"
1817
+ });
1818
+ }
1819
+ const config = loadRepoConfig(repo.path);
1820
+ const appNames = new Set(config.apps.map((app) => app.name));
1821
+ const missingDependencies = config.apps.flatMap(
1822
+ (app) => app.dependencies.filter((dependency) => !appNames.has(dependency.app)).map((dependency) => `${app.name}->${dependency.app}`)
1823
+ );
1824
+ addCheck(checks, {
1825
+ id: "repo.dependencies",
1826
+ level: missingDependencies.length === 0 ? "ok" : "error",
1827
+ summary: missingDependencies.length === 0 ? "All app dependencies resolve to configured app names." : `Missing dependency target(s): ${missingDependencies.join(", ")}.`,
1828
+ suggestion: missingDependencies.length === 0 ? void 0 : "Fix dependencies in .devrouter.yml"
1829
+ });
1830
+ const missingComposeFiles = config.apps.filter((app) => app.runtime === "docker").flatMap((app) => app.docker.composeFiles.map((filePath) => ({
1831
+ app: app.name,
1832
+ filePath,
1833
+ absolutePath: import_node_path4.default.resolve(repo.path, filePath)
1834
+ }))).filter((entry) => !import_node_fs5.default.existsSync(entry.absolutePath));
1835
+ addCheck(checks, {
1836
+ id: "repo.compose-files",
1837
+ level: missingComposeFiles.length === 0 ? "ok" : "error",
1838
+ summary: missingComposeFiles.length === 0 ? "All referenced docker compose files exist." : `${missingComposeFiles.length} compose file reference(s) are missing.`,
1839
+ details: missingComposeFiles.length > 0 ? missingComposeFiles.map((entry) => `${entry.app}: ${entry.filePath}`).join(", ") : void 0,
1840
+ suggestion: missingComposeFiles.length === 0 ? void 0 : "Fix docker.composeFiles paths in .devrouter.yml"
1841
+ });
1842
+ const missingHostCwds = config.apps.filter((app) => app.runtime === "host").map((app) => ({
1843
+ app: app.name,
1844
+ cwd: app.hostRun.cwd,
1845
+ absolutePath: import_node_path4.default.resolve(repo.path, app.hostRun.cwd)
1846
+ })).filter((entry) => !import_node_fs5.default.existsSync(entry.absolutePath));
1847
+ addCheck(checks, {
1848
+ id: "repo.host-cwd",
1849
+ level: missingHostCwds.length === 0 ? "ok" : "error",
1850
+ summary: missingHostCwds.length === 0 ? "All host runtime cwd paths exist." : `${missingHostCwds.length} host runtime cwd path(s) are missing.`,
1851
+ details: missingHostCwds.length > 0 ? missingHostCwds.map((entry) => `${entry.app}: ${entry.cwd}`).join(", ") : void 0,
1852
+ suggestion: missingHostCwds.length === 0 ? void 0 : "Fix hostRun.cwd paths in .devrouter.yml"
1853
+ });
1854
+ if (repo.tcpAppCount > 0 && !status.tlsEnabled) {
1855
+ addCheck(checks, {
1856
+ id: "repo.tcp-tls",
1857
+ level: "error",
1858
+ summary: `Repo defines ${repo.tcpAppCount} tcp/postgres app(s), but TLS is not enabled.`,
1859
+ suggestion: "Run: dev tls install"
1860
+ });
1861
+ } else if (repo.tcpAppCount > 0) {
1862
+ addCheck(checks, {
1863
+ id: "repo.tcp-tls",
1864
+ level: "ok",
1865
+ summary: `TLS is ready for ${repo.tcpAppCount} tcp/postgres app(s).`
1866
+ });
1867
+ }
1868
+ }
1869
+ } catch (error) {
1870
+ const message = error instanceof Error ? error.message : String(error);
1871
+ addCheck(checks, {
1872
+ id: "global.status",
1873
+ level: "error",
1874
+ summary: "Failed to collect router status diagnostics.",
1875
+ details: message
1876
+ });
1877
+ }
1878
+ try {
1879
+ const tlsEnabled = isTLSEnabled();
1880
+ const containers = await listContainers(true);
1881
+ const dockerRoutes = discoverRoutes(containers, tlsEnabled, "devnet").routes;
1882
+ const hostRoutes = listHostRoutes(tlsEnabled);
1883
+ const duplicates = findDuplicateHosts([...dockerRoutes, ...hostRoutes]);
1884
+ addCheck(checks, {
1885
+ id: "routes.duplicates",
1886
+ level: duplicates.length === 0 ? "ok" : "error",
1887
+ summary: duplicates.length === 0 ? "No duplicate hostnames detected across active routes." : `Duplicate hostname(s): ${duplicates.join(", ")}.`,
1888
+ suggestion: duplicates.length === 0 ? void 0 : "Rename hosts in .devrouter.yml so each hostname is unique"
1889
+ });
1890
+ } catch (error) {
1891
+ const message = error instanceof Error ? error.message : String(error);
1892
+ addCheck(checks, {
1893
+ id: "routes.duplicates",
1894
+ level: "warn",
1895
+ summary: "Could not evaluate duplicate hostnames.",
1896
+ details: message
1897
+ });
1898
+ }
1899
+ const staleHostRoutes = listHostRouteState().filter(
1900
+ (route) => route.pid ? !isPidRunning2(route.pid) : true
1901
+ );
1902
+ addCheck(checks, {
1903
+ id: "routes.host-state",
1904
+ level: staleHostRoutes.length === 0 ? "ok" : "warn",
1905
+ 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).`,
1906
+ suggestion: staleHostRoutes.length === 0 ? void 0 : "Re-run the affected host app(s): dev app run <name> --repo <path>"
1907
+ });
1908
+ const summary = collectSummary(checks);
1909
+ const nextSteps = collectNextSteps(checks, statusNextSteps);
1910
+ return {
1911
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1912
+ repoPath: resolvedRepoPath,
1913
+ summary,
1914
+ checks,
1915
+ nextSteps
1916
+ };
1917
+ }
1918
+ var import_node_fs5, import_node_path4;
1919
+ var init_doctor = __esm({
1920
+ "src/core/doctor.ts"() {
1921
+ "use strict";
1922
+ import_node_fs5 = __toESM(require("fs"));
1923
+ import_node_path4 = __toESM(require("path"));
1924
+ init_docker();
1925
+ init_host_routes();
1926
+ init_repo_config();
1927
+ init_router();
1928
+ init_status();
1929
+ init_routes();
1930
+ }
1931
+ });
1932
+
1933
+ // src/commands/doctor.ts
1934
+ var doctor_exports = {};
1935
+ __export(doctor_exports, {
1936
+ runDoctorCommand: () => runDoctorCommand
1937
+ });
1938
+ async function runDoctorCommand(options) {
1939
+ const report = await buildDoctorReport({ repo: options.repo });
1940
+ if (options.json) {
1941
+ printJSON(report);
1942
+ return;
1943
+ }
1944
+ printDoctorReport(report);
1945
+ }
1946
+ var init_doctor2 = __esm({
1947
+ "src/commands/doctor.ts"() {
1948
+ "use strict";
1949
+ init_doctor();
1950
+ init_output();
1951
+ }
1952
+ });
1953
+
1954
+ // src/commands/ls.ts
1955
+ var ls_exports = {};
1956
+ __export(ls_exports, {
1957
+ runLsCommand: () => runLsCommand
1958
+ });
1959
+ async function runLsCommand(json) {
1960
+ const containers = await listContainers(true);
1961
+ const tlsEnabled = isTLSEnabled();
1962
+ const docker = discoverRoutes(containers, tlsEnabled, DEVNET_NAME);
1963
+ const host = listHostRoutes(tlsEnabled);
1964
+ const routes = [...docker.routes, ...host];
1965
+ const duplicateHosts = findDuplicateHosts(routes);
1966
+ const result = { routes, duplicateHosts };
1967
+ if (json) {
1968
+ printJSON(result);
1969
+ return;
1970
+ }
1971
+ printRoutes(result.routes, result.duplicateHosts);
1972
+ }
1973
+ var init_ls = __esm({
1974
+ "src/commands/ls.ts"() {
1975
+ "use strict";
1976
+ init_docker();
1977
+ init_host_routes();
1978
+ init_output();
1979
+ init_routes();
1980
+ init_router();
1981
+ }
1982
+ });
1983
+
1984
+ // src/commands/open.ts
1985
+ var open_exports = {};
1986
+ __export(open_exports, {
1987
+ runOpenCommand: () => runOpenCommand
1988
+ });
1989
+ async function runOpenCommand(name) {
1990
+ const containers = await listContainers(true);
1991
+ const tlsEnabled = isTLSEnabled();
1992
+ const { routes: dockerRoutes } = discoverRoutes(containers, tlsEnabled, DEVNET_NAME);
1993
+ const hostRoutes = listHostRoutes(tlsEnabled);
1994
+ const route = resolveRouteByName([...dockerRoutes, ...hostRoutes], name);
1995
+ const url = route.urls[0];
1996
+ if (!url) {
1997
+ throw new Error(`Route '${name}' has no URL.`);
1998
+ }
1999
+ if (route.protocol !== "http") {
2000
+ process.stdout.write(`Route '${route.serviceName}' is ${route.protocol}: ${url}
2001
+ `);
2002
+ process.stdout.write(
2003
+ 'Use a TLS-enabled Postgres client (for example: psql "host=<host> port=5432 sslmode=require ...").\n'
2004
+ );
2005
+ return;
2006
+ }
2007
+ const result = (0, import_node_child_process4.spawnSync)("open", [url], { encoding: "utf-8" });
2008
+ if (result.status !== 0) {
2009
+ const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
2010
+ throw new Error(`Unable to open '${url}': ${details || "unknown error"}`);
2011
+ }
2012
+ process.stdout.write(`Opened ${url}
2013
+ `);
2014
+ }
2015
+ var import_node_child_process4;
2016
+ var init_open = __esm({
2017
+ "src/commands/open.ts"() {
2018
+ "use strict";
2019
+ import_node_child_process4 = require("child_process");
2020
+ init_docker();
2021
+ init_host_routes();
2022
+ init_routes();
2023
+ init_router();
2024
+ }
2025
+ });
2026
+
2027
+ // src/commands/repo-init.ts
2028
+ var repo_init_exports = {};
2029
+ __export(repo_init_exports, {
2030
+ runRepoInitCommand: () => runRepoInitCommand
2031
+ });
2032
+ async function runRepoInitCommand(options) {
2033
+ const repoPath = resolveRepoPath(options.repo);
2034
+ const result = initRepoConfig(repoPath);
2035
+ if (!result.created) {
2036
+ process.stdout.write(`Already exists: ${getRepoConfigPath(repoPath)}
2037
+ `);
2038
+ return;
2039
+ }
2040
+ process.stdout.write(`Created ${result.configPath}
2041
+ `);
2042
+ process.stdout.write("Next: dev app add --name <name> --host <host.localhost> --protocol <http|tcp> --runtime <host|docker>\n");
2043
+ }
2044
+ var init_repo_init = __esm({
2045
+ "src/commands/repo-init.ts"() {
2046
+ "use strict";
2047
+ init_repo_config();
2048
+ }
2049
+ });
2050
+
2051
+ // src/commands/app-add.ts
2052
+ var app_add_exports = {};
2053
+ __export(app_add_exports, {
2054
+ runAppAddCommand: () => runAppAddCommand
2055
+ });
2056
+ function normalizeOptions(options) {
2057
+ return {
2058
+ name: options.name,
2059
+ host: options.host,
2060
+ protocol: options.protocol,
2061
+ runtime: options.runtime,
2062
+ service: options.service,
2063
+ port: options.port,
2064
+ composeFiles: options.composeFile ?? [],
2065
+ router: options.router,
2066
+ tcpProtocol: options.tcpProtocol,
2067
+ command: options.command,
2068
+ cwd: options.cwd,
2069
+ dependsOn: options.dependsOn ?? []
2070
+ };
2071
+ }
2072
+ async function runAppAddCommand(options) {
2073
+ const repoPath = resolveRepoPath(options.repo);
2074
+ const result = upsertRepoApp(repoPath, normalizeOptions(options));
2075
+ process.stdout.write(`Updated ${result.configPath}
2076
+ `);
2077
+ process.stdout.write(
2078
+ `App '${result.app.name}' (${result.app.protocol}/${result.app.runtime}) -> ${result.app.host}
2079
+ `
2080
+ );
2081
+ process.stdout.write(`Run: dev app run ${result.app.name} --repo ${repoPath}
2082
+ `);
2083
+ }
2084
+ var init_app_add = __esm({
2085
+ "src/commands/app-add.ts"() {
2086
+ "use strict";
2087
+ init_repo_config();
2088
+ }
2089
+ });
2090
+
2091
+ // src/commands/app-ls.ts
2092
+ var app_ls_exports = {};
2093
+ __export(app_ls_exports, {
2094
+ runAppLsCommand: () => runAppLsCommand
2095
+ });
2096
+ async function runAppLsCommand(options) {
2097
+ const repoPath = resolveRepoPath(options.repo);
2098
+ const config = loadRepoConfig(repoPath);
2099
+ if (options.json) {
2100
+ printJSON({
2101
+ repoPath,
2102
+ configPath: `${repoPath}/.devrouter.yml`,
2103
+ apps: config.apps
2104
+ });
2105
+ return;
2106
+ }
2107
+ printConfigApps(repoPath, config.apps);
2108
+ }
2109
+ var init_app_ls = __esm({
2110
+ "src/commands/app-ls.ts"() {
2111
+ "use strict";
2112
+ init_output();
2113
+ init_repo_config();
2114
+ }
2115
+ });
2116
+
2117
+ // src/core/paths.ts
2118
+ function assertPathWithinRepo(filePath, repoRoot, label) {
2119
+ const resolvedRoot = import_node_path5.default.resolve(repoRoot);
2120
+ const resolved = import_node_path5.default.resolve(repoRoot, filePath);
2121
+ if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + import_node_path5.default.sep)) {
2122
+ throw new Error(`${label} path '${filePath}' escapes the repository root.`);
2123
+ }
2124
+ return resolved;
2125
+ }
2126
+ var import_node_path5;
2127
+ var init_paths = __esm({
2128
+ "src/core/paths.ts"() {
2129
+ "use strict";
2130
+ import_node_path5 = __toESM(require("path"));
2131
+ }
2132
+ });
2133
+
2134
+ // src/core/docker-run.ts
2135
+ function sanitizeRouterId(value) {
2136
+ return value.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
2137
+ }
2138
+ function repoHash(repoPath) {
2139
+ return (0, import_node_crypto.createHash)("sha1").update(import_node_path6.default.resolve(repoPath)).digest("hex").slice(0, 12);
2140
+ }
2141
+ function asDockerApp(app) {
2142
+ return app.runtime === "docker";
2143
+ }
2144
+ function ensureComposeFiles(dockerApps) {
2145
+ const files = [];
2146
+ for (const app of dockerApps) {
2147
+ for (const file of app.docker.composeFiles) {
2148
+ if (!files.includes(file)) {
2149
+ files.push(file);
2150
+ }
2151
+ }
2152
+ }
2153
+ return files.length > 0 ? files : ["docker-compose.yml"];
2154
+ }
2155
+ function buildOverlayDocument(dockerApps) {
2156
+ const services = {};
2157
+ for (const app of dockerApps) {
2158
+ const routerId = sanitizeRouterId(app.docker.router ?? app.name);
2159
+ const labels = {
2160
+ "traefik.enable": "true",
2161
+ "traefik.docker.network": "devnet"
2162
+ };
2163
+ if (app.protocol === "http") {
2164
+ labels[`traefik.http.routers.${routerId}.rule`] = `Host(\`${app.host}\`)`;
2165
+ labels[`traefik.http.routers.${routerId}.entrypoints`] = "web,websecure";
2166
+ labels[`traefik.http.routers.${routerId}.tls`] = "true";
2167
+ labels[`traefik.http.services.${routerId}.loadbalancer.server.port`] = String(
2168
+ app.docker.internalPort
2169
+ );
2170
+ } else {
2171
+ labels[`traefik.tcp.routers.${routerId}.rule`] = `HostSNI(\`${app.host}\`)`;
2172
+ labels[`traefik.tcp.routers.${routerId}.entrypoints`] = "postgres";
2173
+ labels[`traefik.tcp.routers.${routerId}.tls`] = "true";
2174
+ labels[`traefik.tcp.services.${routerId}.loadbalancer.server.port`] = String(
2175
+ app.docker.internalPort
2176
+ );
2177
+ }
2178
+ services[app.docker.service] = {
2179
+ networks: ["devnet"],
2180
+ labels
2181
+ };
2182
+ }
2183
+ return {
2184
+ services,
2185
+ networks: {
2186
+ devnet: {
2187
+ external: true
2188
+ }
2189
+ }
2190
+ };
2191
+ }
2192
+ function prepareDockerOverlay(repoPath, appName, apps) {
2193
+ const dockerApps = apps.filter(asDockerApp);
2194
+ if (dockerApps.length === 0) {
2195
+ throw new Error("No docker apps selected to prepare compose overlay.");
2196
+ }
2197
+ const cachePath = import_node_path6.default.join(CACHE_DIR, repoHash(repoPath), sanitizeRouterId(appName));
2198
+ import_node_fs6.default.mkdirSync(cachePath, { recursive: true });
2199
+ const overlayPath = import_node_path6.default.join(cachePath, "compose.devrouter.yml");
2200
+ const overlayDocument = buildOverlayDocument(dockerApps);
2201
+ import_node_fs6.default.writeFileSync(overlayPath, import_yaml3.default.stringify(overlayDocument, { lineWidth: 0 }), "utf-8");
2202
+ return {
2203
+ overlayPath,
2204
+ composeFiles: ensureComposeFiles(dockerApps),
2205
+ dockerApps
2206
+ };
2207
+ }
2208
+ function runDockerComposeUp(repoPath, composeFiles, overlayPath, services) {
2209
+ const fileArgs = [];
2210
+ for (const composeFile of composeFiles) {
2211
+ const resolved = assertPathWithinRepo(composeFile, repoPath, "composeFiles");
2212
+ fileArgs.push("-f", resolved);
2213
+ }
2214
+ const args = ["compose", ...fileArgs, "-f", overlayPath, "up", "-d", ...services];
2215
+ const result = (0, import_node_child_process5.spawnSync)("docker", args, {
2216
+ encoding: "utf-8",
2217
+ cwd: repoPath
2218
+ });
2219
+ if (result.status !== 0) {
2220
+ const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
2221
+ throw new Error(`docker compose up failed: ${details || "unknown error"}`);
2222
+ }
2223
+ }
2224
+ var import_node_fs6, import_node_path6, import_node_crypto, import_node_child_process5, import_yaml3;
2225
+ var init_docker_run = __esm({
2226
+ "src/core/docker-run.ts"() {
2227
+ "use strict";
2228
+ import_node_fs6 = __toESM(require("fs"));
2229
+ import_node_path6 = __toESM(require("path"));
2230
+ import_node_crypto = require("crypto");
2231
+ import_node_child_process5 = require("child_process");
2232
+ import_yaml3 = __toESM(require("yaml"));
2233
+ init_router();
2234
+ init_paths();
2235
+ }
2236
+ });
2237
+
2238
+ // src/core/app-run.ts
2239
+ function sleep(ms) {
2240
+ return new Promise((resolve) => {
2241
+ setTimeout(resolve, ms);
2242
+ });
2243
+ }
2244
+ function toError(error) {
2245
+ if (error instanceof Error) {
2246
+ return error;
2247
+ }
2248
+ return new Error(String(error));
2249
+ }
2250
+ function isProcessRunning(pid) {
2251
+ if (!Number.isInteger(pid) || pid <= 0) {
2252
+ return false;
2253
+ }
2254
+ try {
2255
+ process.kill(pid, 0);
2256
+ return true;
2257
+ } catch {
2258
+ return false;
2259
+ }
2260
+ }
2261
+ function readProcessTree(rootPid) {
2262
+ const result = (0, import_node_child_process6.spawnSync)("ps", ["-ax", "-o", "pid=,ppid="], { encoding: "utf-8" });
2263
+ if (result.status !== 0) {
2264
+ return [rootPid];
2265
+ }
2266
+ const childrenByParent = /* @__PURE__ */ new Map();
2267
+ for (const line of result.stdout.split("\n")) {
2268
+ const parts = line.trim().split(/\s+/);
2269
+ if (parts.length < 2) {
2270
+ continue;
2271
+ }
2272
+ const pid = Number(parts[0]);
2273
+ const ppid = Number(parts[1]);
2274
+ if (!Number.isInteger(pid) || !Number.isInteger(ppid)) {
2275
+ continue;
2276
+ }
2277
+ const children = childrenByParent.get(ppid) ?? [];
2278
+ children.push(pid);
2279
+ childrenByParent.set(ppid, children);
2280
+ }
2281
+ const found = /* @__PURE__ */ new Set([rootPid]);
2282
+ const queue = [rootPid];
2283
+ while (queue.length > 0) {
2284
+ const current = queue.shift();
2285
+ if (current === void 0) {
2286
+ continue;
2287
+ }
2288
+ for (const child of childrenByParent.get(current) ?? []) {
2289
+ if (found.has(child)) {
2290
+ continue;
2291
+ }
2292
+ found.add(child);
2293
+ queue.push(child);
2294
+ }
2295
+ }
2296
+ return Array.from(found.values());
2297
+ }
2298
+ function killProcessTree(rootPid, signal) {
2299
+ const pids = readProcessTree(rootPid).sort((a, b) => b - a);
2300
+ for (const pid of pids) {
2301
+ if (!isProcessRunning(pid)) {
2302
+ continue;
2303
+ }
2304
+ try {
2305
+ process.kill(pid, signal);
2306
+ } catch {
2307
+ }
2308
+ }
2309
+ }
2310
+ async function terminateProcessTree(rootPid) {
2311
+ if (!isProcessRunning(rootPid)) {
2312
+ return;
2313
+ }
2314
+ killProcessTree(rootPid, "SIGTERM");
2315
+ const deadline = Date.now() + PROCESS_TERMINATION_GRACE_MS;
2316
+ while (Date.now() < deadline) {
2317
+ if (!isProcessRunning(rootPid)) {
2318
+ return;
2319
+ }
2320
+ await sleep(100);
2321
+ }
2322
+ if (isProcessRunning(rootPid)) {
2323
+ killProcessTree(rootPid, "SIGKILL");
2324
+ }
2325
+ }
2326
+ function parseListeningPorts(outputText) {
2327
+ const ports = /* @__PURE__ */ new Set();
2328
+ for (const line of outputText.split("\n")) {
2329
+ const match = line.match(/:(\d+)\s+\(LISTEN\)\s*$/);
2330
+ if (!match) {
2331
+ continue;
2332
+ }
2333
+ const port = Number(match[1]);
2334
+ if (Number.isInteger(port) && port > 0) {
2335
+ ports.add(port);
2336
+ }
2337
+ }
2338
+ return Array.from(ports.values()).sort((a, b) => a - b);
2339
+ }
2340
+ function detectListeningPorts(pids) {
2341
+ if (pids.length === 0) {
2342
+ return [];
2343
+ }
2344
+ const result = (0, import_node_child_process6.spawnSync)(
2345
+ "lsof",
2346
+ ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", pids.join(",")],
2347
+ { encoding: "utf-8" }
2348
+ );
2349
+ if (result.status !== 0) {
2350
+ return [];
2351
+ }
2352
+ return parseListeningPorts(result.stdout);
2353
+ }
2354
+ function parseAllowedPortRange(value) {
2355
+ const match = value.trim().match(/^(\d+)-(\d+)$/);
2356
+ if (!match) {
2357
+ return { min: 1024, max: 65535 };
2358
+ }
2359
+ const min = Number(match[1]);
2360
+ const max = Number(match[2]);
2361
+ if (!Number.isInteger(min) || !Number.isInteger(max) || min < 1 || max > 65535 || min > max) {
2362
+ return { min: 1024, max: 65535 };
2363
+ }
2364
+ return { min, max };
2365
+ }
2366
+ function selectAllowedPort(ports, app) {
2367
+ const denyPorts = /* @__PURE__ */ new Set([80, 443, 5432, ...app.hostRun.strategy.denyPorts]);
2368
+ const deniedPort = ports.find((port) => denyPorts.has(port));
2369
+ if (deniedPort !== void 0) {
2370
+ throw new Error(
2371
+ `Detected forbidden host app port ${deniedPort}. Traefik owns 80/443/5432.`
2372
+ );
2373
+ }
2374
+ const range = parseAllowedPortRange(app.hostRun.strategy.allowPortRange);
2375
+ return ports.find((port) => port >= range.min && port <= range.max && !denyPorts.has(port));
2376
+ }
2377
+ async function runHostApp(repoPath, app) {
2378
+ const routeId = buildHostRouteId(repoPath, app.name);
2379
+ const commandCwd = assertPathWithinRepo(app.hostRun.cwd, repoPath, "hostRun.cwd");
2380
+ const child = (0, import_node_child_process6.spawn)(app.hostRun.command, {
2381
+ cwd: commandCwd,
2382
+ stdio: "inherit",
2383
+ shell: true
2384
+ });
2385
+ if (!child.pid) {
2386
+ throw new Error(`Failed to start command '${app.hostRun.command}'.`);
2387
+ }
2388
+ process.stdout.write(`Started '${app.hostRun.command}' for '${app.name}' in ${commandCwd}
2389
+ `);
2390
+ const childExit = new Promise((resolve) => {
2391
+ child.once("exit", (code) => resolve({ code }));
2392
+ });
2393
+ let stopRequested = false;
2394
+ let fatalError = null;
2395
+ let currentPort;
2396
+ const startedAt = Date.now();
2397
+ const onSignal = (signal) => {
2398
+ stopRequested = true;
2399
+ if (isProcessRunning(child.pid)) {
2400
+ killProcessTree(child.pid, signal);
2401
+ }
2402
+ };
2403
+ process.on("SIGINT", onSignal);
2404
+ process.on("SIGTERM", onSignal);
2405
+ try {
2406
+ while (true) {
2407
+ if (stopRequested) {
2408
+ break;
2409
+ }
2410
+ if (!isProcessRunning(child.pid)) {
2411
+ break;
2412
+ }
2413
+ const ports = detectListeningPorts(readProcessTree(child.pid));
2414
+ const selectedPort = selectAllowedPort(ports, app);
2415
+ if (selectedPort !== void 0 && selectedPort !== currentPort) {
2416
+ currentPort = selectedPort;
2417
+ upsertHostRoute({
2418
+ name: app.name,
2419
+ host: app.host,
2420
+ protocol: "http",
2421
+ repoPath,
2422
+ port: selectedPort,
2423
+ mode: "run",
2424
+ pid: child.pid,
2425
+ command: app.hostRun.command
2426
+ });
2427
+ process.stdout.write(`Route ${app.host} -> http://host.docker.internal:${selectedPort}
2428
+ `);
2429
+ } else if (!currentPort && Date.now() - startedAt > INITIAL_PORT_TIMEOUT_MS) {
2430
+ throw new Error(
2431
+ `No listening TCP port detected for '${app.name}' after ${Math.floor(
2432
+ INITIAL_PORT_TIMEOUT_MS / 1e3
2433
+ )}s.`
2434
+ );
2435
+ }
2436
+ await sleep(POLL_INTERVAL_MS);
2437
+ }
2438
+ } catch (error) {
2439
+ fatalError = toError(error);
2440
+ stopRequested = true;
2441
+ await terminateProcessTree(child.pid);
2442
+ } finally {
2443
+ process.off("SIGINT", onSignal);
2444
+ process.off("SIGTERM", onSignal);
2445
+ const processStillRunning = isProcessRunning(child.pid);
2446
+ if (stopRequested || fatalError || !processStillRunning) {
2447
+ removeHostRouteById(routeId);
2448
+ }
2449
+ }
2450
+ const exit = await childExit;
2451
+ if (fatalError) {
2452
+ throw fatalError;
2453
+ }
2454
+ if (exit.code !== null && exit.code !== 0) {
2455
+ throw new Error(`Host command for '${app.name}' exited with code ${exit.code}.`);
2456
+ }
2457
+ }
2458
+ function uniqueApps(apps) {
2459
+ const byName = /* @__PURE__ */ new Map();
2460
+ for (const app of apps) {
2461
+ byName.set(app.name, app);
2462
+ }
2463
+ return Array.from(byName.values());
2464
+ }
2465
+ function dependencyNames(apps) {
2466
+ return apps.map((entry) => entry.name).sort();
2467
+ }
2468
+ async function shouldStartDependencies(appName, dependencies, yes) {
2469
+ if (dependencies.length === 0) {
2470
+ return false;
2471
+ }
2472
+ if (yes) {
2473
+ return true;
2474
+ }
2475
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2476
+ throw new Error(
2477
+ `App '${appName}' has dependencies (${dependencyNames(
2478
+ dependencies
2479
+ ).join(", ")}). Re-run with --yes in non-interactive mode.`
2480
+ );
2481
+ }
2482
+ const rl = (0, import_promises.createInterface)({ input: import_node_process.stdin, output: import_node_process.stdout });
2483
+ try {
2484
+ const answer = await rl.question(
2485
+ `Start dependencies for '${appName}' (${dependencyNames(dependencies).join(", ")})? [y/N] `
2486
+ );
2487
+ return /^y(es)?$/i.test(answer.trim());
2488
+ } finally {
2489
+ rl.close();
2490
+ }
2491
+ }
2492
+ async function runConfiguredApp(options) {
2493
+ ensureRouterFiles();
2494
+ await ensureNetwork(DEVNET_NAME);
2495
+ const repoPath = resolveRepoPath(options.repoPath);
2496
+ const { config, app } = resolveAppByName(repoPath, options.name);
2497
+ const dependencies = resolveAppDependencies(config, app);
2498
+ const unsupportedDependencies = dependencies.filter((entry) => entry.runtime !== "docker");
2499
+ if (unsupportedDependencies.length > 0) {
2500
+ throw new Error(
2501
+ `App '${app.name}' has host-runtime dependencies (${dependencyNames(
2502
+ unsupportedDependencies
2503
+ ).join(", ")}). v1 only auto-starts docker dependencies. Start host dependencies manually before running this app.`
2504
+ );
2505
+ }
2506
+ const startDependencies = await shouldStartDependencies(
2507
+ app.name,
2508
+ dependencies,
2509
+ Boolean(options.yes)
2510
+ );
2511
+ const selectedApps = uniqueApps(
2512
+ startDependencies ? [app, ...dependencies] : [app]
2513
+ );
2514
+ const selectedDockerApps = selectedApps.filter(
2515
+ (entry) => entry.runtime === "docker"
2516
+ );
2517
+ const startedServices = [];
2518
+ if (selectedDockerApps.length > 0) {
2519
+ const overlay = prepareDockerOverlay(repoPath, app.name, selectedDockerApps);
2520
+ const services = selectedDockerApps.map((entry) => entry.docker.service);
2521
+ runDockerComposeUp(repoPath, overlay.composeFiles, overlay.overlayPath, services);
2522
+ startedServices.push(...services);
2523
+ }
2524
+ if (app.runtime === "host") {
2525
+ await runHostApp(repoPath, app);
2526
+ } else if (app.protocol === "tcp") {
2527
+ process.stdout.write(
2528
+ `TCP route ready: postgres://${app.host}:5432 (tls required, e.g. sslmode=require)
2529
+ `
2530
+ );
2531
+ }
2532
+ return {
2533
+ repoPath,
2534
+ appName: app.name,
2535
+ mode: app.runtime,
2536
+ startedServices,
2537
+ dependencyApps: startDependencies ? dependencyNames(dependencies) : []
2538
+ };
2539
+ }
2540
+ var import_promises, import_node_child_process6, import_node_process, POLL_INTERVAL_MS, INITIAL_PORT_TIMEOUT_MS, PROCESS_TERMINATION_GRACE_MS;
2541
+ var init_app_run = __esm({
2542
+ "src/core/app-run.ts"() {
2543
+ "use strict";
2544
+ import_promises = require("readline/promises");
2545
+ import_node_child_process6 = require("child_process");
2546
+ import_node_process = require("process");
2547
+ init_docker_run();
2548
+ init_repo_config();
2549
+ init_host_routes();
2550
+ init_docker();
2551
+ init_router();
2552
+ init_paths();
2553
+ POLL_INTERVAL_MS = 1e3;
2554
+ INITIAL_PORT_TIMEOUT_MS = 3e4;
2555
+ PROCESS_TERMINATION_GRACE_MS = 3e3;
2556
+ }
2557
+ });
2558
+
2559
+ // src/commands/app-run.ts
2560
+ var app_run_exports = {};
2561
+ __export(app_run_exports, {
2562
+ runAppRunCommand: () => runAppRunCommand
2563
+ });
2564
+ async function runAppRunCommand(options) {
2565
+ const result = await runConfiguredApp({
2566
+ name: options.name,
2567
+ repoPath: options.repo,
2568
+ yes: options.yes
2569
+ });
2570
+ if (result.startedServices.length > 0) {
2571
+ process.stdout.write(
2572
+ `Started docker services: ${result.startedServices.join(", ")}
2573
+ `
2574
+ );
2575
+ }
2576
+ if (result.dependencyApps.length > 0) {
2577
+ process.stdout.write(`Configured dependencies: ${result.dependencyApps.join(", ")}
2578
+ `);
2579
+ }
2580
+ process.stdout.write(`App '${result.appName}' is running in ${result.mode} mode.
2581
+ `);
2582
+ }
2583
+ var init_app_run2 = __esm({
2584
+ "src/commands/app-run.ts"() {
2585
+ "use strict";
2586
+ init_app_run();
2587
+ }
2588
+ });
2589
+
2590
+ // src/commands/app-rm.ts
2591
+ var app_rm_exports = {};
2592
+ __export(app_rm_exports, {
2593
+ runAppRmCommand: () => runAppRmCommand
2594
+ });
2595
+ async function runAppRmCommand(options) {
2596
+ const repoPath = resolveRepoPath(options.repo);
2597
+ const result = removeRepoApp(repoPath, options.name);
2598
+ if (!result.removed) {
2599
+ throw new Error(`App '${options.name}' not found in ${result.configPath}.`);
2600
+ }
2601
+ try {
2602
+ removeHostRouteByName(options.name, repoPath);
2603
+ } catch {
2604
+ }
2605
+ process.stdout.write(`Removed '${options.name}' from ${result.configPath}
2606
+ `);
2607
+ }
2608
+ var init_app_rm = __esm({
2609
+ "src/commands/app-rm.ts"() {
2610
+ "use strict";
2611
+ init_host_routes();
2612
+ init_repo_config();
2613
+ }
2614
+ });
2615
+
2616
+ // src/core/tls.ts
2617
+ function runOrThrow(command, args) {
2618
+ const result = (0, import_node_child_process7.spawnSync)(command, args, {
2619
+ encoding: "utf-8"
2620
+ });
2621
+ if (result.status !== 0) {
2622
+ const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
2623
+ throw new Error(`${command} ${args.join(" ")} failed: ${details || "unknown error"}`);
2624
+ }
2625
+ }
2626
+ function commandExists(command) {
2627
+ const result = (0, import_node_child_process7.spawnSync)("sh", ["-c", `command -v ${command}`], { encoding: "utf-8" });
2628
+ return result.status === 0;
2629
+ }
2630
+ function ensureMkcert() {
2631
+ if (commandExists("mkcert")) {
2632
+ return;
2633
+ }
2634
+ if (!commandExists("brew")) {
2635
+ throw new Error("mkcert is missing and Homebrew is not available.");
2636
+ }
2637
+ runOrThrow("brew", ["install", "mkcert"]);
2638
+ }
2639
+ async function installTLS() {
2640
+ ensureRouterFiles();
2641
+ const alreadyEnabled = isTLSEnabled();
2642
+ ensureMkcert();
2643
+ runOrThrow("mkcert", ["-install"]);
2644
+ runOrThrow("mkcert", [
2645
+ "-cert-file",
2646
+ CERT_FILE,
2647
+ "-key-file",
2648
+ CERT_KEY_FILE,
2649
+ "localhost",
2650
+ "*.localhost"
2651
+ ]);
2652
+ setTLSEnabled(true);
2653
+ refreshHostRoutesDynamicFile();
2654
+ const routerContainer = await findContainerByName("devrouter-traefik");
2655
+ if (routerContainer && await isContainerRunning("devrouter-traefik")) {
2656
+ startRouterStack();
2657
+ }
2658
+ return { alreadyEnabled };
2659
+ }
2660
+ var import_node_child_process7;
2661
+ var init_tls = __esm({
2662
+ "src/core/tls.ts"() {
2663
+ "use strict";
2664
+ import_node_child_process7 = require("child_process");
2665
+ init_router();
2666
+ init_docker();
2667
+ init_host_routes();
2668
+ }
2669
+ });
2670
+
2671
+ // src/commands/tls.ts
2672
+ var tls_exports = {};
2673
+ __export(tls_exports, {
2674
+ runTLSInstallCommand: () => runTLSInstallCommand
2675
+ });
2676
+ async function runTLSInstallCommand() {
2677
+ const result = await installTLS();
2678
+ if (result.alreadyEnabled) {
2679
+ process.stdout.write("TLS was already enabled and has been refreshed.\n");
2680
+ return;
2681
+ }
2682
+ process.stdout.write("TLS is now enabled for localhost and *.localhost.\n");
2683
+ }
2684
+ var init_tls2 = __esm({
2685
+ "src/commands/tls.ts"() {
2686
+ "use strict";
2687
+ init_tls();
2688
+ }
2689
+ });
2690
+
2691
+ // src/cli.ts
2692
+ var import_commander = require("commander");
2693
+ function withErrorHandling(action) {
2694
+ return async (...args) => {
2695
+ try {
2696
+ await action(...args);
2697
+ } catch (error) {
2698
+ const message = error instanceof Error ? error.message : String(error);
2699
+ process.stderr.write(`Error: ${message}
2700
+ `);
2701
+ process.exitCode = 1;
2702
+ }
2703
+ };
2704
+ }
2705
+ var program = new import_commander.Command();
2706
+ program.name("dev").description("Local dev router CLI for stable .localhost routing across repositories").version("0.1.0").showSuggestionAfterError(true).showHelpAfterError();
2707
+ program.command("init").description("Print an AI onboarding prompt template for adapting a repository to devrouter").option("--repo <path>", "Repository path to embed in the prompt (defaults to current directory)").option("--entries-json <json>", "Optional JSON array of app entries to embed in the prompt").option("--json", "Output prompt and command intents as JSON").action(withErrorHandling(async (options) => {
2708
+ const { runInitCommand: runInitCommand2 } = await Promise.resolve().then(() => (init_init(), init_exports));
2709
+ await runInitCommand2(options);
2710
+ }));
2711
+ program.command("up").description("Ensure devnet and start shared Traefik (reserves 80/443/5432)").action(withErrorHandling(async () => {
2712
+ const { runUpCommand: runUpCommand2 } = await Promise.resolve().then(() => (init_up(), up_exports));
2713
+ await runUpCommand2();
2714
+ }));
2715
+ program.command("down").description("Stop the shared Traefik router stack").action(withErrorHandling(async () => {
2716
+ const { runDownCommand: runDownCommand2 } = await Promise.resolve().then(() => (init_down(), down_exports));
2717
+ await runDownCommand2();
2718
+ }));
2719
+ program.command("status").description("Show router/container/network/TLS status and bound ports").option("--json", "Output JSON").option("--repo <path>", "Repository path for repo-specific readiness insights").action(withErrorHandling(async (options) => {
2720
+ const { runStatusCommand: runStatusCommand2 } = await Promise.resolve().then(() => (init_status2(), status_exports));
2721
+ await runStatusCommand2(options);
2722
+ }));
2723
+ program.command("doctor").alias("verify").description("Run global + repo diagnostics with actionable fixes for humans and AI agents").option("--repo <path>", "Repository path to validate (defaults to current directory)").option("--json", "Output JSON").action(withErrorHandling(async (options) => {
2724
+ const { runDoctorCommand: runDoctorCommand2 } = await Promise.resolve().then(() => (init_doctor2(), doctor_exports));
2725
+ await runDoctorCommand2(options);
2726
+ }));
2727
+ program.command("ls").alias("list").description("List active HTTP and TCP routes from Docker labels and host runtime state").option("--json", "Output JSON").action(withErrorHandling(async (options) => {
2728
+ const { runLsCommand: runLsCommand2 } = await Promise.resolve().then(() => (init_ls(), ls_exports));
2729
+ await runLsCommand2(Boolean(options.json));
2730
+ }));
2731
+ program.command("open").description("Open HTTP routes in browser or show TCP connection hints by name/host").argument("<name>", "service name or host").action(withErrorHandling(async (name) => {
2732
+ const { runOpenCommand: runOpenCommand2 } = await Promise.resolve().then(() => (init_open(), open_exports));
2733
+ await runOpenCommand2(name);
2734
+ }));
2735
+ var repoCommand = program.command("repo").description("Create and manage `.devrouter.yml` in repositories");
2736
+ repoCommand.command("init").description("Initialize `.devrouter.yml` in a repository").option("--repo <path>", "Repository path (defaults to current directory)").action(withErrorHandling(async (options) => {
2737
+ const { runRepoInitCommand: runRepoInitCommand2 } = await Promise.resolve().then(() => (init_repo_init(), repo_init_exports));
2738
+ await runRepoInitCommand2(options);
2739
+ }));
2740
+ var appCommand = program.command("app").description("Manage app entries and runtime actions from `.devrouter.yml`");
2741
+ appCommand.command("add").description("Add or update one app definition in `.devrouter.yml`").requiredOption("--name <name>", "App name").requiredOption("--host <host>", "Hostname ending with .localhost").requiredOption("--protocol <protocol>", "http or tcp").requiredOption("--runtime <runtime>", "host or docker").option("--service <service>", "Docker service name (runtime=docker)").option("--port <port>", "Internal port (runtime=docker)", (value) => Number(value)).option("--compose-file <file>", "Compose file path (repeatable)", (value, prev) => {
2742
+ const next = prev ?? [];
2743
+ next.push(value);
2744
+ return next;
2745
+ }).option("--router <id>", "Optional Traefik router ID").option("--tcp-protocol <protocol>", "tcp protocol (postgres)").option("--command <command>", "Host command (runtime=host)").option("--cwd <path>", "Host command working directory (runtime=host)").option("--depends-on <app>", "Dependency app name (repeatable)", (value, prev) => {
2746
+ const next = prev ?? [];
2747
+ next.push(value);
2748
+ return next;
2749
+ }).option("--repo <path>", "Repository path (defaults to current directory)").action(withErrorHandling(async (options) => {
2750
+ const { runAppAddCommand: runAppAddCommand2 } = await Promise.resolve().then(() => (init_app_add(), app_add_exports));
2751
+ await runAppAddCommand2(options);
2752
+ }));
2753
+ appCommand.command("ls").description("List app definitions from `.devrouter.yml`").option("--repo <path>", "Repository path (defaults to current directory)").option("--json", "Output JSON").action(withErrorHandling(async (options) => {
2754
+ const { runAppLsCommand: runAppLsCommand2 } = await Promise.resolve().then(() => (init_app_ls(), app_ls_exports));
2755
+ await runAppLsCommand2(options);
2756
+ }));
2757
+ appCommand.command("run").description("Run one configured app and reconcile its active route").argument("<name>", "Configured app name").option("--repo <path>", "Repository path (defaults to current directory)").option("--yes", "Auto-start dependencies without prompt").action(withErrorHandling(async (name, _options, command) => {
2758
+ const options = command.opts();
2759
+ const { runAppRunCommand: runAppRunCommand2 } = await Promise.resolve().then(() => (init_app_run2(), app_run_exports));
2760
+ await runAppRunCommand2({ name, repo: options.repo, yes: Boolean(options.yes) });
2761
+ }));
2762
+ appCommand.command("rm").description("Remove one app definition from `.devrouter.yml`").argument("<name>", "Configured app name").option("--repo <path>", "Repository path (defaults to current directory)").action(withErrorHandling(async (name, _options, command) => {
2763
+ const options = command.opts();
2764
+ const { runAppRmCommand: runAppRmCommand2 } = await Promise.resolve().then(() => (init_app_rm(), app_rm_exports));
2765
+ await runAppRmCommand2({ name, repo: options.repo });
2766
+ }));
2767
+ var tlsCommand = program.command("tls").description("TLS helpers for HTTPS and Postgres SNI routing");
2768
+ tlsCommand.command("install").description("Install mkcert certs and enable HTTPS + TLS redirect behavior").action(withErrorHandling(async () => {
2769
+ const { runTLSInstallCommand: runTLSInstallCommand2 } = await Promise.resolve().then(() => (init_tls2(), tls_exports));
2770
+ await runTLSInstallCommand2();
2771
+ }));
2772
+ program.parseAsync(process.argv);