@gachlab/devup 0.3.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 join6 } 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";
@@ -221,6 +223,44 @@ function formatValidationErrors(errors) {
221
223
  // src/config/cli.ts
222
224
  var DEFAULT_LAZY_TIMEOUT = 10;
223
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.`;
224
264
  function parseCliArgs(argv) {
225
265
  const args = {
226
266
  skip: [],
@@ -344,6 +384,359 @@ function filterServices(services, args, config) {
344
384
  return result;
345
385
  }
346
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
+
347
740
  // src/platform/detect.ts
348
741
  async function detectPlatform() {
349
742
  switch (process.platform) {
@@ -365,8 +758,8 @@ async function detectPlatform() {
365
758
  }
366
759
 
367
760
  // src/proxy-config/traefik.ts
368
- import { existsSync as existsSync3, mkdirSync, writeFileSync } from "fs";
369
- import { dirname } from "path";
761
+ import { existsSync as existsSync5, mkdirSync, writeFileSync as writeFileSync2 } from "fs";
762
+ import { dirname as dirname2 } from "path";
370
763
  var EMPTY_CONFIG = "http:\n routers: {}\n services: {}\n";
371
764
  var TraefikProvider = class {
372
765
  name = "traefik";
@@ -403,9 +796,9 @@ ${svcs.join("\n")}
403
796
  `;
404
797
  }
405
798
  write(content, opts) {
406
- const dir = dirname(opts.confPath);
407
- if (!existsSync3(dir)) mkdirSync(dir, { recursive: true });
408
- writeFileSync(opts.confPath, content);
799
+ const dir = dirname2(opts.confPath);
800
+ if (!existsSync5(dir)) mkdirSync(dir, { recursive: true });
801
+ writeFileSync2(opts.confPath, content);
409
802
  }
410
803
  clear(opts) {
411
804
  this.write(EMPTY_CONFIG, opts);
@@ -413,8 +806,8 @@ ${svcs.join("\n")}
413
806
  };
414
807
 
415
808
  // src/proxy-config/nginx.ts
416
- import { existsSync as existsSync4, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
417
- 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";
418
811
  var EMPTY_CONFIG2 = "# devup: no healthy services\n";
419
812
  var NginxProvider = class {
420
813
  name = "nginx";
@@ -451,9 +844,9 @@ var NginxProvider = class {
451
844
  return blocks.join("\n\n") + "\n";
452
845
  }
453
846
  write(content, opts) {
454
- const dir = dirname2(opts.confPath);
455
- if (!existsSync4(dir)) mkdirSync2(dir, { recursive: true });
456
- writeFileSync2(opts.confPath, content);
847
+ const dir = dirname3(opts.confPath);
848
+ if (!existsSync6(dir)) mkdirSync2(dir, { recursive: true });
849
+ writeFileSync3(opts.confPath, content);
457
850
  }
458
851
  clear(opts) {
459
852
  this.write(EMPTY_CONFIG2, opts);
@@ -461,8 +854,8 @@ var NginxProvider = class {
461
854
  };
462
855
 
463
856
  // src/proxy-config/caddy.ts
464
- import { existsSync as existsSync5, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
465
- 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";
466
859
  var EMPTY_CONFIG3 = "# devup: no healthy services\n";
467
860
  var CaddyProvider = class {
468
861
  name = "caddy";
@@ -485,9 +878,9 @@ var CaddyProvider = class {
485
878
  return blocks.join("\n\n") + "\n";
486
879
  }
487
880
  write(content, opts) {
488
- const dir = dirname3(opts.confPath);
489
- if (!existsSync5(dir)) mkdirSync3(dir, { recursive: true });
490
- writeFileSync3(opts.confPath, content);
881
+ const dir = dirname4(opts.confPath);
882
+ if (!existsSync7(dir)) mkdirSync3(dir, { recursive: true });
883
+ writeFileSync4(opts.confPath, content);
491
884
  }
492
885
  clear(opts) {
493
886
  this.write(EMPTY_CONFIG3, opts);
@@ -509,110 +902,6 @@ function detectProxyProvider(name) {
509
902
  return factory();
510
903
  }
511
904
 
512
- // src/utils.ts
513
- import { existsSync as existsSync6, readFileSync, writeFileSync as writeFileSync4 } from "fs";
514
- import { createHash } from "crypto";
515
- import { join as join2 } from "path";
516
- function parseEnvFile(filePath, baseEnv = {}) {
517
- const env = { ...baseEnv };
518
- if (!existsSync6(filePath)) return env;
519
- for (const line of readFileSync(filePath, "utf8").split("\n")) {
520
- const trimmed = line.trim();
521
- if (!trimmed || trimmed.startsWith("#")) continue;
522
- const eqIdx = trimmed.indexOf("=");
523
- if (eqIdx === -1) continue;
524
- const key = trimmed.slice(0, eqIdx).trim();
525
- let val = trimmed.slice(eqIdx + 1).trim();
526
- if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
527
- val = val.slice(1, -1);
528
- }
529
- if (!env[key]) env[key] = val;
530
- }
531
- return env;
532
- }
533
- function fmtUptime(ms) {
534
- if (!ms || ms < 0) return "-";
535
- const s = Math.floor(ms / 1e3);
536
- if (s < 60) return `${s}s`;
537
- const m = Math.floor(s / 60);
538
- if (m < 60) return `${m}m${s % 60}s`;
539
- const h = Math.floor(m / 60);
540
- if (h < 24) return `${h}h${m % 60}m`;
541
- const d = Math.floor(h / 24);
542
- return `${d}d${h % 24}h`;
543
- }
544
- function needsInstall(fullCwd) {
545
- const nm = join2(fullCwd, "node_modules");
546
- if (!existsSync6(nm)) return true;
547
- try {
548
- const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
549
- const stampFile = join2(nm, ".install-stamp");
550
- if (existsSync6(stampFile) && readFileSync(stampFile, "utf8") === pkgHash) return false;
551
- } catch {
552
- }
553
- return true;
554
- }
555
- function writeInstallStamp(fullCwd) {
556
- try {
557
- const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
558
- writeFileSync4(join2(fullCwd, "node_modules", ".install-stamp"), pkgHash);
559
- } catch {
560
- }
561
- }
562
- function sortServiceNames(names, sortMode, statsMap, procState) {
563
- if (sortMode === "name") return names.slice().sort();
564
- return names.slice().sort((a, b) => {
565
- if (sortMode === "mem") {
566
- return (parseFloat(statsMap[b]?.mem ?? "0") || 0) - (parseFloat(statsMap[a]?.mem ?? "0") || 0);
567
- }
568
- return (procState[b]?.errors ?? 0) - (procState[a]?.errors ?? 0);
569
- });
570
- }
571
- function groupByPhase(services) {
572
- const phases = {};
573
- for (const s of services) {
574
- (phases[s.phase] ??= []).push(s);
575
- }
576
- return phases;
577
- }
578
- function buildProcessArgs(svc) {
579
- const extra = svc.nodeArgs ?? [];
580
- if (!svc.maxMem) return [...extra, ...svc.args];
581
- if (svc.cmd === "node") return [`--max-old-space-size=${svc.maxMem}`, ...extra, ...svc.args];
582
- return [...extra, ...svc.args];
583
- }
584
- function buildProcessEnv(svc, baseEnv) {
585
- const env = { ...baseEnv, ...svc.extraEnv ?? {} };
586
- if (svc.maxMem && svc.cmd !== "node") {
587
- const existing = env["NODE_OPTIONS"] ?? "";
588
- const flag = `--max-old-space-size=${svc.maxMem}`;
589
- if (!existing.includes("max-old-space-size")) {
590
- env["NODE_OPTIONS"] = existing ? `${existing} ${flag}` : flag;
591
- }
592
- }
593
- return env;
594
- }
595
- function calcCpuPercent(totalCpuSec, prevCpu, prevTime) {
596
- const elapsed = (Date.now() - prevTime) / 1e3;
597
- const cpuDelta = totalCpuSec - prevCpu;
598
- return elapsed > 0 ? cpuDelta / elapsed * 100 : 0;
599
- }
600
- var tagColors = [
601
- "cyan",
602
- "yellow",
603
- "green",
604
- "magenta",
605
- "blue",
606
- "red",
607
- "#5faf5f",
608
- "#d7af5f",
609
- "#5f87d7",
610
- "#af5faf",
611
- "#5fd7d7",
612
- "#d75f5f",
613
- "white"
614
- ];
615
-
616
905
  // src/tui/App.tsx
617
906
  import { useEffect as useEffect5, useState as useState5, useCallback as useCallback3, useRef as useRef3 } from "react";
618
907
  import { Box as Box6, Text as Text6, useStdout } from "ink";
@@ -621,89 +910,15 @@ import { Box as Box6, Text as Text6, useStdout } from "ink";
621
910
  import { useState, useEffect, useRef, useCallback } from "react";
622
911
 
623
912
  // src/process/manager.ts
624
- import { spawn as spawn2 } from "child_process";
625
- import { join as join3 } from "path";
626
-
627
- // src/process/health.ts
628
- import net from "net";
629
- import http from "http";
630
- function checkPort(port, host = "127.0.0.1", timeoutMs = 2e3) {
631
- return new Promise((resolve3) => {
632
- const socket = new net.Socket();
633
- socket.setTimeout(timeoutMs);
634
- socket.once("connect", () => {
635
- socket.destroy();
636
- resolve3(true);
637
- });
638
- socket.once("error", () => {
639
- socket.destroy();
640
- resolve3(false);
641
- });
642
- socket.once("timeout", () => {
643
- socket.destroy();
644
- resolve3(false);
645
- });
646
- socket.connect(port, host);
647
- });
648
- }
649
- function checkHttp(port, opts = {}) {
650
- const path = opts.path ?? "/";
651
- const host = opts.host ?? "127.0.0.1";
652
- const timeoutMs = opts.timeoutMs ?? 2e3;
653
- const accept = (code) => {
654
- if (opts.expect === void 0) return code >= 200 && code < 300;
655
- if (Array.isArray(opts.expect)) return opts.expect.includes(code);
656
- return code === opts.expect;
657
- };
658
- return new Promise((resolve3) => {
659
- const req = http.get({ host, port, path, timeout: timeoutMs }, (res) => {
660
- const ok = typeof res.statusCode === "number" && accept(res.statusCode);
661
- res.resume();
662
- resolve3(ok);
663
- });
664
- req.on("error", () => resolve3(false));
665
- req.on("timeout", () => {
666
- req.destroy();
667
- resolve3(false);
668
- });
669
- });
670
- }
671
- function checkHealth(port, hc) {
672
- if (hc?.type === "http") {
673
- return checkHttp(port, {
674
- path: hc.path,
675
- expect: hc.expect,
676
- host: hc.host,
677
- timeoutMs: hc.timeoutMs
678
- });
679
- }
680
- return checkPort(port, "127.0.0.1", hc?.timeoutMs);
681
- }
682
- function waitForPort(port, opts = {}) {
683
- const { timeout = 45e3, interval = 1e3 } = opts;
684
- return new Promise((resolve3) => {
685
- const start = Date.now();
686
- const check = () => {
687
- checkPort(port).then((ok) => {
688
- if (ok) return resolve3(true);
689
- if (Date.now() - start > timeout) return resolve3(false);
690
- setTimeout(check, interval);
691
- });
692
- };
693
- check();
694
- });
695
- }
696
- function deriveHealth(isUp, currentStatus) {
697
- if (currentStatus === "idle") return "idle";
698
- if (isUp) return "up";
699
- return currentStatus === "starting" ? "wait" : "down";
700
- }
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";
701
916
 
702
917
  // src/process/installer.ts
703
- import { spawn } from "child_process";
704
- import { existsSync as existsSync7 } from "fs";
918
+ import { spawn as spawn2 } from "child_process";
919
+ import { existsSync as existsSync8 } from "fs";
705
920
  function installService(cwd, env, onLog) {
706
- if (!existsSync7(cwd)) {
921
+ if (!existsSync8(cwd)) {
707
922
  onLog?.(`\u26A0 directory not found: ${cwd}`);
708
923
  return Promise.resolve(false);
709
924
  }
@@ -712,9 +927,9 @@ function installService(cwd, env, onLog) {
712
927
  return Promise.resolve(true);
713
928
  }
714
929
  onLog?.("\u{1F4E6} npm install...");
715
- return new Promise((resolve3) => {
930
+ return new Promise((resolve4) => {
716
931
  const command = process.platform === "win32" ? "npm.cmd" : "npm";
717
- const proc = spawn(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
932
+ const proc = spawn2(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
718
933
  let stderr = "";
719
934
  proc.stderr?.on("data", (d) => {
720
935
  stderr += d.toString();
@@ -722,16 +937,16 @@ function installService(cwd, env, onLog) {
722
937
  proc.on("close", (code) => {
723
938
  if (code !== 0) {
724
939
  onLog?.(`\u26A0 npm install failed: ${stderr.split("\n")[0]}`);
725
- resolve3(false);
940
+ resolve4(false);
726
941
  } else {
727
942
  writeInstallStamp(cwd);
728
943
  onLog?.("\u2705 dependencies ready");
729
- resolve3(true);
944
+ resolve4(true);
730
945
  }
731
946
  });
732
947
  proc.on("error", (err) => {
733
948
  onLog?.(`\u26A0 spawn error: ${err.message}`);
734
- resolve3(false);
949
+ resolve4(false);
735
950
  });
736
951
  });
737
952
  }
@@ -749,6 +964,26 @@ function compileReadyPattern(pattern) {
749
964
  return null;
750
965
  }
751
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
+ }
752
987
  function lineBuffer(onLine) {
753
988
  let buf = "";
754
989
  return {
@@ -783,12 +1018,12 @@ var ProcessManager = class {
783
1018
  this.events = opts.events;
784
1019
  }
785
1020
  async install(svc, colorIdx) {
786
- const cwd = join3(this.baseCwd, svc.cwd);
1021
+ const cwd = join4(this.baseCwd, svc.cwd);
787
1022
  const idx = colorIdx ?? this.state.get(svc.name)?.colorIdx ?? 0;
788
1023
  return installService(cwd, this.env, (msg) => this.log(svc.name, msg, idx));
789
1024
  }
790
1025
  async start(svc, colorIdx, isRestart = false) {
791
- const cwd = join3(this.baseCwd, svc.cwd);
1026
+ const cwd = join4(this.baseCwd, svc.cwd);
792
1027
  if (svc.type === "api") {
793
1028
  const occupied = await checkPort(svc.port);
794
1029
  if (occupied && !isRestart) {
@@ -804,8 +1039,14 @@ var ProcessManager = class {
804
1039
  }
805
1040
  }
806
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
+ }
807
1048
  const env = buildProcessEnv(svc, this.env);
808
- 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"] });
809
1050
  const prev = this.state.get(svc.name);
810
1051
  const state = {
811
1052
  svc,
@@ -877,29 +1118,29 @@ var ProcessManager = class {
877
1118
  }
878
1119
  runPreBuild(svc, cwd, colorIdx) {
879
1120
  this.log(svc.name, `\u{1F528} preBuild: ${svc.preBuild}`, colorIdx);
880
- return new Promise((resolve3) => {
1121
+ return new Promise((resolve4) => {
881
1122
  const isWin = process.platform === "win32";
882
1123
  const shell = isWin ? "cmd.exe" : "sh";
883
1124
  const shellFlag = isWin ? "/c" : "-c";
884
1125
  const env = buildProcessEnv(svc, this.env);
885
- const child = spawn2(shell, [shellFlag, svc.preBuild], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
1126
+ const child = spawn3(shell, [shellFlag, svc.preBuild], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
886
1127
  const outBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
887
1128
  const errBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
888
1129
  child.stdout?.on("data", (d) => outBuf.push(d));
889
1130
  child.stderr?.on("data", (d) => errBuf.push(d));
890
1131
  child.on("error", (err) => {
891
1132
  this.log(svc.name, `[build] \u274C ${err.message}`, colorIdx);
892
- resolve3(false);
1133
+ resolve4(false);
893
1134
  });
894
1135
  child.on("close", (code) => {
895
1136
  outBuf.flush();
896
1137
  errBuf.flush();
897
1138
  if (code === 0) {
898
1139
  this.log(svc.name, `[build] \u2705 done`, colorIdx);
899
- resolve3(true);
1140
+ resolve4(true);
900
1141
  } else {
901
1142
  this.log(svc.name, `[build] \u274C exited with code ${code}`, colorIdx);
902
- resolve3(false);
1143
+ resolve4(false);
903
1144
  }
904
1145
  });
905
1146
  });
@@ -909,7 +1150,7 @@ var ProcessManager = class {
909
1150
  const isWin = process.platform === "win32";
910
1151
  const shell = isWin ? "cmd.exe" : "sh";
911
1152
  const shellFlag = isWin ? "/c" : "-c";
912
- const child = spawn2(shell, [shellFlag, svc.watchBuild], {
1153
+ const child = spawn3(shell, [shellFlag, svc.watchBuild], {
913
1154
  cwd,
914
1155
  env,
915
1156
  detached: true,
@@ -992,14 +1233,14 @@ var ProcessManager = class {
992
1233
  }
993
1234
  for (const st of this.state.values()) this.stopWatchProc(st);
994
1235
  const waits = procs.map(
995
- (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()))
996
1237
  );
997
1238
  let timedOut = false;
998
1239
  await Promise.race([
999
1240
  Promise.all(waits),
1000
- new Promise((resolve3) => setTimeout(() => {
1241
+ new Promise((resolve4) => setTimeout(() => {
1001
1242
  timedOut = true;
1002
- resolve3();
1243
+ resolve4();
1003
1244
  }, grace))
1004
1245
  ]);
1005
1246
  if (timedOut) {
@@ -1010,7 +1251,7 @@ var ProcessManager = class {
1010
1251
  }
1011
1252
  await Promise.race([
1012
1253
  Promise.all(waits),
1013
- new Promise((resolve3) => setTimeout(resolve3, 1e3))
1254
+ new Promise((resolve4) => setTimeout(resolve4, 1e3))
1014
1255
  ]);
1015
1256
  }
1016
1257
  }
@@ -1306,19 +1547,25 @@ var H = {
1306
1547
  down: { c: "\u25CF", color: "red" },
1307
1548
  idle: { c: "\u25CB", color: "blue" }
1308
1549
  };
1550
+ var MAX_RESTARTS2 = 3;
1551
+ function isCrashLooped(st) {
1552
+ return st.status === "crashed" && st.restarts >= MAX_RESTARTS2;
1553
+ }
1309
1554
  function Row({ name, st, stat, ml }) {
1310
- 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 });
1311
1557
  const color = tagColors[st.colorIdx % tagColors.length];
1312
- 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;
1313
1560
  const up = st.startedAt ? fmtUptime(Date.now() - st.startedAt) : "-";
1314
1561
  return /* @__PURE__ */ jsxs2(Text2, { children: [
1315
- /* @__PURE__ */ jsx2(Text2, { color: h.color, children: h.c }),
1562
+ indicator,
1316
1563
  " ",
1317
1564
  /* @__PURE__ */ jsx2(Text2, { color, children: name.padEnd(ml) }),
1318
1565
  " ",
1319
1566
  String(st.svc.port).padStart(5),
1320
1567
  " ",
1321
- /* @__PURE__ */ jsx2(Text2, { color: sc, children: st.status.padEnd(8) }),
1568
+ /* @__PURE__ */ jsx2(Text2, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
1322
1569
  " ",
1323
1570
  (stat?.cpu ?? "-").padStart(6),
1324
1571
  " ",
@@ -1385,6 +1632,7 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1385
1632
  const totalRowsLong = Math.max(apis.length, webs.length);
1386
1633
  const positionInfo = focused && totalRowsLong > 0 ? `(${effectiveOffset + 1}-${Math.min(effectiveOffset + rowsPerCol, totalRowsLong)}/${totalRowsLong})` : "";
1387
1634
  const scrolled = effectiveOffset > 0;
1635
+ const loopedCount = [...states.values()].filter(isCrashLooped).length;
1388
1636
  return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "green" : "gray", height, children: [
1389
1637
  /* @__PURE__ */ jsxs2(Box2, { children: [
1390
1638
  /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "green", children: [
@@ -1392,6 +1640,11 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1392
1640
  positionInfo
1393
1641
  ] }),
1394
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
+ ] }),
1395
1648
  /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1396
1649
  " System: ",
1397
1650
  cpus,
@@ -1484,33 +1737,69 @@ function StatusBar() {
1484
1737
  }
1485
1738
 
1486
1739
  // src/tui/ServiceList.tsx
1487
- import { useState as useState3 } from "react";
1740
+ import { useState as useState3, useMemo } from "react";
1488
1741
  import { Box as Box4, Text as Text4, useInput as useInput2 } from "ink";
1489
1742
  import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1490
1743
  function ServiceList({ title, services, onSelect, onClose, filterType }) {
1491
- 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
+ );
1492
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));
1493
1756
  useInput2((input, key) => {
1494
- if (key.escape) onClose();
1495
- else if (key.return) {
1496
- if (names[idx]) onSelect(names[idx]);
1497
- } else if (key.upArrow) setIdx((i) => Math.max(0, i - 1));
1498
- 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
+ }
1499
1783
  }, { isActive: process.stdin.isTTY ?? false });
1500
1784
  return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
1501
1785
  /* @__PURE__ */ jsxs4(Text4, { bold: true, color: "cyan", children: [
1502
1786
  " ",
1503
1787
  title,
1504
- " "
1788
+ " ",
1789
+ query && /* @__PURE__ */ jsxs4(Text4, { color: "yellow", children: [
1790
+ "[",
1791
+ query,
1792
+ "]"
1793
+ ] })
1505
1794
  ] }),
1506
- 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: [
1507
1796
  " ",
1508
1797
  name,
1509
1798
  " :",
1510
1799
  services.get(name).svc.port,
1511
1800
  " "
1512
1801
  ] }) }, name)),
1513
- /* @__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" })
1514
1803
  ] });
1515
1804
  }
1516
1805
 
@@ -1647,8 +1936,8 @@ function createLazyProxy(opts) {
1647
1936
  }
1648
1937
 
1649
1938
  // src/process/external.ts
1650
- import { spawn as spawn3 } from "child_process";
1651
- import { join as join4 } from "path";
1939
+ import { spawn as spawn4 } from "child_process";
1940
+ import { join as join5 } from "path";
1652
1941
  var DEFAULT_START_TIMEOUT_S = 60;
1653
1942
  async function startExternals(externals, opts) {
1654
1943
  const procs = [];
@@ -1681,16 +1970,16 @@ async function stopExternals(procs, platform, opts = {}) {
1681
1970
  if (pid) platform.killTree(pid);
1682
1971
  if (svc.stopCmd) {
1683
1972
  opts.onLog?.(svc.name, `\u{1F9F9} ${svc.stopCmd}`);
1684
- await new Promise((resolve3) => {
1973
+ await new Promise((resolve4) => {
1685
1974
  const isWin = process.platform === "win32";
1686
1975
  const shell = isWin ? "cmd.exe" : "sh";
1687
1976
  const flag = isWin ? "/c" : "-c";
1688
- const cwd = svc.cwd ? join4(opts.baseCwd, svc.cwd) : opts.baseCwd;
1977
+ const cwd = svc.cwd ? join5(opts.baseCwd, svc.cwd) : opts.baseCwd;
1689
1978
  const env = { ...opts.env, ...svc.extraEnv ?? {} };
1690
- const child = spawn3(shell, [flag, svc.stopCmd], { cwd, env, stdio: "ignore" });
1691
- child.on("close", () => resolve3());
1692
- child.on("error", () => resolve3());
1693
- setTimeout(() => resolve3(), 1e4);
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);
1694
1983
  });
1695
1984
  }
1696
1985
  } catch {
@@ -1702,10 +1991,10 @@ function spawnExternal(svc, opts) {
1702
1991
  const isWin = process.platform === "win32";
1703
1992
  const shell = isWin ? "cmd.exe" : "sh";
1704
1993
  const flag = isWin ? "/c" : "-c";
1705
- const cwd = svc.cwd ? join4(opts.baseCwd, svc.cwd) : opts.baseCwd;
1994
+ const cwd = svc.cwd ? join5(opts.baseCwd, svc.cwd) : opts.baseCwd;
1706
1995
  const env = { ...opts.env, ...svc.extraEnv ?? {} };
1707
1996
  opts.onLog?.(svc.name, `\u{1F680} ${svc.cmd}`);
1708
- const child = spawn3(shell, [flag, svc.cmd], {
1997
+ const child = spawn4(shell, [flag, svc.cmd], {
1709
1998
  cwd,
1710
1999
  env,
1711
2000
  detached: true,
@@ -1726,8 +2015,33 @@ async function waitHealthy(svc, timeoutMs) {
1726
2015
  return false;
1727
2016
  }
1728
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
+
1729
2032
  // src/tui/App.tsx
1730
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
+ }
1731
2045
  function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts, logSink }) {
1732
2046
  const { stdout } = useStdout();
1733
2047
  const [rows, setRows] = useState5(stdout?.rows ?? 40);
@@ -1746,6 +2060,8 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1746
2060
  const [booted, setBooted] = useState5(false);
1747
2061
  const lazyProxies = useRef3(/* @__PURE__ */ new Map());
1748
2062
  const externals = useRef3([]);
2063
+ const shownTips = useRef3(/* @__PURE__ */ new Set());
2064
+ const [activeTip, setActiveTip] = useState5(null);
1749
2065
  const kb = useKeyBindings({
1750
2066
  onQuit: () => {
1751
2067
  void shutdown();
@@ -1771,6 +2087,21 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1771
2087
  useEffect5(() => {
1772
2088
  pm.setPaused(kb.logsPaused || kb.logsScrollOffset > 0);
1773
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]);
1774
2105
  useProxySync(proxyProvider, proxyOpts, pm.states, kb.proxyEnabled);
1775
2106
  useEffect5(() => {
1776
2107
  if (booted || !pm.manager) return;
@@ -1885,23 +2216,32 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1885
2216
  }, [pm, kb]);
1886
2217
  const handleOpenSelect = useCallback3((name) => {
1887
2218
  const st = pm.states.get(name);
1888
- 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
+ }
1889
2223
  kb.setModal("none");
1890
- }, [pm, platform, kb]);
2224
+ }, [pm, platform, kb, cliArgs.proxy, proxyOpts]);
1891
2225
  const icon = config.icon ?? "\u{1F4E6}";
1892
2226
  const modeLabel = cliArgs.lazy && config.lazy ? "lazy" : "normal";
1893
2227
  return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", height: rows, children: [
1894
- /* @__PURE__ */ jsx6(Box6, { children: /* @__PURE__ */ jsxs6(Text6, { bold: true, color: "cyan", children: [
1895
- " ",
1896
- icon,
1897
- " ",
1898
- config.name,
1899
- " \u2014 devup \u2014 ",
1900
- services.length,
1901
- " services (",
1902
- modeLabel,
1903
- ") "
1904
- ] }) }),
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
+ ] }),
1905
2245
  /* @__PURE__ */ jsx6(
1906
2246
  LogsPanel,
1907
2247
  {
@@ -1939,23 +2279,23 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1939
2279
  }
1940
2280
 
1941
2281
  // src/process/log-sink.ts
1942
- import { existsSync as existsSync8, mkdirSync as mkdirSync4, renameSync, createWriteStream } from "fs";
1943
- import { join as join5, dirname as dirname4 } from "path";
1944
- 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";
1945
2285
  var LogSink = class {
1946
2286
  dir;
1947
2287
  rotateOnStart;
1948
2288
  streams = /* @__PURE__ */ new Map();
1949
2289
  seen = /* @__PURE__ */ new Set();
1950
2290
  constructor(opts) {
1951
- const root = opts.rootDir ?? join5(homedir(), ".devup", "logs");
1952
- this.dir = join5(root, sanitize(opts.projectName));
2291
+ const root = opts.rootDir ?? join6(homedir2(), ".devup", "logs");
2292
+ this.dir = join6(root, sanitize2(opts.projectName));
1953
2293
  this.rotateOnStart = opts.rotateOnStart ?? true;
1954
2294
  mkdirSync4(this.dir, { recursive: true });
1955
2295
  }
1956
2296
  /** Returns the file path for a service log (useful for tests / UI). */
1957
2297
  pathFor(svcName) {
1958
- return join5(this.dir, `${sanitize(svcName)}.log`);
2298
+ return join6(this.dir, `${sanitize2(svcName)}.log`);
1959
2299
  }
1960
2300
  write(svcName, line) {
1961
2301
  const stream = this.streamFor(svcName);
@@ -1974,9 +2314,9 @@ var LogSink = class {
1974
2314
  let s = this.streams.get(svcName);
1975
2315
  if (s) return s;
1976
2316
  const file = this.pathFor(svcName);
1977
- if (this.rotateOnStart && !this.seen.has(svcName) && existsSync8(file)) {
2317
+ if (this.rotateOnStart && !this.seen.has(svcName) && existsSync10(file)) {
1978
2318
  try {
1979
- mkdirSync4(dirname4(file), { recursive: true });
2319
+ mkdirSync4(dirname5(file), { recursive: true });
1980
2320
  renameSync(file, file + ".prev");
1981
2321
  } catch {
1982
2322
  }
@@ -1989,7 +2329,7 @@ var LogSink = class {
1989
2329
  return s;
1990
2330
  }
1991
2331
  };
1992
- function sanitize(name) {
2332
+ function sanitize2(name) {
1993
2333
  return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
1994
2334
  }
1995
2335
 
@@ -2160,9 +2500,46 @@ function defineConfig(config) {
2160
2500
  }
2161
2501
 
2162
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
+ }
2163
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
+ }
2164
2526
  const cwd = process.cwd();
2165
- 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
+ }
2166
2543
  let configPath;
2167
2544
  try {
2168
2545
  configPath = findConfigFile(cwd, cliArgs.configPath);
@@ -2189,7 +2566,7 @@ ${formatValidationErrors(errors)}`);
2189
2566
  process.exit(1);
2190
2567
  }
2191
2568
  const platform = await detectPlatform();
2192
- const envFile = config.envFile ? join6(cwd, config.envFile) : join6(cwd, ".env");
2569
+ const envFile = config.envFile ? join7(cwd, config.envFile) : join7(cwd, ".env");
2193
2570
  const env = parseEnvFile(envFile, process.env);
2194
2571
  if (config.env) {
2195
2572
  for (const [k, v] of Object.entries(config.env)) {
@@ -2206,7 +2583,7 @@ ${formatValidationErrors(errors)}`);
2206
2583
  routes: config.proxy.routes,
2207
2584
  tls: cliArgs.proxyTls ?? config.proxy.tls ?? true,
2208
2585
  entrypoint: cliArgs.proxyEntrypoint ?? config.proxy.entrypoint ?? "websecure",
2209
- confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join6(homedir2(), ".traefik", "traefik_conf.yaml")
2586
+ confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join7(homedir3(), ".traefik", "traefik_conf.yaml")
2210
2587
  };
2211
2588
  }
2212
2589
  if (cliArgs.dryRun) {