@gachlab/devup 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,8 +3,10 @@
3
3
  // src/index.ts
4
4
  import React7 from "react";
5
5
  import { render } from "ink";
6
- import { join as join5 } from "path";
7
- import { homedir as homedir2 } from "os";
6
+ import { readFileSync as readFileSync2 } from "fs";
7
+ import { dirname as dirname6, join as join7 } from "path";
8
+ import { fileURLToPath as fileURLToPath2 } from "url";
9
+ import { homedir as homedir3 } from "os";
8
10
 
9
11
  // src/config/loader.ts
10
12
  import { existsSync } from "fs";
@@ -112,6 +114,25 @@ function validateConfig(config, cwd) {
112
114
  if (svc.cwd && !existsSync2(resolve2(cwd, svc.cwd))) {
113
115
  errors.push({ field: `services[${svc.name}].cwd`, message: `Directory not found: ${svc.cwd}` });
114
116
  }
117
+ if (svc.readyPattern !== void 0) {
118
+ if (typeof svc.readyPattern !== "string" || !svc.readyPattern.length) {
119
+ errors.push({ field: `services[${svc.name}].readyPattern`, message: `readyPattern must be a non-empty string` });
120
+ } else {
121
+ const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(svc.readyPattern);
122
+ try {
123
+ if (slashed) new RegExp(slashed[1], slashed[2] || "i");
124
+ else new RegExp(svc.readyPattern, "i");
125
+ } catch (e) {
126
+ errors.push({ field: `services[${svc.name}].readyPattern`, message: `Invalid regex: ${e.message}` });
127
+ }
128
+ }
129
+ }
130
+ if (svc.preBuild !== void 0 && (typeof svc.preBuild !== "string" || !svc.preBuild.trim())) {
131
+ errors.push({ field: `services[${svc.name}].preBuild`, message: `preBuild must be a non-empty string` });
132
+ }
133
+ if (svc.watchBuild !== void 0 && (typeof svc.watchBuild !== "string" || !svc.watchBuild.trim())) {
134
+ errors.push({ field: `services[${svc.name}].watchBuild`, message: `watchBuild must be a non-empty string` });
135
+ }
115
136
  if (svc.healthCheck) {
116
137
  const hc = svc.healthCheck;
117
138
  if (hc.type !== "tcp" && hc.type !== "http") {
@@ -145,6 +166,34 @@ function validateConfig(config, cwd) {
145
166
  }
146
167
  }
147
168
  }
169
+ if (config.external) {
170
+ const extNames = /* @__PURE__ */ new Set();
171
+ for (const ext of config.external) {
172
+ if (!ext.name?.trim()) {
173
+ errors.push({ field: "external[].name", message: "External service name is required" });
174
+ continue;
175
+ }
176
+ if (extNames.has(ext.name)) {
177
+ errors.push({ field: `external[${ext.name}].name`, message: `Duplicate external name: ${ext.name}` });
178
+ }
179
+ extNames.add(ext.name);
180
+ if (!ext.cmd?.trim()) {
181
+ errors.push({ field: `external[${ext.name}].cmd`, message: "cmd is required" });
182
+ }
183
+ if (ext.healthCheck) {
184
+ const hc = ext.healthCheck;
185
+ if (hc.type !== "tcp" && hc.type !== "http") {
186
+ errors.push({ field: `external[${ext.name}].healthCheck.type`, message: `Invalid healthCheck.type: ${hc.type}` });
187
+ }
188
+ if ((hc.type === "tcp" || hc.type === "http") && (typeof ext.port !== "number" || ext.port <= 0)) {
189
+ errors.push({ field: `external[${ext.name}].port`, message: `port is required when healthCheck is set (got ${ext.port})` });
190
+ }
191
+ if (hc.type === "http" && hc.path && !hc.path.startsWith("/")) {
192
+ errors.push({ field: `external[${ext.name}].healthCheck.path`, message: `must start with "/"` });
193
+ }
194
+ }
195
+ }
196
+ }
148
197
  if (config.proxy?.routes) {
149
198
  for (const ref of Object.keys(config.proxy.routes)) {
150
199
  if (!names.has(ref)) {
@@ -152,6 +201,19 @@ function validateConfig(config, cwd) {
152
201
  }
153
202
  }
154
203
  }
204
+ if (config.profiles) {
205
+ for (const [profile, svcNames] of Object.entries(config.profiles)) {
206
+ if (!Array.isArray(svcNames) || !svcNames.length) {
207
+ errors.push({ field: `profiles.${profile}`, message: `Profile "${profile}" must be a non-empty array of service names` });
208
+ continue;
209
+ }
210
+ for (const ref of svcNames) {
211
+ if (!names.has(ref)) {
212
+ errors.push({ field: `profiles.${profile}`, message: `Unknown service: ${ref}` });
213
+ }
214
+ }
215
+ }
216
+ }
155
217
  return errors;
156
218
  }
157
219
  function formatValidationErrors(errors) {
@@ -161,6 +223,44 @@ function formatValidationErrors(errors) {
161
223
  // src/config/cli.ts
162
224
  var DEFAULT_LAZY_TIMEOUT = 10;
163
225
  var DEFAULT_ONCE_TIMEOUT = 90;
226
+ var USAGE = `devup \u2014 terminal UI dev stack runner
227
+
228
+ Usage: devup [options]
229
+
230
+ Service selection:
231
+ --only apis | webs Start only APIs or only webs
232
+ --services a,b,c Start only the named services
233
+ --profile <name> Start the services in a named profile (see ROADMAP)
234
+ --skip a,b,c Start everything except these
235
+ --config <path> Use a custom config file
236
+
237
+ Lazy mode:
238
+ --lazy Enable lazy mode (default)
239
+ --no-lazy Start every service immediately
240
+ --timeout <minutes> Idle timeout for lazy services. Default: 10
241
+
242
+ Reverse proxy:
243
+ --proxy Enable proxy config generation
244
+ --proxy-host <host> Override the target host (Docker/local)
245
+ --proxy-conf <path> Override the generated config file path
246
+ --proxy-tls Enable TLS in the generated config (default)
247
+ --no-proxy-tls Disable TLS
248
+ --proxy-entrypoint <n> Override entrypoint name (Traefik only)
249
+
250
+ CI / scripting:
251
+ --dry-run Print the resolved boot plan and exit
252
+ --once Boot, wait for readiness, exit 0/1 (no TUI)
253
+ --once-timeout <s> Max seconds to wait in --once mode. Default: 90
254
+
255
+ Log files:
256
+ --no-log-file Disable persistent log files
257
+ --log-dir <path> Override log root (default: ~/.devup/logs)
258
+
259
+ Other:
260
+ -h, --help Show this help and exit
261
+ -v, --version Show version and exit
262
+
263
+ See https://github.com/gachlab/devup for the full documentation.`;
164
264
  function parseCliArgs(argv) {
165
265
  const args = {
166
266
  skip: [],
@@ -194,6 +294,10 @@ function parseCliArgs(argv) {
194
294
  args.services = next?.split(",");
195
295
  i++;
196
296
  break;
297
+ case "--profile":
298
+ args.profile = next;
299
+ i++;
300
+ break;
197
301
  case "--lazy":
198
302
  args.lazy = true;
199
303
  break;
@@ -246,9 +350,18 @@ function parseCliArgs(argv) {
246
350
  }
247
351
  return args;
248
352
  }
249
- function filterServices(services, args) {
353
+ function filterServices(services, args, config) {
250
354
  let result = services;
251
- if (args.services) {
355
+ if (args.profile) {
356
+ const profileNames = config?.profiles?.[args.profile];
357
+ if (!profileNames) {
358
+ const available = Object.keys(config?.profiles ?? {});
359
+ const hint = available.length ? `Available: ${available.join(", ")}` : "No profiles defined in config.";
360
+ throw new Error(`Unknown profile: "${args.profile}". ${hint}`);
361
+ }
362
+ const set = new Set(profileNames);
363
+ result = result.filter((s) => set.has(s.name));
364
+ } else if (args.services) {
252
365
  const explicit = new Set(args.services);
253
366
  result = result.filter((s) => explicit.has(s.name));
254
367
  } else if (args.only) {
@@ -271,6 +384,359 @@ function filterServices(services, args) {
271
384
  return result;
272
385
  }
273
386
 
387
+ // src/orchestrator/subcommands.ts
388
+ import { spawn } from "child_process";
389
+ import { createReadStream, watchFile, unwatchFile, existsSync as existsSync4, statSync } from "fs";
390
+ import { readFile as readFile2 } from "fs/promises";
391
+ import { join as join3, dirname } from "path";
392
+ import { fileURLToPath } from "url";
393
+ import { homedir } from "os";
394
+ import { createInterface } from "readline";
395
+
396
+ // src/process/health.ts
397
+ import net from "net";
398
+ import http from "http";
399
+ function checkPort(port, host = "127.0.0.1", timeoutMs = 2e3) {
400
+ return new Promise((resolve4) => {
401
+ const socket = new net.Socket();
402
+ socket.setTimeout(timeoutMs);
403
+ socket.once("connect", () => {
404
+ socket.destroy();
405
+ resolve4(true);
406
+ });
407
+ socket.once("error", () => {
408
+ socket.destroy();
409
+ resolve4(false);
410
+ });
411
+ socket.once("timeout", () => {
412
+ socket.destroy();
413
+ resolve4(false);
414
+ });
415
+ socket.connect(port, host);
416
+ });
417
+ }
418
+ function checkHttp(port, opts = {}) {
419
+ const path = opts.path ?? "/";
420
+ const host = opts.host ?? "127.0.0.1";
421
+ const timeoutMs = opts.timeoutMs ?? 2e3;
422
+ const accept = (code) => {
423
+ if (opts.expect === void 0) return code >= 200 && code < 300;
424
+ if (Array.isArray(opts.expect)) return opts.expect.includes(code);
425
+ return code === opts.expect;
426
+ };
427
+ return new Promise((resolve4) => {
428
+ const req = http.get({ host, port, path, timeout: timeoutMs }, (res) => {
429
+ const ok = typeof res.statusCode === "number" && accept(res.statusCode);
430
+ res.resume();
431
+ resolve4(ok);
432
+ });
433
+ req.on("error", () => resolve4(false));
434
+ req.on("timeout", () => {
435
+ req.destroy();
436
+ resolve4(false);
437
+ });
438
+ });
439
+ }
440
+ function checkHealth(port, hc) {
441
+ if (hc?.type === "http") {
442
+ return checkHttp(port, {
443
+ path: hc.path,
444
+ expect: hc.expect,
445
+ host: hc.host,
446
+ timeoutMs: hc.timeoutMs
447
+ });
448
+ }
449
+ return checkPort(port, "127.0.0.1", hc?.timeoutMs);
450
+ }
451
+ function waitForPort(port, opts = {}) {
452
+ const { timeout = 45e3, interval = 1e3 } = opts;
453
+ return new Promise((resolve4) => {
454
+ const start = Date.now();
455
+ const check = () => {
456
+ checkPort(port).then((ok) => {
457
+ if (ok) return resolve4(true);
458
+ if (Date.now() - start > timeout) return resolve4(false);
459
+ setTimeout(check, interval);
460
+ });
461
+ };
462
+ check();
463
+ });
464
+ }
465
+ function deriveHealth(isUp, currentStatus) {
466
+ if (currentStatus === "idle") return "idle";
467
+ if (isUp) return "up";
468
+ return currentStatus === "starting" ? "wait" : "down";
469
+ }
470
+
471
+ // src/utils.ts
472
+ import { existsSync as existsSync3, readFileSync, writeFileSync } from "fs";
473
+ import { createHash } from "crypto";
474
+ import { join as join2 } from "path";
475
+ function parseEnvFile(filePath, baseEnv = {}) {
476
+ const env = { ...baseEnv };
477
+ if (!existsSync3(filePath)) return env;
478
+ for (const line of readFileSync(filePath, "utf8").split("\n")) {
479
+ const trimmed = line.trim();
480
+ if (!trimmed || trimmed.startsWith("#")) continue;
481
+ const eqIdx = trimmed.indexOf("=");
482
+ if (eqIdx === -1) continue;
483
+ const key = trimmed.slice(0, eqIdx).trim();
484
+ let val = trimmed.slice(eqIdx + 1).trim();
485
+ if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
486
+ val = val.slice(1, -1);
487
+ }
488
+ if (!env[key]) env[key] = val;
489
+ }
490
+ return env;
491
+ }
492
+ function fmtUptime(ms) {
493
+ if (!ms || ms < 0) return "-";
494
+ const s = Math.floor(ms / 1e3);
495
+ if (s < 60) return `${s}s`;
496
+ const m = Math.floor(s / 60);
497
+ if (m < 60) return `${m}m${s % 60}s`;
498
+ const h = Math.floor(m / 60);
499
+ if (h < 24) return `${h}h${m % 60}m`;
500
+ const d = Math.floor(h / 24);
501
+ return `${d}d${h % 24}h`;
502
+ }
503
+ function needsInstall(fullCwd) {
504
+ const nm = join2(fullCwd, "node_modules");
505
+ if (!existsSync3(nm)) return true;
506
+ try {
507
+ const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
508
+ const stampFile = join2(nm, ".install-stamp");
509
+ if (existsSync3(stampFile) && readFileSync(stampFile, "utf8") === pkgHash) return false;
510
+ } catch {
511
+ }
512
+ return true;
513
+ }
514
+ function writeInstallStamp(fullCwd) {
515
+ try {
516
+ const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
517
+ writeFileSync(join2(fullCwd, "node_modules", ".install-stamp"), pkgHash);
518
+ } catch {
519
+ }
520
+ }
521
+ function sortServiceNames(names, sortMode, statsMap, procState) {
522
+ if (sortMode === "name") return names.slice().sort();
523
+ return names.slice().sort((a, b) => {
524
+ if (sortMode === "mem") {
525
+ return (parseFloat(statsMap[b]?.mem ?? "0") || 0) - (parseFloat(statsMap[a]?.mem ?? "0") || 0);
526
+ }
527
+ return (procState[b]?.errors ?? 0) - (procState[a]?.errors ?? 0);
528
+ });
529
+ }
530
+ function groupByPhase(services) {
531
+ const phases = {};
532
+ for (const s of services) {
533
+ (phases[s.phase] ??= []).push(s);
534
+ }
535
+ return phases;
536
+ }
537
+ function buildProcessArgs(svc) {
538
+ const extra = svc.nodeArgs ?? [];
539
+ if (!svc.maxMem) return [...extra, ...svc.args];
540
+ if (svc.cmd === "node") return [`--max-old-space-size=${svc.maxMem}`, ...extra, ...svc.args];
541
+ return [...extra, ...svc.args];
542
+ }
543
+ function buildProcessEnv(svc, baseEnv) {
544
+ const env = { ...baseEnv, ...svc.extraEnv ?? {} };
545
+ if (svc.maxMem && svc.cmd !== "node") {
546
+ const existing = env["NODE_OPTIONS"] ?? "";
547
+ const flag = `--max-old-space-size=${svc.maxMem}`;
548
+ if (!existing.includes("max-old-space-size")) {
549
+ env["NODE_OPTIONS"] = existing ? `${existing} ${flag}` : flag;
550
+ }
551
+ }
552
+ return env;
553
+ }
554
+ function calcCpuPercent(totalCpuSec, prevCpu, prevTime) {
555
+ const elapsed = (Date.now() - prevTime) / 1e3;
556
+ const cpuDelta = totalCpuSec - prevCpu;
557
+ return elapsed > 0 ? cpuDelta / elapsed * 100 : 0;
558
+ }
559
+ var tagColors = [
560
+ "cyan",
561
+ "yellow",
562
+ "green",
563
+ "magenta",
564
+ "blue",
565
+ "red",
566
+ "#5faf5f",
567
+ "#d7af5f",
568
+ "#5f87d7",
569
+ "#af5faf",
570
+ "#5fd7d7",
571
+ "#d75f5f",
572
+ "white"
573
+ ];
574
+
575
+ // src/orchestrator/subcommands.ts
576
+ var KNOWN = /* @__PURE__ */ new Set(["logs", "install", "status", "help"]);
577
+ function detectSubcommand(argv) {
578
+ const first = argv[0];
579
+ return first && KNOWN.has(first) ? first : null;
580
+ }
581
+ function logRoot(config, override) {
582
+ const root = override ?? join3(homedir(), ".devup", "logs");
583
+ return join3(root, sanitize(config.name));
584
+ }
585
+ function sanitize(name) {
586
+ return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
587
+ }
588
+ async function runLogs(argv, opts) {
589
+ const out = opts.out ?? ((l) => console.log(l));
590
+ const follow = argv.includes("--follow") || argv.includes("-f");
591
+ const svcArg = argv.find((a) => !a.startsWith("-"));
592
+ if (!svcArg) {
593
+ out("usage: devup logs <service> [--follow]");
594
+ return 1;
595
+ }
596
+ const knownSvcs = opts.config.services.map((s) => s.name);
597
+ if (!knownSvcs.includes(svcArg)) {
598
+ out(`Unknown service "${svcArg}". Known: ${knownSvcs.join(", ")}`);
599
+ return 1;
600
+ }
601
+ const file = join3(logRoot(opts.config, opts.logDir), `${sanitize(svcArg)}.log`);
602
+ if (!existsSync4(file)) {
603
+ out(`No log file yet for "${svcArg}" (${file})`);
604
+ return follow ? await followFile(file, out) : 1;
605
+ }
606
+ await streamFile(file, out);
607
+ if (!follow) return 0;
608
+ return await followFile(file, out, statSync(file).size);
609
+ }
610
+ async function streamFile(file, out) {
611
+ return new Promise((resolve4, reject) => {
612
+ const rl = createInterface({ input: createReadStream(file, { encoding: "utf8" }) });
613
+ rl.on("line", (l) => out(l));
614
+ rl.on("close", () => resolve4());
615
+ rl.on("error", reject);
616
+ });
617
+ }
618
+ async function followFile(file, out, startAt = 0) {
619
+ let pos = startAt;
620
+ while (!existsSync4(file)) await new Promise((r) => setTimeout(r, 500));
621
+ return new Promise((resolve4) => {
622
+ const tick = async () => {
623
+ const size = statSync(file).size;
624
+ if (size > pos) {
625
+ await new Promise((res) => {
626
+ const rl = createInterface({ input: createReadStream(file, { encoding: "utf8", start: pos, end: size - 1 }) });
627
+ rl.on("line", (l) => out(l));
628
+ rl.on("close", () => {
629
+ pos = size;
630
+ res();
631
+ });
632
+ });
633
+ } else if (size < pos) {
634
+ pos = 0;
635
+ }
636
+ };
637
+ watchFile(file, { interval: 500 }, () => {
638
+ void tick();
639
+ });
640
+ process.once("SIGINT", () => {
641
+ unwatchFile(file);
642
+ resolve4(0);
643
+ });
644
+ });
645
+ }
646
+ async function runInstall(opts) {
647
+ const out = opts.out ?? ((l) => console.log(l));
648
+ const concurrency = opts.concurrency ?? 4;
649
+ const items = opts.config.services.map((s) => ({ name: s.name, cwd: join3(opts.baseCwd, s.cwd) }));
650
+ const queue = [...items];
651
+ const failed = [];
652
+ let inFlight = 0;
653
+ await new Promise((resolve4) => {
654
+ const pump = () => {
655
+ while (inFlight < concurrency && queue.length) {
656
+ const item = queue.shift();
657
+ inFlight++;
658
+ installOne(item.cwd, opts.env).then((ok) => {
659
+ inFlight--;
660
+ if (ok) out(`\u2713 ${item.name}`);
661
+ else {
662
+ failed.push(item.name);
663
+ out(`\u2717 ${item.name}`);
664
+ }
665
+ if (queue.length === 0 && inFlight === 0) resolve4();
666
+ else pump();
667
+ });
668
+ }
669
+ };
670
+ pump();
671
+ });
672
+ if (failed.length) {
673
+ out(`
674
+ failed: ${failed.join(", ")}`);
675
+ return 1;
676
+ }
677
+ out(`
678
+ ${items.length} services up to date`);
679
+ return 0;
680
+ }
681
+ function installOne(cwd, env) {
682
+ if (!existsSync4(cwd)) return Promise.resolve(false);
683
+ if (!needsInstall(cwd)) return Promise.resolve(true);
684
+ return new Promise((resolve4) => {
685
+ const command = process.platform === "win32" ? "npm.cmd" : "npm";
686
+ const proc = spawn(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
687
+ proc.on("close", (code) => {
688
+ if (code === 0) {
689
+ writeInstallStamp(cwd);
690
+ resolve4(true);
691
+ } else resolve4(false);
692
+ });
693
+ proc.on("error", () => resolve4(false));
694
+ });
695
+ }
696
+ async function runStatus(opts) {
697
+ const out = opts.out ?? ((l) => console.log(l));
698
+ out(`${opts.config.icon ?? "\u{1F4E6}"} ${opts.config.name} \u2014 ${opts.config.services.length} services`);
699
+ out("");
700
+ const maxLen = Math.max(...opts.config.services.map((s) => s.name.length), 12);
701
+ out(`${"Service".padEnd(maxLen)} ${"Port".padStart(5)} ${"Type".padEnd(4)} Health`);
702
+ out("-".repeat(maxLen + 24));
703
+ for (const svc of opts.config.services) {
704
+ const up = await checkHealth(svc.port, svc.healthCheck);
705
+ const health = up ? "\u2713 up" : "\u2717 down";
706
+ out(`${svc.name.padEnd(maxLen)} ${String(svc.port).padStart(5)} ${svc.type.padEnd(4)} ${health}`);
707
+ }
708
+ return 0;
709
+ }
710
+ function runHelp(argv, opts = {}) {
711
+ const out = opts.out ?? ((l) => console.log(l));
712
+ const sub = argv[0];
713
+ if (sub === "logs") {
714
+ out("Usage: devup logs <service> [--follow|-f]");
715
+ out(" Print the persisted log file for a service (works without devup running).");
716
+ out(" --follow tails new lines as they are appended.");
717
+ return 0;
718
+ }
719
+ if (sub === "install") {
720
+ out("Usage: devup install");
721
+ out(" Run `npm install` across every service.cwd in parallel (max 4 at a time).");
722
+ out(" Skips services whose .install-stamp matches package.json hash.");
723
+ return 0;
724
+ }
725
+ if (sub === "status") {
726
+ out("Usage: devup status");
727
+ out(" For each service, probes its health-check endpoint and prints up/down.");
728
+ return 0;
729
+ }
730
+ out("Subcommands:");
731
+ out(" devup logs <service> [--follow] Read the persisted log file");
732
+ out(" devup install Concurrent npm install across services");
733
+ out(" devup status Health check every service in config");
734
+ out(" devup help [<subcommand>] Show detailed help for a subcommand");
735
+ out("");
736
+ out("No subcommand \u2192 launch the interactive TUI.");
737
+ return 0;
738
+ }
739
+
274
740
  // src/platform/detect.ts
275
741
  async function detectPlatform() {
276
742
  switch (process.platform) {
@@ -292,8 +758,8 @@ async function detectPlatform() {
292
758
  }
293
759
 
294
760
  // src/proxy-config/traefik.ts
295
- import { existsSync as existsSync3, mkdirSync, writeFileSync } from "fs";
296
- import { dirname } from "path";
761
+ import { existsSync as existsSync5, mkdirSync, writeFileSync as writeFileSync2 } from "fs";
762
+ import { dirname as dirname2 } from "path";
297
763
  var EMPTY_CONFIG = "http:\n routers: {}\n services: {}\n";
298
764
  var TraefikProvider = class {
299
765
  name = "traefik";
@@ -330,9 +796,9 @@ ${svcs.join("\n")}
330
796
  `;
331
797
  }
332
798
  write(content, opts) {
333
- const dir = dirname(opts.confPath);
334
- if (!existsSync3(dir)) mkdirSync(dir, { recursive: true });
335
- writeFileSync(opts.confPath, content);
799
+ const dir = dirname2(opts.confPath);
800
+ if (!existsSync5(dir)) mkdirSync(dir, { recursive: true });
801
+ writeFileSync2(opts.confPath, content);
336
802
  }
337
803
  clear(opts) {
338
804
  this.write(EMPTY_CONFIG, opts);
@@ -340,8 +806,8 @@ ${svcs.join("\n")}
340
806
  };
341
807
 
342
808
  // src/proxy-config/nginx.ts
343
- import { existsSync as existsSync4, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
344
- import { dirname as dirname2 } from "path";
809
+ import { existsSync as existsSync6, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
810
+ import { dirname as dirname3 } from "path";
345
811
  var EMPTY_CONFIG2 = "# devup: no healthy services\n";
346
812
  var NginxProvider = class {
347
813
  name = "nginx";
@@ -378,9 +844,9 @@ var NginxProvider = class {
378
844
  return blocks.join("\n\n") + "\n";
379
845
  }
380
846
  write(content, opts) {
381
- const dir = dirname2(opts.confPath);
382
- if (!existsSync4(dir)) mkdirSync2(dir, { recursive: true });
383
- writeFileSync2(opts.confPath, content);
847
+ const dir = dirname3(opts.confPath);
848
+ if (!existsSync6(dir)) mkdirSync2(dir, { recursive: true });
849
+ writeFileSync3(opts.confPath, content);
384
850
  }
385
851
  clear(opts) {
386
852
  this.write(EMPTY_CONFIG2, opts);
@@ -388,8 +854,8 @@ var NginxProvider = class {
388
854
  };
389
855
 
390
856
  // src/proxy-config/caddy.ts
391
- import { existsSync as existsSync5, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
392
- import { dirname as dirname3 } from "path";
857
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
858
+ import { dirname as dirname4 } from "path";
393
859
  var EMPTY_CONFIG3 = "# devup: no healthy services\n";
394
860
  var CaddyProvider = class {
395
861
  name = "caddy";
@@ -401,144 +867,40 @@ var CaddyProvider = class {
401
867
  if (sub === void 0) continue;
402
868
  const host = sub ? `${sub}.${opts.domain}` : opts.domain;
403
869
  const port = st.realPort ?? st.port;
404
- const siteAddr = opts.tls ? host : `http://${host}`;
405
- blocks.push(
406
- `${siteAddr} {
407
- reverse_proxy ${opts.host}:${port}
408
- }`
409
- );
410
- }
411
- if (!blocks.length) return EMPTY_CONFIG3;
412
- return blocks.join("\n\n") + "\n";
413
- }
414
- write(content, opts) {
415
- const dir = dirname3(opts.confPath);
416
- if (!existsSync5(dir)) mkdirSync3(dir, { recursive: true });
417
- writeFileSync3(opts.confPath, content);
418
- }
419
- clear(opts) {
420
- this.write(EMPTY_CONFIG3, opts);
421
- }
422
- };
423
-
424
- // src/proxy-config/detect.ts
425
- var providers = {
426
- traefik: () => new TraefikProvider(),
427
- nginx: () => new NginxProvider(),
428
- caddy: () => new CaddyProvider()
429
- };
430
- function detectProxyProvider(name) {
431
- const factory = providers[name];
432
- if (!factory) {
433
- const available = Object.keys(providers).join(", ");
434
- throw new Error(`Unknown proxy provider: "${name}". Available: ${available}`);
435
- }
436
- return factory();
437
- }
438
-
439
- // src/utils.ts
440
- import { existsSync as existsSync6, readFileSync, writeFileSync as writeFileSync4 } from "fs";
441
- import { createHash } from "crypto";
442
- import { join as join2 } from "path";
443
- function parseEnvFile(filePath, baseEnv = {}) {
444
- const env = { ...baseEnv };
445
- if (!existsSync6(filePath)) return env;
446
- for (const line of readFileSync(filePath, "utf8").split("\n")) {
447
- const trimmed = line.trim();
448
- if (!trimmed || trimmed.startsWith("#")) continue;
449
- const eqIdx = trimmed.indexOf("=");
450
- if (eqIdx === -1) continue;
451
- const key = trimmed.slice(0, eqIdx).trim();
452
- let val = trimmed.slice(eqIdx + 1).trim();
453
- if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
454
- val = val.slice(1, -1);
455
- }
456
- if (!env[key]) env[key] = val;
457
- }
458
- return env;
459
- }
460
- function fmtUptime(ms) {
461
- if (!ms || ms < 0) return "-";
462
- const s = Math.floor(ms / 1e3);
463
- if (s < 60) return `${s}s`;
464
- const m = Math.floor(s / 60);
465
- if (m < 60) return `${m}m${s % 60}s`;
466
- const h = Math.floor(m / 60);
467
- if (h < 24) return `${h}h${m % 60}m`;
468
- const d = Math.floor(h / 24);
469
- return `${d}d${h % 24}h`;
470
- }
471
- function needsInstall(fullCwd) {
472
- const nm = join2(fullCwd, "node_modules");
473
- if (!existsSync6(nm)) return true;
474
- try {
475
- const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
476
- const stampFile = join2(nm, ".install-stamp");
477
- if (existsSync6(stampFile) && readFileSync(stampFile, "utf8") === pkgHash) return false;
478
- } catch {
479
- }
480
- return true;
481
- }
482
- function writeInstallStamp(fullCwd) {
483
- try {
484
- const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
485
- writeFileSync4(join2(fullCwd, "node_modules", ".install-stamp"), pkgHash);
486
- } catch {
487
- }
488
- }
489
- function sortServiceNames(names, sortMode, statsMap, procState) {
490
- if (sortMode === "name") return names.slice().sort();
491
- return names.slice().sort((a, b) => {
492
- if (sortMode === "mem") {
493
- return (parseFloat(statsMap[b]?.mem ?? "0") || 0) - (parseFloat(statsMap[a]?.mem ?? "0") || 0);
494
- }
495
- return (procState[b]?.errors ?? 0) - (procState[a]?.errors ?? 0);
496
- });
497
- }
498
- function groupByPhase(services) {
499
- const phases = {};
500
- for (const s of services) {
501
- (phases[s.phase] ??= []).push(s);
502
- }
503
- return phases;
504
- }
505
- function buildProcessArgs(svc) {
506
- const extra = svc.nodeArgs ?? [];
507
- if (!svc.maxMem) return [...extra, ...svc.args];
508
- if (svc.cmd === "node") return [`--max-old-space-size=${svc.maxMem}`, ...extra, ...svc.args];
509
- return [...extra, ...svc.args];
510
- }
511
- function buildProcessEnv(svc, baseEnv) {
512
- const env = { ...baseEnv, ...svc.extraEnv ?? {} };
513
- if (svc.maxMem && svc.cmd !== "node") {
514
- const existing = env["NODE_OPTIONS"] ?? "";
515
- const flag = `--max-old-space-size=${svc.maxMem}`;
516
- if (!existing.includes("max-old-space-size")) {
517
- env["NODE_OPTIONS"] = existing ? `${existing} ${flag}` : flag;
870
+ const siteAddr = opts.tls ? host : `http://${host}`;
871
+ blocks.push(
872
+ `${siteAddr} {
873
+ reverse_proxy ${opts.host}:${port}
874
+ }`
875
+ );
518
876
  }
877
+ if (!blocks.length) return EMPTY_CONFIG3;
878
+ return blocks.join("\n\n") + "\n";
519
879
  }
520
- return env;
521
- }
522
- function calcCpuPercent(totalCpuSec, prevCpu, prevTime) {
523
- const elapsed = (Date.now() - prevTime) / 1e3;
524
- const cpuDelta = totalCpuSec - prevCpu;
525
- return elapsed > 0 ? cpuDelta / elapsed * 100 : 0;
880
+ write(content, opts) {
881
+ const dir = dirname4(opts.confPath);
882
+ if (!existsSync7(dir)) mkdirSync3(dir, { recursive: true });
883
+ writeFileSync4(opts.confPath, content);
884
+ }
885
+ clear(opts) {
886
+ this.write(EMPTY_CONFIG3, opts);
887
+ }
888
+ };
889
+
890
+ // src/proxy-config/detect.ts
891
+ var providers = {
892
+ traefik: () => new TraefikProvider(),
893
+ nginx: () => new NginxProvider(),
894
+ caddy: () => new CaddyProvider()
895
+ };
896
+ function detectProxyProvider(name) {
897
+ const factory = providers[name];
898
+ if (!factory) {
899
+ const available = Object.keys(providers).join(", ");
900
+ throw new Error(`Unknown proxy provider: "${name}". Available: ${available}`);
901
+ }
902
+ return factory();
526
903
  }
527
- var tagColors = [
528
- "cyan",
529
- "yellow",
530
- "green",
531
- "magenta",
532
- "blue",
533
- "red",
534
- "#5faf5f",
535
- "#d7af5f",
536
- "#5f87d7",
537
- "#af5faf",
538
- "#5fd7d7",
539
- "#d75f5f",
540
- "white"
541
- ];
542
904
 
543
905
  // src/tui/App.tsx
544
906
  import { useEffect as useEffect5, useState as useState5, useCallback as useCallback3, useRef as useRef3 } from "react";
@@ -548,89 +910,15 @@ import { Box as Box6, Text as Text6, useStdout } from "ink";
548
910
  import { useState, useEffect, useRef, useCallback } from "react";
549
911
 
550
912
  // src/process/manager.ts
551
- import { spawn as spawn2 } from "child_process";
552
- import { join as join3 } from "path";
553
-
554
- // src/process/health.ts
555
- import net from "net";
556
- import http from "http";
557
- function checkPort(port, host = "127.0.0.1", timeoutMs = 2e3) {
558
- return new Promise((resolve3) => {
559
- const socket = new net.Socket();
560
- socket.setTimeout(timeoutMs);
561
- socket.once("connect", () => {
562
- socket.destroy();
563
- resolve3(true);
564
- });
565
- socket.once("error", () => {
566
- socket.destroy();
567
- resolve3(false);
568
- });
569
- socket.once("timeout", () => {
570
- socket.destroy();
571
- resolve3(false);
572
- });
573
- socket.connect(port, host);
574
- });
575
- }
576
- function checkHttp(port, opts = {}) {
577
- const path = opts.path ?? "/";
578
- const host = opts.host ?? "127.0.0.1";
579
- const timeoutMs = opts.timeoutMs ?? 2e3;
580
- const accept = (code) => {
581
- if (opts.expect === void 0) return code >= 200 && code < 300;
582
- if (Array.isArray(opts.expect)) return opts.expect.includes(code);
583
- return code === opts.expect;
584
- };
585
- return new Promise((resolve3) => {
586
- const req = http.get({ host, port, path, timeout: timeoutMs }, (res) => {
587
- const ok = typeof res.statusCode === "number" && accept(res.statusCode);
588
- res.resume();
589
- resolve3(ok);
590
- });
591
- req.on("error", () => resolve3(false));
592
- req.on("timeout", () => {
593
- req.destroy();
594
- resolve3(false);
595
- });
596
- });
597
- }
598
- function checkHealth(port, hc) {
599
- if (hc?.type === "http") {
600
- return checkHttp(port, {
601
- path: hc.path,
602
- expect: hc.expect,
603
- host: hc.host,
604
- timeoutMs: hc.timeoutMs
605
- });
606
- }
607
- return checkPort(port, "127.0.0.1", hc?.timeoutMs);
608
- }
609
- function waitForPort(port, opts = {}) {
610
- const { timeout = 45e3, interval = 1e3 } = opts;
611
- return new Promise((resolve3) => {
612
- const start = Date.now();
613
- const check = () => {
614
- checkPort(port).then((ok) => {
615
- if (ok) return resolve3(true);
616
- if (Date.now() - start > timeout) return resolve3(false);
617
- setTimeout(check, interval);
618
- });
619
- };
620
- check();
621
- });
622
- }
623
- function deriveHealth(isUp, currentStatus) {
624
- if (currentStatus === "idle") return "idle";
625
- if (isUp) return "up";
626
- return currentStatus === "starting" ? "wait" : "down";
627
- }
913
+ import { spawn as spawn3 } from "child_process";
914
+ import { existsSync as existsSync9 } from "fs";
915
+ import { join as join4, resolve as resolve3 } from "path";
628
916
 
629
917
  // src/process/installer.ts
630
- import { spawn } from "child_process";
631
- import { existsSync as existsSync7 } from "fs";
918
+ import { spawn as spawn2 } from "child_process";
919
+ import { existsSync as existsSync8 } from "fs";
632
920
  function installService(cwd, env, onLog) {
633
- if (!existsSync7(cwd)) {
921
+ if (!existsSync8(cwd)) {
634
922
  onLog?.(`\u26A0 directory not found: ${cwd}`);
635
923
  return Promise.resolve(false);
636
924
  }
@@ -639,9 +927,9 @@ function installService(cwd, env, onLog) {
639
927
  return Promise.resolve(true);
640
928
  }
641
929
  onLog?.("\u{1F4E6} npm install...");
642
- return new Promise((resolve3) => {
930
+ return new Promise((resolve4) => {
643
931
  const command = process.platform === "win32" ? "npm.cmd" : "npm";
644
- const proc = spawn(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
932
+ const proc = spawn2(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
645
933
  let stderr = "";
646
934
  proc.stderr?.on("data", (d) => {
647
935
  stderr += d.toString();
@@ -649,16 +937,16 @@ function installService(cwd, env, onLog) {
649
937
  proc.on("close", (code) => {
650
938
  if (code !== 0) {
651
939
  onLog?.(`\u26A0 npm install failed: ${stderr.split("\n")[0]}`);
652
- resolve3(false);
940
+ resolve4(false);
653
941
  } else {
654
942
  writeInstallStamp(cwd);
655
943
  onLog?.("\u2705 dependencies ready");
656
- resolve3(true);
944
+ resolve4(true);
657
945
  }
658
946
  });
659
947
  proc.on("error", (err) => {
660
948
  onLog?.(`\u26A0 spawn error: ${err.message}`);
661
- resolve3(false);
949
+ resolve4(false);
662
950
  });
663
951
  });
664
952
  }
@@ -666,6 +954,36 @@ function installService(cwd, env, onLog) {
666
954
  // src/process/manager.ts
667
955
  var MAX_RESTARTS = 3;
668
956
  var BACKOFF_BASE_MS = 2e3;
957
+ function compileReadyPattern(pattern) {
958
+ if (!pattern) return null;
959
+ const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(pattern);
960
+ try {
961
+ if (slashed) return new RegExp(slashed[1], slashed[2] || "i");
962
+ return new RegExp(pattern, "i");
963
+ } catch {
964
+ return null;
965
+ }
966
+ }
967
+ function extractWatchPaths(args) {
968
+ const watchFlags = /* @__PURE__ */ new Set(["--watch", "--watch-path"]);
969
+ const out = [];
970
+ for (let i = 0; i < args.length; i++) {
971
+ const a = args[i];
972
+ if (watchFlags.has(a)) {
973
+ const v = args[i + 1];
974
+ if (v && !v.startsWith("-")) {
975
+ out.push(v);
976
+ i++;
977
+ }
978
+ continue;
979
+ }
980
+ const eq = a.indexOf("=");
981
+ if (eq > 0 && watchFlags.has(a.slice(0, eq))) {
982
+ out.push(a.slice(eq + 1));
983
+ }
984
+ }
985
+ return out;
986
+ }
669
987
  function lineBuffer(onLine) {
670
988
  let buf = "";
671
989
  return {
@@ -700,12 +1018,12 @@ var ProcessManager = class {
700
1018
  this.events = opts.events;
701
1019
  }
702
1020
  async install(svc, colorIdx) {
703
- const cwd = join3(this.baseCwd, svc.cwd);
1021
+ const cwd = join4(this.baseCwd, svc.cwd);
704
1022
  const idx = colorIdx ?? this.state.get(svc.name)?.colorIdx ?? 0;
705
1023
  return installService(cwd, this.env, (msg) => this.log(svc.name, msg, idx));
706
1024
  }
707
1025
  async start(svc, colorIdx, isRestart = false) {
708
- const cwd = join3(this.baseCwd, svc.cwd);
1026
+ const cwd = join4(this.baseCwd, svc.cwd);
709
1027
  if (svc.type === "api") {
710
1028
  const occupied = await checkPort(svc.port);
711
1029
  if (occupied && !isRestart) {
@@ -713,9 +1031,22 @@ var ProcessManager = class {
713
1031
  return;
714
1032
  }
715
1033
  }
1034
+ if (svc.preBuild) {
1035
+ const built = await this.runPreBuild(svc, cwd, colorIdx);
1036
+ if (!built) {
1037
+ this.recordCrashedState(svc, colorIdx);
1038
+ return;
1039
+ }
1040
+ }
716
1041
  const args = buildProcessArgs(svc);
1042
+ const missingWatchPaths = extractWatchPaths(args).filter((p) => !existsSync9(resolve3(cwd, p)));
1043
+ if (missingWatchPaths.length) {
1044
+ this.log(svc.name, `\u26A0 missing watch paths: ${missingWatchPaths.join(", ")}`, colorIdx);
1045
+ this.recordCrashedState(svc, colorIdx);
1046
+ return;
1047
+ }
717
1048
  const env = buildProcessEnv(svc, this.env);
718
- const proc = spawn2(svc.cmd, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
1049
+ const proc = spawn3(svc.cmd, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
719
1050
  const prev = this.state.get(svc.name);
720
1051
  const state = {
721
1052
  svc,
@@ -732,9 +1063,22 @@ var ProcessManager = class {
732
1063
  this.state.set(svc.name, state);
733
1064
  this.procs.add(proc);
734
1065
  this.events.onStateChange(svc.name, state);
735
- const stdoutBuf = lineBuffer((line) => this.log(svc.name, line, colorIdx));
1066
+ const readyRegex = compileReadyPattern(svc.readyPattern);
1067
+ const markReadyIfMatch = (line) => {
1068
+ if (!readyRegex || state.health === "up") return;
1069
+ if (readyRegex.test(line)) {
1070
+ state.health = "up";
1071
+ if (state.status === "starting") state.status = "running";
1072
+ this.events.onStateChange(svc.name, state);
1073
+ }
1074
+ };
1075
+ const stdoutBuf = lineBuffer((line) => {
1076
+ markReadyIfMatch(line);
1077
+ this.log(svc.name, line, colorIdx);
1078
+ });
736
1079
  const stderrBuf = lineBuffer((line) => {
737
1080
  state.errors += 1;
1081
+ markReadyIfMatch(line);
738
1082
  this.log(svc.name, line, colorIdx);
739
1083
  });
740
1084
  proc.stdout?.on("data", (d) => stdoutBuf.push(d));
@@ -743,6 +1087,7 @@ var ProcessManager = class {
743
1087
  proc.stderr?.on("end", () => stderrBuf.flush());
744
1088
  proc.on("close", (code) => {
745
1089
  this.procs.delete(proc);
1090
+ this.stopWatchProc(state);
746
1091
  if (state.intentionalStop) {
747
1092
  state.intentionalStop = false;
748
1093
  return;
@@ -766,13 +1111,90 @@ var ProcessManager = class {
766
1111
  this.log(svc.name, "\u26D4 max restarts reached", colorIdx);
767
1112
  }
768
1113
  });
1114
+ if (svc.watchBuild) {
1115
+ state.watchProc = this.spawnWatchBuild(svc, cwd, env, colorIdx);
1116
+ }
769
1117
  this.log(svc.name, isRestart ? `\u{1F504} restarted (:${svc.port})` : `\u{1F680} started (:${svc.port})`, colorIdx);
770
1118
  }
1119
+ runPreBuild(svc, cwd, colorIdx) {
1120
+ this.log(svc.name, `\u{1F528} preBuild: ${svc.preBuild}`, colorIdx);
1121
+ return new Promise((resolve4) => {
1122
+ const isWin = process.platform === "win32";
1123
+ const shell = isWin ? "cmd.exe" : "sh";
1124
+ const shellFlag = isWin ? "/c" : "-c";
1125
+ const env = buildProcessEnv(svc, this.env);
1126
+ const child = spawn3(shell, [shellFlag, svc.preBuild], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
1127
+ const outBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
1128
+ const errBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
1129
+ child.stdout?.on("data", (d) => outBuf.push(d));
1130
+ child.stderr?.on("data", (d) => errBuf.push(d));
1131
+ child.on("error", (err) => {
1132
+ this.log(svc.name, `[build] \u274C ${err.message}`, colorIdx);
1133
+ resolve4(false);
1134
+ });
1135
+ child.on("close", (code) => {
1136
+ outBuf.flush();
1137
+ errBuf.flush();
1138
+ if (code === 0) {
1139
+ this.log(svc.name, `[build] \u2705 done`, colorIdx);
1140
+ resolve4(true);
1141
+ } else {
1142
+ this.log(svc.name, `[build] \u274C exited with code ${code}`, colorIdx);
1143
+ resolve4(false);
1144
+ }
1145
+ });
1146
+ });
1147
+ }
1148
+ spawnWatchBuild(svc, cwd, env, colorIdx) {
1149
+ this.log(svc.name, `\u{1F440} watchBuild: ${svc.watchBuild}`, colorIdx);
1150
+ const isWin = process.platform === "win32";
1151
+ const shell = isWin ? "cmd.exe" : "sh";
1152
+ const shellFlag = isWin ? "/c" : "-c";
1153
+ const child = spawn3(shell, [shellFlag, svc.watchBuild], {
1154
+ cwd,
1155
+ env,
1156
+ detached: true,
1157
+ stdio: ["ignore", "pipe", "pipe"]
1158
+ });
1159
+ const outBuf = lineBuffer((line) => this.log(svc.name, `[watch] ${line}`, colorIdx));
1160
+ const errBuf = lineBuffer((line) => this.log(svc.name, `[watch] ${line}`, colorIdx));
1161
+ child.stdout?.on("data", (d) => outBuf.push(d));
1162
+ child.stderr?.on("data", (d) => errBuf.push(d));
1163
+ child.on("error", (err) => this.log(svc.name, `[watch] \u274C ${err.message}`, colorIdx));
1164
+ return child;
1165
+ }
1166
+ /** Create a state entry in 'crashed' status without spawning a process (used when preBuild fails). */
1167
+ recordCrashedState(svc, colorIdx) {
1168
+ const prev = this.state.get(svc.name);
1169
+ this.state.set(svc.name, {
1170
+ svc,
1171
+ proc: null,
1172
+ pid: null,
1173
+ status: "crashed",
1174
+ health: "down",
1175
+ errors: prev?.errors ?? 0,
1176
+ restarts: prev?.restarts ?? 0,
1177
+ startedAt: null,
1178
+ intentionalStop: false,
1179
+ colorIdx
1180
+ });
1181
+ this.events.onStateChange(svc.name, this.state.get(svc.name));
1182
+ }
771
1183
  stop(name) {
772
1184
  const st = this.state.get(name);
773
1185
  if (!st?.proc || !st.pid) return;
774
1186
  st.intentionalStop = true;
775
1187
  this.platform.killTree(st.pid);
1188
+ this.stopWatchProc(st);
1189
+ }
1190
+ stopWatchProc(state) {
1191
+ const wp = state.watchProc;
1192
+ if (!wp || !wp.pid) return;
1193
+ try {
1194
+ this.platform.killTree(wp.pid);
1195
+ } catch {
1196
+ }
1197
+ state.watchProc = null;
776
1198
  }
777
1199
  async restart(name) {
778
1200
  const st = this.state.get(name);
@@ -803,18 +1225,22 @@ var ProcessManager = class {
803
1225
  if (!procs.length) return;
804
1226
  for (const proc of procs) {
805
1227
  const st = this.findStateByProc(proc);
806
- if (st) st.intentionalStop = true;
1228
+ if (st) {
1229
+ st.intentionalStop = true;
1230
+ this.stopWatchProc(st);
1231
+ }
807
1232
  if (proc.pid) this.platform.killTree(proc.pid);
808
1233
  }
1234
+ for (const st of this.state.values()) this.stopWatchProc(st);
809
1235
  const waits = procs.map(
810
- (p) => p.exitCode !== null || p.signalCode !== null ? Promise.resolve() : new Promise((resolve3) => p.once("close", () => resolve3()))
1236
+ (p) => p.exitCode !== null || p.signalCode !== null ? Promise.resolve() : new Promise((resolve4) => p.once("close", () => resolve4()))
811
1237
  );
812
1238
  let timedOut = false;
813
1239
  await Promise.race([
814
1240
  Promise.all(waits),
815
- new Promise((resolve3) => setTimeout(() => {
1241
+ new Promise((resolve4) => setTimeout(() => {
816
1242
  timedOut = true;
817
- resolve3();
1243
+ resolve4();
818
1244
  }, grace))
819
1245
  ]);
820
1246
  if (timedOut) {
@@ -825,7 +1251,7 @@ var ProcessManager = class {
825
1251
  }
826
1252
  await Promise.race([
827
1253
  Promise.all(waits),
828
- new Promise((resolve3) => setTimeout(resolve3, 1e3))
1254
+ new Promise((resolve4) => setTimeout(resolve4, 1e3))
829
1255
  ]);
830
1256
  }
831
1257
  }
@@ -913,6 +1339,21 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
913
1339
  pendingLogsRef.current = [];
914
1340
  setLogs([]);
915
1341
  }, []);
1342
+ const pushLog = useCallback((svcName, text, colorIdx = 0) => {
1343
+ sinkRef.current?.write(svcName, text);
1344
+ const entry = { svcName, text, colorIdx, ts: Date.now() };
1345
+ if (pausedRef.current) {
1346
+ pendingLogsRef.current.push(entry);
1347
+ if (pendingLogsRef.current.length > 5e3) {
1348
+ pendingLogsRef.current = pendingLogsRef.current.slice(-5e3);
1349
+ }
1350
+ return;
1351
+ }
1352
+ setLogs((prev) => {
1353
+ const next = prev.concat(entry);
1354
+ return next.length > 5e3 ? next.slice(-5e3) : next;
1355
+ });
1356
+ }, []);
916
1357
  const setPaused = useCallback((paused) => {
917
1358
  pausedRef.current = paused;
918
1359
  if (!paused && pendingLogsRef.current.length) {
@@ -935,6 +1376,7 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
935
1376
  cleanup: useCallback(() => mgr?.cleanup(), [mgr]),
936
1377
  clearLogs,
937
1378
  setPaused,
1379
+ pushLog,
938
1380
  manager: mgr
939
1381
  };
940
1382
  }
@@ -1105,19 +1547,25 @@ var H = {
1105
1547
  down: { c: "\u25CF", color: "red" },
1106
1548
  idle: { c: "\u25CB", color: "blue" }
1107
1549
  };
1550
+ var MAX_RESTARTS2 = 3;
1551
+ function isCrashLooped(st) {
1552
+ return st.status === "crashed" && st.restarts >= MAX_RESTARTS2;
1553
+ }
1108
1554
  function Row({ name, st, stat, ml }) {
1109
- const h = H[st.health] ?? H["down"];
1555
+ const looped = isCrashLooped(st);
1556
+ const indicator = looped ? /* @__PURE__ */ jsx2(Text2, { color: "red", bold: true, children: "\u2716" }) : /* @__PURE__ */ jsx2(Text2, { color: (H[st.health] ?? H["down"]).color, children: (H[st.health] ?? H["down"]).c });
1110
1557
  const color = tagColors[st.colorIdx % tagColors.length];
1111
- const sc = st.status === "running" ? "green" : st.status === "starting" ? "yellow" : st.status === "idle" ? "blue" : "red";
1558
+ const sc = looped ? "red" : st.status === "running" ? "green" : st.status === "starting" ? "yellow" : st.status === "idle" ? "blue" : "red";
1559
+ const statusLabel = looped ? "looping" : st.status;
1112
1560
  const up = st.startedAt ? fmtUptime(Date.now() - st.startedAt) : "-";
1113
1561
  return /* @__PURE__ */ jsxs2(Text2, { children: [
1114
- /* @__PURE__ */ jsx2(Text2, { color: h.color, children: h.c }),
1562
+ indicator,
1115
1563
  " ",
1116
1564
  /* @__PURE__ */ jsx2(Text2, { color, children: name.padEnd(ml) }),
1117
1565
  " ",
1118
1566
  String(st.svc.port).padStart(5),
1119
1567
  " ",
1120
- /* @__PURE__ */ jsx2(Text2, { color: sc, children: st.status.padEnd(8) }),
1568
+ /* @__PURE__ */ jsx2(Text2, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
1121
1569
  " ",
1122
1570
  (stat?.cpu ?? "-").padStart(6),
1123
1571
  " ",
@@ -1184,6 +1632,7 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1184
1632
  const totalRowsLong = Math.max(apis.length, webs.length);
1185
1633
  const positionInfo = focused && totalRowsLong > 0 ? `(${effectiveOffset + 1}-${Math.min(effectiveOffset + rowsPerCol, totalRowsLong)}/${totalRowsLong})` : "";
1186
1634
  const scrolled = effectiveOffset > 0;
1635
+ const loopedCount = [...states.values()].filter(isCrashLooped).length;
1187
1636
  return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "green" : "gray", height, children: [
1188
1637
  /* @__PURE__ */ jsxs2(Box2, { children: [
1189
1638
  /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "green", children: [
@@ -1191,6 +1640,11 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1191
1640
  positionInfo
1192
1641
  ] }),
1193
1642
  scrolled && /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: " [SCROLL]" }),
1643
+ loopedCount > 0 && /* @__PURE__ */ jsxs2(Text2, { color: "red", bold: true, children: [
1644
+ " \u26A0 ",
1645
+ loopedCount,
1646
+ " need attention"
1647
+ ] }),
1194
1648
  /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1195
1649
  " System: ",
1196
1650
  cpus,
@@ -1283,33 +1737,69 @@ function StatusBar() {
1283
1737
  }
1284
1738
 
1285
1739
  // src/tui/ServiceList.tsx
1286
- import { useState as useState3 } from "react";
1740
+ import { useState as useState3, useMemo } from "react";
1287
1741
  import { Box as Box4, Text as Text4, useInput as useInput2 } from "ink";
1288
1742
  import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1289
1743
  function ServiceList({ title, services, onSelect, onClose, filterType }) {
1290
- const names = [...services.keys()].filter((n) => !filterType || services.get(n).svc.type === filterType);
1744
+ const allNames = useMemo(
1745
+ () => [...services.keys()].filter((n) => !filterType || services.get(n).svc.type === filterType),
1746
+ [services, filterType]
1747
+ );
1291
1748
  const [idx, setIdx] = useState3(0);
1749
+ const [query, setQuery] = useState3("");
1750
+ const names = useMemo(() => {
1751
+ if (!query) return allNames;
1752
+ const q = query.toLowerCase();
1753
+ return allNames.filter((n) => n.toLowerCase().includes(q));
1754
+ }, [allNames, query]);
1755
+ const clamped = Math.min(idx, Math.max(0, names.length - 1));
1292
1756
  useInput2((input, key) => {
1293
- if (key.escape) onClose();
1294
- else if (key.return) {
1295
- if (names[idx]) onSelect(names[idx]);
1296
- } else if (key.upArrow) setIdx((i) => Math.max(0, i - 1));
1297
- else if (key.downArrow) setIdx((i) => Math.min(names.length - 1, i + 1));
1757
+ if (key.escape) {
1758
+ if (query) setQuery("");
1759
+ else onClose();
1760
+ return;
1761
+ }
1762
+ if (key.return) {
1763
+ if (names[clamped]) onSelect(names[clamped]);
1764
+ return;
1765
+ }
1766
+ if (key.upArrow) {
1767
+ setIdx((i) => Math.max(0, i - 1));
1768
+ return;
1769
+ }
1770
+ if (key.downArrow) {
1771
+ setIdx((i) => Math.min(names.length - 1, i + 1));
1772
+ return;
1773
+ }
1774
+ if (key.backspace || key.delete) {
1775
+ setQuery((q) => q.slice(0, -1));
1776
+ setIdx(0);
1777
+ return;
1778
+ }
1779
+ if (input && !key.ctrl && !key.meta && input.length === 1) {
1780
+ setQuery((q) => q + input);
1781
+ setIdx(0);
1782
+ }
1298
1783
  }, { isActive: process.stdin.isTTY ?? false });
1299
1784
  return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
1300
1785
  /* @__PURE__ */ jsxs4(Text4, { bold: true, color: "cyan", children: [
1301
1786
  " ",
1302
1787
  title,
1303
- " "
1788
+ " ",
1789
+ query && /* @__PURE__ */ jsxs4(Text4, { color: "yellow", children: [
1790
+ "[",
1791
+ query,
1792
+ "]"
1793
+ ] })
1304
1794
  ] }),
1305
- names.map((name, i) => /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs4(Text4, { color: i === idx ? "cyan" : void 0, inverse: i === idx, children: [
1795
+ names.length === 0 ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " (no matches) " }) : names.map((name, i) => /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs4(Text4, { color: i === clamped ? "cyan" : void 0, inverse: i === clamped, children: [
1306
1796
  " ",
1307
1797
  name,
1308
1798
  " :",
1309
1799
  services.get(name).svc.port,
1310
1800
  " "
1311
1801
  ] }) }, name)),
1312
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u2191\u2193 navigate Enter select Esc close" })
1802
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "type to filter \u2191\u2193 navigate Enter select Esc clear/close" })
1313
1803
  ] });
1314
1804
  }
1315
1805
 
@@ -1445,8 +1935,113 @@ function createLazyProxy(opts) {
1445
1935
  };
1446
1936
  }
1447
1937
 
1938
+ // src/process/external.ts
1939
+ import { spawn as spawn4 } from "child_process";
1940
+ import { join as join5 } from "path";
1941
+ var DEFAULT_START_TIMEOUT_S = 60;
1942
+ async function startExternals(externals, opts) {
1943
+ const procs = [];
1944
+ const failed = [];
1945
+ for (const svc of externals) {
1946
+ const proc = spawnExternal(svc, opts);
1947
+ procs.push({ svc, proc, pid: proc.pid ?? null });
1948
+ if (!svc.healthCheck) {
1949
+ opts.onLog?.(svc.name, "\u2705 started (no healthCheck)");
1950
+ continue;
1951
+ }
1952
+ if (svc.healthCheck.type === "tcp" && !svc.port) {
1953
+ opts.onLog?.(svc.name, "\u26A0 tcp healthCheck requires `port` \u2014 skipping wait");
1954
+ continue;
1955
+ }
1956
+ const timeoutMs = (svc.startTimeout ?? DEFAULT_START_TIMEOUT_S) * 1e3;
1957
+ const ok = await waitHealthy(svc, timeoutMs);
1958
+ if (ok) {
1959
+ opts.onLog?.(svc.name, "\u2705 healthy");
1960
+ } else {
1961
+ opts.onLog?.(svc.name, `\u274C never became healthy (timeout ${timeoutMs / 1e3}s)`);
1962
+ failed.push(svc.name);
1963
+ }
1964
+ }
1965
+ return { procs, allHealthy: failed.length === 0, failed };
1966
+ }
1967
+ async function stopExternals(procs, platform, opts = {}) {
1968
+ for (const { svc, proc, pid } of procs) {
1969
+ try {
1970
+ if (pid) platform.killTree(pid);
1971
+ if (svc.stopCmd) {
1972
+ opts.onLog?.(svc.name, `\u{1F9F9} ${svc.stopCmd}`);
1973
+ await new Promise((resolve4) => {
1974
+ const isWin = process.platform === "win32";
1975
+ const shell = isWin ? "cmd.exe" : "sh";
1976
+ const flag = isWin ? "/c" : "-c";
1977
+ const cwd = svc.cwd ? join5(opts.baseCwd, svc.cwd) : opts.baseCwd;
1978
+ const env = { ...opts.env, ...svc.extraEnv ?? {} };
1979
+ const child = spawn4(shell, [flag, svc.stopCmd], { cwd, env, stdio: "ignore" });
1980
+ child.on("close", () => resolve4());
1981
+ child.on("error", () => resolve4());
1982
+ setTimeout(() => resolve4(), 1e4);
1983
+ });
1984
+ }
1985
+ } catch {
1986
+ }
1987
+ void proc;
1988
+ }
1989
+ }
1990
+ function spawnExternal(svc, opts) {
1991
+ const isWin = process.platform === "win32";
1992
+ const shell = isWin ? "cmd.exe" : "sh";
1993
+ const flag = isWin ? "/c" : "-c";
1994
+ const cwd = svc.cwd ? join5(opts.baseCwd, svc.cwd) : opts.baseCwd;
1995
+ const env = { ...opts.env, ...svc.extraEnv ?? {} };
1996
+ opts.onLog?.(svc.name, `\u{1F680} ${svc.cmd}`);
1997
+ const child = spawn4(shell, [flag, svc.cmd], {
1998
+ cwd,
1999
+ env,
2000
+ detached: true,
2001
+ stdio: ["ignore", "pipe", "pipe"]
2002
+ });
2003
+ child.stdout?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
2004
+ child.stderr?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
2005
+ child.on("error", (err) => opts.onLog?.(svc.name, `\u274C spawn error: ${err.message}`));
2006
+ return child;
2007
+ }
2008
+ async function waitHealthy(svc, timeoutMs) {
2009
+ const deadline = Date.now() + timeoutMs;
2010
+ const port = svc.port;
2011
+ while (Date.now() < deadline) {
2012
+ if (await checkHealth(port, svc.healthCheck)) return true;
2013
+ await new Promise((r) => setTimeout(r, 500));
2014
+ }
2015
+ return false;
2016
+ }
2017
+
2018
+ // src/tui/tips.ts
2019
+ function pickTip(state) {
2020
+ if (state.crashLoopedCount > 0 && !state.shown.has("crashed")) {
2021
+ return { id: "crashed", message: "tip: press r to restart, or check the log of the failing service" };
2022
+ }
2023
+ if (state.totalLogs > 1e3 && !state.hasSearch && !state.shown.has("search")) {
2024
+ return { id: "search", message: "tip: press / to search in logs" };
2025
+ }
2026
+ if (state.totalLogs > 500 && !state.hasFilter && !state.shown.has("filter")) {
2027
+ return { id: "filter", message: "tip: press f to filter logs by service" };
2028
+ }
2029
+ return null;
2030
+ }
2031
+
1448
2032
  // src/tui/App.tsx
1449
2033
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
2034
+ function buildServiceUrl(name, port, proxyActive, proxyOpts) {
2035
+ if (proxyActive && proxyOpts) {
2036
+ const sub = proxyOpts.routes[name];
2037
+ if (sub !== void 0) {
2038
+ const host = sub ? `${sub}.${proxyOpts.domain}` : proxyOpts.domain;
2039
+ const scheme = proxyOpts.tls ? "https" : "http";
2040
+ return `${scheme}://${host}`;
2041
+ }
2042
+ }
2043
+ return `http://localhost:${port}`;
2044
+ }
1450
2045
  function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts, logSink }) {
1451
2046
  const { stdout } = useStdout();
1452
2047
  const [rows, setRows] = useState5(stdout?.rows ?? 40);
@@ -1464,6 +2059,9 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1464
2059
  const pm = useProcessManager(platform, baseCwd, env, logSink);
1465
2060
  const [booted, setBooted] = useState5(false);
1466
2061
  const lazyProxies = useRef3(/* @__PURE__ */ new Map());
2062
+ const externals = useRef3([]);
2063
+ const shownTips = useRef3(/* @__PURE__ */ new Set());
2064
+ const [activeTip, setActiveTip] = useState5(null);
1467
2065
  const kb = useKeyBindings({
1468
2066
  onQuit: () => {
1469
2067
  void shutdown();
@@ -1475,12 +2073,35 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1475
2073
  const shutdown = useCallback3(async () => {
1476
2074
  lazyProxies.current.forEach((p) => p.destroy());
1477
2075
  await pm.cleanup();
2076
+ if (externals.current.length) {
2077
+ await stopExternals(externals.current, platform, {
2078
+ baseCwd,
2079
+ env,
2080
+ onLog: (svc, msg) => pm.pushLog(`ext:${svc}`, msg, 12)
2081
+ });
2082
+ externals.current = [];
2083
+ }
1478
2084
  await logSink?.close();
1479
2085
  process.exit(0);
1480
- }, [pm, logSink]);
2086
+ }, [pm, logSink, platform, baseCwd, env]);
1481
2087
  useEffect5(() => {
1482
2088
  pm.setPaused(kb.logsPaused || kb.logsScrollOffset > 0);
1483
2089
  }, [kb.logsPaused, kb.logsScrollOffset, pm]);
2090
+ useEffect5(() => {
2091
+ const tip = pickTip({
2092
+ totalLogs: pm.logs.length,
2093
+ hasSearch: !!kb.searchTerm,
2094
+ hasFilter: !!kb.logFilter,
2095
+ crashLoopedCount: [...pm.states.values()].filter(isCrashLooped).length,
2096
+ shown: shownTips.current
2097
+ });
2098
+ if (tip && tip.id !== activeTip) {
2099
+ shownTips.current.add(tip.id);
2100
+ setActiveTip(tip.message);
2101
+ const timer = setTimeout(() => setActiveTip(null), 12e3);
2102
+ return () => clearTimeout(timer);
2103
+ }
2104
+ }, [pm.logs.length, pm.states, kb.searchTerm, kb.logFilter, activeTip]);
1484
2105
  useProxySync(proxyProvider, proxyOpts, pm.states, kb.proxyEnabled);
1485
2106
  useEffect5(() => {
1486
2107
  if (booted || !pm.manager) return;
@@ -1489,6 +2110,19 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1489
2110
  (async () => {
1490
2111
  const lazyMode = cliArgs.lazy;
1491
2112
  const lazyTimeout = cliArgs.lazyTimeout;
2113
+ if (config.external?.length) {
2114
+ const result = await startExternals(config.external, {
2115
+ baseCwd,
2116
+ env,
2117
+ platform,
2118
+ onLog: (svc, msg) => pm.pushLog(`ext:${svc}`, msg, 12)
2119
+ });
2120
+ externals.current = result.procs;
2121
+ if (!result.allHealthy) {
2122
+ pm.pushLog("devup", `\u274C external(s) failed: ${result.failed.join(", ")}. Aborting boot.`, 5);
2123
+ return;
2124
+ }
2125
+ }
1492
2126
  if (lazyMode && config.lazy) {
1493
2127
  const { alwaysOn, lazy } = classifyServices(services, config.lazy);
1494
2128
  const aoPhases = groupByPhase(alwaysOn);
@@ -1582,23 +2216,32 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1582
2216
  }, [pm, kb]);
1583
2217
  const handleOpenSelect = useCallback3((name) => {
1584
2218
  const st = pm.states.get(name);
1585
- if (st) platform.openBrowser(`http://localhost:${st.svc.port}`);
2219
+ if (st) {
2220
+ const url = buildServiceUrl(name, st.svc.port, cliArgs.proxy, proxyOpts);
2221
+ platform.openBrowser(url);
2222
+ }
1586
2223
  kb.setModal("none");
1587
- }, [pm, platform, kb]);
2224
+ }, [pm, platform, kb, cliArgs.proxy, proxyOpts]);
1588
2225
  const icon = config.icon ?? "\u{1F4E6}";
1589
2226
  const modeLabel = cliArgs.lazy && config.lazy ? "lazy" : "normal";
1590
2227
  return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", height: rows, children: [
1591
- /* @__PURE__ */ jsx6(Box6, { children: /* @__PURE__ */ jsxs6(Text6, { bold: true, color: "cyan", children: [
1592
- " ",
1593
- icon,
1594
- " ",
1595
- config.name,
1596
- " \u2014 devup \u2014 ",
1597
- services.length,
1598
- " services (",
1599
- modeLabel,
1600
- ") "
1601
- ] }) }),
2228
+ /* @__PURE__ */ jsxs6(Box6, { children: [
2229
+ /* @__PURE__ */ jsxs6(Text6, { bold: true, color: "cyan", children: [
2230
+ " ",
2231
+ icon,
2232
+ " ",
2233
+ config.name,
2234
+ " \u2014 devup \u2014 ",
2235
+ services.length,
2236
+ " services (",
2237
+ modeLabel,
2238
+ ") "
2239
+ ] }),
2240
+ activeTip && /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
2241
+ " \xB7 ",
2242
+ activeTip
2243
+ ] })
2244
+ ] }),
1602
2245
  /* @__PURE__ */ jsx6(
1603
2246
  LogsPanel,
1604
2247
  {
@@ -1636,23 +2279,23 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1636
2279
  }
1637
2280
 
1638
2281
  // src/process/log-sink.ts
1639
- import { existsSync as existsSync8, mkdirSync as mkdirSync4, renameSync, createWriteStream } from "fs";
1640
- import { join as join4, dirname as dirname4 } from "path";
1641
- import { homedir } from "os";
2282
+ import { existsSync as existsSync10, mkdirSync as mkdirSync4, renameSync, createWriteStream } from "fs";
2283
+ import { join as join6, dirname as dirname5 } from "path";
2284
+ import { homedir as homedir2 } from "os";
1642
2285
  var LogSink = class {
1643
2286
  dir;
1644
2287
  rotateOnStart;
1645
2288
  streams = /* @__PURE__ */ new Map();
1646
2289
  seen = /* @__PURE__ */ new Set();
1647
2290
  constructor(opts) {
1648
- const root = opts.rootDir ?? join4(homedir(), ".devup", "logs");
1649
- this.dir = join4(root, sanitize(opts.projectName));
2291
+ const root = opts.rootDir ?? join6(homedir2(), ".devup", "logs");
2292
+ this.dir = join6(root, sanitize2(opts.projectName));
1650
2293
  this.rotateOnStart = opts.rotateOnStart ?? true;
1651
2294
  mkdirSync4(this.dir, { recursive: true });
1652
2295
  }
1653
2296
  /** Returns the file path for a service log (useful for tests / UI). */
1654
2297
  pathFor(svcName) {
1655
- return join4(this.dir, `${sanitize(svcName)}.log`);
2298
+ return join6(this.dir, `${sanitize2(svcName)}.log`);
1656
2299
  }
1657
2300
  write(svcName, line) {
1658
2301
  const stream = this.streamFor(svcName);
@@ -1671,9 +2314,9 @@ var LogSink = class {
1671
2314
  let s = this.streams.get(svcName);
1672
2315
  if (s) return s;
1673
2316
  const file = this.pathFor(svcName);
1674
- if (this.rotateOnStart && !this.seen.has(svcName) && existsSync8(file)) {
2317
+ if (this.rotateOnStart && !this.seen.has(svcName) && existsSync10(file)) {
1675
2318
  try {
1676
- mkdirSync4(dirname4(file), { recursive: true });
2319
+ mkdirSync4(dirname5(file), { recursive: true });
1677
2320
  renameSync(file, file + ".prev");
1678
2321
  } catch {
1679
2322
  }
@@ -1686,7 +2329,7 @@ var LogSink = class {
1686
2329
  return s;
1687
2330
  }
1688
2331
  };
1689
- function sanitize(name) {
2332
+ function sanitize2(name) {
1690
2333
  return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
1691
2334
  }
1692
2335
 
@@ -1696,8 +2339,18 @@ function renderDryRun(opts) {
1696
2339
  const lines = [];
1697
2340
  lines.push(`Project: ${config.icon ?? "\u{1F4E6}"} ${config.name}`);
1698
2341
  lines.push(`Mode: ${cliArgs.lazy && config.lazy ? "lazy" : "normal"}`);
2342
+ if (cliArgs.profile) lines.push(`Profile: ${cliArgs.profile}`);
1699
2343
  lines.push(`Services: ${services.length}`);
1700
2344
  lines.push("");
2345
+ if (config.external?.length) {
2346
+ lines.push(`Externals (${config.external.length}):`);
2347
+ for (const ext of config.external) {
2348
+ const hc = ext.healthCheck;
2349
+ const hcTag = hc ? ` health=${hc.type}${hc.type === "http" ? " " + (hc.path ?? "/") : ""} :${ext.port ?? "?"}` : "";
2350
+ lines.push(` - ${ext.name.padEnd(20)} ${ext.cmd}${hcTag}`);
2351
+ }
2352
+ lines.push("");
2353
+ }
1701
2354
  const lazyMode = cliArgs.lazy && !!config.lazy;
1702
2355
  let alwaysOn = services;
1703
2356
  let lazy = [];
@@ -1771,6 +2424,26 @@ async function runOnce(opts) {
1771
2424
  }
1772
2425
  }
1773
2426
  });
2427
+ let externals = [];
2428
+ if (config.external?.length) {
2429
+ out(`\u25B6 externals (${config.external.length})`);
2430
+ const result = await startExternals(config.external, {
2431
+ baseCwd,
2432
+ env,
2433
+ platform,
2434
+ onLog: (svc, msg) => {
2435
+ logSink?.write(`ext:${svc}`, msg);
2436
+ out(`[ext:${svc}] ${msg}`);
2437
+ }
2438
+ });
2439
+ externals = result.procs;
2440
+ if (!result.allHealthy) {
2441
+ out(`\u2717 externals failed: ${result.failed.join(", ")}`);
2442
+ await stopExternals(externals, platform, { baseCwd, env });
2443
+ await mgr.cleanup();
2444
+ return 1;
2445
+ }
2446
+ }
1774
2447
  const phases = groupByPhase(services);
1775
2448
  const phaseNums = Object.keys(phases).map(Number).sort((a, b) => a - b);
1776
2449
  const apiNames = services.filter((s) => s.type === "api").map((s) => s.name);
@@ -1784,16 +2457,18 @@ async function runOnce(opts) {
1784
2457
  if (!installed) {
1785
2458
  out(`\u2717 install failed for ${svc.name}`);
1786
2459
  await mgr.cleanup();
2460
+ await stopExternals(externals, platform, { baseCwd, env });
1787
2461
  return 1;
1788
2462
  }
1789
2463
  await mgr.start(svc, ci);
1790
2464
  }
1791
2465
  const apis = phases[num].filter((s) => s.type === "api");
1792
2466
  for (const api of apis) {
1793
- const ok = await waitHealthy(api, deadline);
2467
+ const ok = await waitHealthy2(api, deadline);
1794
2468
  if (!ok) {
1795
2469
  out(`\u2717 ${api.name} did not become healthy within ${cliArgs.onceTimeout}s`);
1796
2470
  await mgr.cleanup();
2471
+ await stopExternals(externals, platform, { baseCwd, env });
1797
2472
  return 1;
1798
2473
  }
1799
2474
  out(`\u2713 ${api.name} ready`);
@@ -1807,9 +2482,10 @@ async function runOnce(opts) {
1807
2482
  const summary = `ready: ${apiNames.length} APIs in ${((cliArgs.onceTimeout * 1e3 - (deadline - Date.now())) / 1e3).toFixed(1)}s`;
1808
2483
  out(summary);
1809
2484
  await mgr.cleanup();
2485
+ await stopExternals(externals, platform, { baseCwd, env });
1810
2486
  return 0;
1811
2487
  }
1812
- async function waitHealthy(svc, deadline) {
2488
+ async function waitHealthy2(svc, deadline) {
1813
2489
  while (Date.now() < deadline) {
1814
2490
  const ok = await checkHealth(svc.port, svc.healthCheck);
1815
2491
  if (ok) return true;
@@ -1824,9 +2500,46 @@ function defineConfig(config) {
1824
2500
  }
1825
2501
 
1826
2502
  // src/index.ts
2503
+ function readVersion() {
2504
+ try {
2505
+ const here = dirname6(fileURLToPath2(import.meta.url));
2506
+ const pkgPath = join7(here, "..", "package.json");
2507
+ return JSON.parse(readFileSync2(pkgPath, "utf8")).version ?? "unknown";
2508
+ } catch {
2509
+ return "unknown";
2510
+ }
2511
+ }
1827
2512
  async function main() {
2513
+ const raw = process.argv.slice(2);
2514
+ if (raw.includes("-v") || raw.includes("--version")) {
2515
+ console.log(readVersion());
2516
+ return;
2517
+ }
2518
+ if (raw.includes("-h") || raw.includes("--help")) {
2519
+ console.log(USAGE);
2520
+ return;
2521
+ }
2522
+ const subcmd = detectSubcommand(raw);
2523
+ if (subcmd === "help") {
2524
+ process.exit(runHelp(raw.slice(1)));
2525
+ }
1828
2526
  const cwd = process.cwd();
1829
- const cliArgs = parseCliArgs(process.argv.slice(2));
2527
+ const cliArgs = parseCliArgs(raw);
2528
+ if (subcmd) {
2529
+ const subArgs = raw.slice(1);
2530
+ let cfgPath;
2531
+ try {
2532
+ cfgPath = findConfigFile(cwd, cliArgs.configPath);
2533
+ } catch (e) {
2534
+ console.error(`\u274C ${e.message}`);
2535
+ process.exit(1);
2536
+ }
2537
+ const cfg = await loadConfig(cfgPath);
2538
+ const subOpts = { config: cfg, baseCwd: cwd, env: process.env, logDir: cliArgs.logDir };
2539
+ if (subcmd === "logs") process.exit(await runLogs(subArgs, subOpts));
2540
+ if (subcmd === "install") process.exit(await runInstall(subOpts));
2541
+ if (subcmd === "status") process.exit(await runStatus(subOpts));
2542
+ }
1830
2543
  let configPath;
1831
2544
  try {
1832
2545
  configPath = findConfigFile(cwd, cliArgs.configPath);
@@ -1841,13 +2554,19 @@ async function main() {
1841
2554
  ${formatValidationErrors(errors)}`);
1842
2555
  process.exit(1);
1843
2556
  }
1844
- const services = filterServices(config.services, cliArgs);
2557
+ let services;
2558
+ try {
2559
+ services = filterServices(config.services, cliArgs, config);
2560
+ } catch (e) {
2561
+ console.error(`\u274C ${e.message}`);
2562
+ process.exit(1);
2563
+ }
1845
2564
  if (!services.length) {
1846
2565
  console.error("\u274C No services to run after filtering");
1847
2566
  process.exit(1);
1848
2567
  }
1849
2568
  const platform = await detectPlatform();
1850
- const envFile = config.envFile ? join5(cwd, config.envFile) : join5(cwd, ".env");
2569
+ const envFile = config.envFile ? join7(cwd, config.envFile) : join7(cwd, ".env");
1851
2570
  const env = parseEnvFile(envFile, process.env);
1852
2571
  if (config.env) {
1853
2572
  for (const [k, v] of Object.entries(config.env)) {
@@ -1864,7 +2583,7 @@ ${formatValidationErrors(errors)}`);
1864
2583
  routes: config.proxy.routes,
1865
2584
  tls: cliArgs.proxyTls ?? config.proxy.tls ?? true,
1866
2585
  entrypoint: cliArgs.proxyEntrypoint ?? config.proxy.entrypoint ?? "websecure",
1867
- confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join5(homedir2(), ".traefik", "traefik_conf.yaml")
2586
+ confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join7(homedir3(), ".traefik", "traefik_conf.yaml")
1868
2587
  };
1869
2588
  }
1870
2589
  if (cliArgs.dryRun) {