@gachlab/devup 0.3.0 → 0.5.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";
@@ -112,6 +114,19 @@ 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.errorPattern !== void 0) {
118
+ if (typeof svc.errorPattern !== "string" || !svc.errorPattern.length) {
119
+ errors.push({ field: `services[${svc.name}].errorPattern`, message: `errorPattern must be a non-empty string` });
120
+ } else {
121
+ const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(svc.errorPattern);
122
+ try {
123
+ if (slashed) new RegExp(slashed[1], slashed[2] || "i");
124
+ else new RegExp(svc.errorPattern, "i");
125
+ } catch (e) {
126
+ errors.push({ field: `services[${svc.name}].errorPattern`, message: `Invalid regex: ${e.message}` });
127
+ }
128
+ }
129
+ }
115
130
  if (svc.readyPattern !== void 0) {
116
131
  if (typeof svc.readyPattern !== "string" || !svc.readyPattern.length) {
117
132
  errors.push({ field: `services[${svc.name}].readyPattern`, message: `readyPattern must be a non-empty string` });
@@ -136,6 +151,9 @@ function validateConfig(config, cwd) {
136
151
  if (hc.type !== "tcp" && hc.type !== "http") {
137
152
  errors.push({ field: `services[${svc.name}].healthCheck.type`, message: `Invalid healthCheck.type: ${hc.type} (must be "tcp" or "http")` });
138
153
  }
154
+ if (hc.startPeriod !== void 0 && (typeof hc.startPeriod !== "number" || hc.startPeriod < 0)) {
155
+ errors.push({ field: `services[${svc.name}].healthCheck.startPeriod`, message: `startPeriod must be a non-negative number (seconds), got ${hc.startPeriod}` });
156
+ }
139
157
  if (hc.type === "http" && hc.path && !hc.path.startsWith("/")) {
140
158
  errors.push({ field: `services[${svc.name}].healthCheck.path`, message: `healthCheck.path must start with "/": got "${hc.path}"` });
141
159
  }
@@ -221,6 +239,44 @@ function formatValidationErrors(errors) {
221
239
  // src/config/cli.ts
222
240
  var DEFAULT_LAZY_TIMEOUT = 10;
223
241
  var DEFAULT_ONCE_TIMEOUT = 90;
242
+ var USAGE = `devup \u2014 terminal UI dev stack runner
243
+
244
+ Usage: devup [options]
245
+
246
+ Service selection:
247
+ --only apis | webs Start only APIs or only webs
248
+ --services a,b,c Start only the named services
249
+ --profile <name> Start the services in a named profile (see ROADMAP)
250
+ --skip a,b,c Start everything except these
251
+ --config <path> Use a custom config file
252
+
253
+ Lazy mode:
254
+ --lazy Enable lazy mode (default)
255
+ --no-lazy Start every service immediately
256
+ --timeout <minutes> Idle timeout for lazy services. Default: 10
257
+
258
+ Reverse proxy:
259
+ --proxy Enable proxy config generation
260
+ --proxy-host <host> Override the target host (Docker/local)
261
+ --proxy-conf <path> Override the generated config file path
262
+ --proxy-tls Enable TLS in the generated config (default)
263
+ --no-proxy-tls Disable TLS
264
+ --proxy-entrypoint <n> Override entrypoint name (Traefik only)
265
+
266
+ CI / scripting:
267
+ --dry-run Print the resolved boot plan and exit
268
+ --once Boot, wait for readiness, exit 0/1 (no TUI)
269
+ --once-timeout <s> Max seconds to wait in --once mode. Default: 90
270
+
271
+ Log files:
272
+ --no-log-file Disable persistent log files
273
+ --log-dir <path> Override log root (default: ~/.devup/logs)
274
+
275
+ Other:
276
+ -h, --help Show this help and exit
277
+ -v, --version Show version and exit
278
+
279
+ See https://github.com/gachlab/devup for the full documentation.`;
224
280
  function parseCliArgs(argv) {
225
281
  const args = {
226
282
  skip: [],
@@ -344,6 +400,394 @@ function filterServices(services, args, config) {
344
400
  return result;
345
401
  }
346
402
 
403
+ // src/orchestrator/subcommands.ts
404
+ import { spawn } from "child_process";
405
+ import { createReadStream, watchFile, unwatchFile, existsSync as existsSync4, statSync } from "fs";
406
+ import { readFile as readFile2 } from "fs/promises";
407
+ import { join as join3, dirname } from "path";
408
+ import { fileURLToPath } from "url";
409
+ import { homedir } from "os";
410
+ import { createInterface } from "readline";
411
+
412
+ // src/process/health.ts
413
+ import net from "net";
414
+ import http from "http";
415
+ function checkPort(port, host = "127.0.0.1", timeoutMs = 2e3) {
416
+ return new Promise((resolve4) => {
417
+ const socket = new net.Socket();
418
+ socket.setTimeout(timeoutMs);
419
+ socket.once("connect", () => {
420
+ socket.destroy();
421
+ resolve4(true);
422
+ });
423
+ socket.once("error", () => {
424
+ socket.destroy();
425
+ resolve4(false);
426
+ });
427
+ socket.once("timeout", () => {
428
+ socket.destroy();
429
+ resolve4(false);
430
+ });
431
+ socket.connect(port, host);
432
+ });
433
+ }
434
+ function checkHttp(port, opts = {}) {
435
+ const path = opts.path ?? "/";
436
+ const host = opts.host ?? "127.0.0.1";
437
+ const timeoutMs = opts.timeoutMs ?? 2e3;
438
+ const accept = (code) => {
439
+ if (opts.expect === void 0) return code >= 200 && code < 300;
440
+ if (Array.isArray(opts.expect)) return opts.expect.includes(code);
441
+ return code === opts.expect;
442
+ };
443
+ return new Promise((resolve4) => {
444
+ const req = http.get({ host, port, path, timeout: timeoutMs }, (res) => {
445
+ const ok = typeof res.statusCode === "number" && accept(res.statusCode);
446
+ res.resume();
447
+ resolve4(ok);
448
+ });
449
+ req.on("error", () => resolve4(false));
450
+ req.on("timeout", () => {
451
+ req.destroy();
452
+ resolve4(false);
453
+ });
454
+ });
455
+ }
456
+ function checkHealth(port, hc) {
457
+ if (hc?.type === "http") {
458
+ return checkHttp(port, {
459
+ path: hc.path,
460
+ expect: hc.expect,
461
+ host: hc.host,
462
+ timeoutMs: hc.timeoutMs
463
+ });
464
+ }
465
+ return checkPort(port, "127.0.0.1", hc?.timeoutMs);
466
+ }
467
+ function waitForPort(port, opts = {}) {
468
+ const { timeout = 45e3, interval = 1e3 } = opts;
469
+ return new Promise((resolve4) => {
470
+ const start = Date.now();
471
+ const check = () => {
472
+ checkPort(port).then((ok) => {
473
+ if (ok) return resolve4(true);
474
+ if (Date.now() - start > timeout) return resolve4(false);
475
+ setTimeout(check, interval);
476
+ });
477
+ };
478
+ check();
479
+ });
480
+ }
481
+ function deriveHealth(isUp, currentStatus) {
482
+ if (currentStatus === "idle") return "idle";
483
+ if (isUp) return "up";
484
+ return currentStatus === "starting" ? "wait" : "down";
485
+ }
486
+
487
+ // src/utils.ts
488
+ import { existsSync as existsSync3, readFileSync, writeFileSync } from "fs";
489
+ import { createHash } from "crypto";
490
+ import { join as join2 } from "path";
491
+ function parseEnvFile(filePath, baseEnv = {}) {
492
+ const env = { ...baseEnv };
493
+ if (!existsSync3(filePath)) return env;
494
+ for (const line of readFileSync(filePath, "utf8").split("\n")) {
495
+ const trimmed = line.trim();
496
+ if (!trimmed || trimmed.startsWith("#")) continue;
497
+ const eqIdx = trimmed.indexOf("=");
498
+ if (eqIdx === -1) continue;
499
+ const key = trimmed.slice(0, eqIdx).trim();
500
+ let val = trimmed.slice(eqIdx + 1).trim();
501
+ if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
502
+ val = val.slice(1, -1);
503
+ }
504
+ if (!env[key]) env[key] = val;
505
+ }
506
+ return env;
507
+ }
508
+ function nextRamBannerVisibility(usagePct, previousVisible, highWatermark = 80, lowWatermark = 75) {
509
+ if (usagePct >= highWatermark) return true;
510
+ if (usagePct < lowWatermark) return false;
511
+ return previousVisible;
512
+ }
513
+ function redactSecrets(env) {
514
+ if (!env) return {};
515
+ const out = {};
516
+ for (const [k, v] of Object.entries(env)) {
517
+ out[k] = /secret|token|password|api[_-]?key|auth/i.test(k) ? "***" : v;
518
+ }
519
+ return out;
520
+ }
521
+ function detectLogLevel(line) {
522
+ const l = line.toLowerCase();
523
+ if (/\b(?:error|err|fail(?:ed|ure|ures|s)?|fatal|exception|crash(?:ed|es)?)\b/.test(l) || /❌|✗|⛔/.test(line)) return "error";
524
+ if (/\b(?:warn(?:ed|ing|s|ings)?|deprec)\b/.test(l) || /⚠/.test(line)) return "warn";
525
+ return "info";
526
+ }
527
+ function compileSearchPattern(term) {
528
+ if (!term) return null;
529
+ const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(term);
530
+ if (slashed) {
531
+ const flags = slashed[2].includes("i") ? slashed[2] : slashed[2] + "i";
532
+ try {
533
+ const re = new RegExp(slashed[1], flags);
534
+ return { test: (l) => re.test(l), regex: re };
535
+ } catch {
536
+ const lower2 = term.toLowerCase();
537
+ return { test: (l) => l.toLowerCase().includes(lower2), invalid: true };
538
+ }
539
+ }
540
+ const lower = term.toLowerCase();
541
+ return { test: (l) => l.toLowerCase().includes(lower) };
542
+ }
543
+ function fmtUptime(ms) {
544
+ if (!ms || ms < 0) return "-";
545
+ const s = Math.floor(ms / 1e3);
546
+ if (s < 60) return `${s}s`;
547
+ const m = Math.floor(s / 60);
548
+ if (m < 60) return `${m}m${s % 60}s`;
549
+ const h = Math.floor(m / 60);
550
+ if (h < 24) return `${h}h${m % 60}m`;
551
+ const d = Math.floor(h / 24);
552
+ return `${d}d${h % 24}h`;
553
+ }
554
+ function needsInstall(fullCwd) {
555
+ const nm = join2(fullCwd, "node_modules");
556
+ if (!existsSync3(nm)) return true;
557
+ try {
558
+ const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
559
+ const stampFile = join2(nm, ".install-stamp");
560
+ if (existsSync3(stampFile) && readFileSync(stampFile, "utf8") === pkgHash) return false;
561
+ } catch {
562
+ }
563
+ return true;
564
+ }
565
+ function writeInstallStamp(fullCwd) {
566
+ try {
567
+ const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
568
+ writeFileSync(join2(fullCwd, "node_modules", ".install-stamp"), pkgHash);
569
+ } catch {
570
+ }
571
+ }
572
+ function sortServiceNames(names, sortMode, statsMap, procState) {
573
+ if (sortMode === "name") return names.slice().sort();
574
+ return names.slice().sort((a, b) => {
575
+ if (sortMode === "mem") {
576
+ return (parseFloat(statsMap[b]?.mem ?? "0") || 0) - (parseFloat(statsMap[a]?.mem ?? "0") || 0);
577
+ }
578
+ return (procState[b]?.errors ?? 0) - (procState[a]?.errors ?? 0);
579
+ });
580
+ }
581
+ function groupByPhase(services) {
582
+ const phases = {};
583
+ for (const s of services) {
584
+ (phases[s.phase] ??= []).push(s);
585
+ }
586
+ return phases;
587
+ }
588
+ function buildProcessArgs(svc) {
589
+ const extra = svc.nodeArgs ?? [];
590
+ if (!svc.maxMem) return [...extra, ...svc.args];
591
+ if (svc.cmd === "node") return [`--max-old-space-size=${svc.maxMem}`, ...extra, ...svc.args];
592
+ return [...extra, ...svc.args];
593
+ }
594
+ function buildProcessEnv(svc, baseEnv) {
595
+ const env = { ...baseEnv, ...svc.extraEnv ?? {} };
596
+ if (svc.maxMem && svc.cmd !== "node") {
597
+ const existing = env["NODE_OPTIONS"] ?? "";
598
+ const flag = `--max-old-space-size=${svc.maxMem}`;
599
+ if (!existing.includes("max-old-space-size")) {
600
+ env["NODE_OPTIONS"] = existing ? `${existing} ${flag}` : flag;
601
+ }
602
+ }
603
+ return env;
604
+ }
605
+ function calcCpuPercent(totalCpuSec, prevCpu, prevTime) {
606
+ const elapsed = (Date.now() - prevTime) / 1e3;
607
+ const cpuDelta = totalCpuSec - prevCpu;
608
+ return elapsed > 0 ? cpuDelta / elapsed * 100 : 0;
609
+ }
610
+ var tagColors = [
611
+ "cyan",
612
+ "yellow",
613
+ "green",
614
+ "magenta",
615
+ "blue",
616
+ "red",
617
+ "#5faf5f",
618
+ "#d7af5f",
619
+ "#5f87d7",
620
+ "#af5faf",
621
+ "#5fd7d7",
622
+ "#d75f5f",
623
+ "white"
624
+ ];
625
+
626
+ // src/orchestrator/subcommands.ts
627
+ var KNOWN = /* @__PURE__ */ new Set(["logs", "install", "status", "help"]);
628
+ function detectSubcommand(argv) {
629
+ const first = argv[0];
630
+ return first && KNOWN.has(first) ? first : null;
631
+ }
632
+ function logRoot(config, override) {
633
+ const root = override ?? join3(homedir(), ".devup", "logs");
634
+ return join3(root, sanitize(config.name));
635
+ }
636
+ function sanitize(name) {
637
+ return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
638
+ }
639
+ async function runLogs(argv, opts) {
640
+ const out = opts.out ?? ((l) => console.log(l));
641
+ const follow = argv.includes("--follow") || argv.includes("-f");
642
+ const svcArg = argv.find((a) => !a.startsWith("-"));
643
+ if (!svcArg) {
644
+ out("usage: devup logs <service> [--follow]");
645
+ return 1;
646
+ }
647
+ const knownSvcs = opts.config.services.map((s) => s.name);
648
+ if (!knownSvcs.includes(svcArg)) {
649
+ out(`Unknown service "${svcArg}". Known: ${knownSvcs.join(", ")}`);
650
+ return 1;
651
+ }
652
+ const file = join3(logRoot(opts.config, opts.logDir), `${sanitize(svcArg)}.log`);
653
+ if (!existsSync4(file)) {
654
+ out(`No log file yet for "${svcArg}" (${file})`);
655
+ return follow ? await followFile(file, out) : 1;
656
+ }
657
+ await streamFile(file, out);
658
+ if (!follow) return 0;
659
+ return await followFile(file, out, statSync(file).size);
660
+ }
661
+ async function streamFile(file, out) {
662
+ return new Promise((resolve4, reject) => {
663
+ const rl = createInterface({ input: createReadStream(file, { encoding: "utf8" }) });
664
+ rl.on("line", (l) => out(l));
665
+ rl.on("close", () => resolve4());
666
+ rl.on("error", reject);
667
+ });
668
+ }
669
+ async function followFile(file, out, startAt = 0) {
670
+ let pos = startAt;
671
+ while (!existsSync4(file)) await new Promise((r) => setTimeout(r, 500));
672
+ return new Promise((resolve4) => {
673
+ const tick = async () => {
674
+ const size = statSync(file).size;
675
+ if (size > pos) {
676
+ await new Promise((res) => {
677
+ const rl = createInterface({ input: createReadStream(file, { encoding: "utf8", start: pos, end: size - 1 }) });
678
+ rl.on("line", (l) => out(l));
679
+ rl.on("close", () => {
680
+ pos = size;
681
+ res();
682
+ });
683
+ });
684
+ } else if (size < pos) {
685
+ pos = 0;
686
+ }
687
+ };
688
+ watchFile(file, { interval: 500 }, () => {
689
+ void tick();
690
+ });
691
+ process.once("SIGINT", () => {
692
+ unwatchFile(file);
693
+ resolve4(0);
694
+ });
695
+ });
696
+ }
697
+ async function runInstall(opts) {
698
+ const out = opts.out ?? ((l) => console.log(l));
699
+ const concurrency = opts.concurrency ?? 4;
700
+ const items = opts.config.services.map((s) => ({ name: s.name, cwd: join3(opts.baseCwd, s.cwd) }));
701
+ const queue = [...items];
702
+ const failed = [];
703
+ let inFlight = 0;
704
+ await new Promise((resolve4) => {
705
+ const pump = () => {
706
+ while (inFlight < concurrency && queue.length) {
707
+ const item = queue.shift();
708
+ inFlight++;
709
+ installOne(item.cwd, opts.env).then((ok) => {
710
+ inFlight--;
711
+ if (ok) out(`\u2713 ${item.name}`);
712
+ else {
713
+ failed.push(item.name);
714
+ out(`\u2717 ${item.name}`);
715
+ }
716
+ if (queue.length === 0 && inFlight === 0) resolve4();
717
+ else pump();
718
+ });
719
+ }
720
+ };
721
+ pump();
722
+ });
723
+ if (failed.length) {
724
+ out(`
725
+ failed: ${failed.join(", ")}`);
726
+ return 1;
727
+ }
728
+ out(`
729
+ ${items.length} services up to date`);
730
+ return 0;
731
+ }
732
+ function installOne(cwd, env) {
733
+ if (!existsSync4(cwd)) return Promise.resolve(false);
734
+ if (!needsInstall(cwd)) return Promise.resolve(true);
735
+ return new Promise((resolve4) => {
736
+ const command = process.platform === "win32" ? "npm.cmd" : "npm";
737
+ const proc = spawn(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
738
+ proc.on("close", (code) => {
739
+ if (code === 0) {
740
+ writeInstallStamp(cwd);
741
+ resolve4(true);
742
+ } else resolve4(false);
743
+ });
744
+ proc.on("error", () => resolve4(false));
745
+ });
746
+ }
747
+ async function runStatus(opts) {
748
+ const out = opts.out ?? ((l) => console.log(l));
749
+ out(`${opts.config.icon ?? "\u{1F4E6}"} ${opts.config.name} \u2014 ${opts.config.services.length} services`);
750
+ out("");
751
+ const maxLen = Math.max(...opts.config.services.map((s) => s.name.length), 12);
752
+ out(`${"Service".padEnd(maxLen)} ${"Port".padStart(5)} ${"Type".padEnd(4)} Health`);
753
+ out("-".repeat(maxLen + 24));
754
+ for (const svc of opts.config.services) {
755
+ const up = await checkHealth(svc.port, svc.healthCheck);
756
+ const health = up ? "\u2713 up" : "\u2717 down";
757
+ out(`${svc.name.padEnd(maxLen)} ${String(svc.port).padStart(5)} ${svc.type.padEnd(4)} ${health}`);
758
+ }
759
+ return 0;
760
+ }
761
+ function runHelp(argv, opts = {}) {
762
+ const out = opts.out ?? ((l) => console.log(l));
763
+ const sub = argv[0];
764
+ if (sub === "logs") {
765
+ out("Usage: devup logs <service> [--follow|-f]");
766
+ out(" Print the persisted log file for a service (works without devup running).");
767
+ out(" --follow tails new lines as they are appended.");
768
+ return 0;
769
+ }
770
+ if (sub === "install") {
771
+ out("Usage: devup install");
772
+ out(" Run `npm install` across every service.cwd in parallel (max 4 at a time).");
773
+ out(" Skips services whose .install-stamp matches package.json hash.");
774
+ return 0;
775
+ }
776
+ if (sub === "status") {
777
+ out("Usage: devup status");
778
+ out(" For each service, probes its health-check endpoint and prints up/down.");
779
+ return 0;
780
+ }
781
+ out("Subcommands:");
782
+ out(" devup logs <service> [--follow] Read the persisted log file");
783
+ out(" devup install Concurrent npm install across services");
784
+ out(" devup status Health check every service in config");
785
+ out(" devup help [<subcommand>] Show detailed help for a subcommand");
786
+ out("");
787
+ out("No subcommand \u2192 launch the interactive TUI.");
788
+ return 0;
789
+ }
790
+
347
791
  // src/platform/detect.ts
348
792
  async function detectPlatform() {
349
793
  switch (process.platform) {
@@ -365,8 +809,8 @@ async function detectPlatform() {
365
809
  }
366
810
 
367
811
  // src/proxy-config/traefik.ts
368
- import { existsSync as existsSync3, mkdirSync, writeFileSync } from "fs";
369
- import { dirname } from "path";
812
+ import { existsSync as existsSync5, mkdirSync, writeFileSync as writeFileSync2 } from "fs";
813
+ import { dirname as dirname2 } from "path";
370
814
  var EMPTY_CONFIG = "http:\n routers: {}\n services: {}\n";
371
815
  var TraefikProvider = class {
372
816
  name = "traefik";
@@ -403,9 +847,9 @@ ${svcs.join("\n")}
403
847
  `;
404
848
  }
405
849
  write(content, opts) {
406
- const dir = dirname(opts.confPath);
407
- if (!existsSync3(dir)) mkdirSync(dir, { recursive: true });
408
- writeFileSync(opts.confPath, content);
850
+ const dir = dirname2(opts.confPath);
851
+ if (!existsSync5(dir)) mkdirSync(dir, { recursive: true });
852
+ writeFileSync2(opts.confPath, content);
409
853
  }
410
854
  clear(opts) {
411
855
  this.write(EMPTY_CONFIG, opts);
@@ -413,8 +857,8 @@ ${svcs.join("\n")}
413
857
  };
414
858
 
415
859
  // 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";
860
+ import { existsSync as existsSync6, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
861
+ import { dirname as dirname3 } from "path";
418
862
  var EMPTY_CONFIG2 = "# devup: no healthy services\n";
419
863
  var NginxProvider = class {
420
864
  name = "nginx";
@@ -451,9 +895,9 @@ var NginxProvider = class {
451
895
  return blocks.join("\n\n") + "\n";
452
896
  }
453
897
  write(content, opts) {
454
- const dir = dirname2(opts.confPath);
455
- if (!existsSync4(dir)) mkdirSync2(dir, { recursive: true });
456
- writeFileSync2(opts.confPath, content);
898
+ const dir = dirname3(opts.confPath);
899
+ if (!existsSync6(dir)) mkdirSync2(dir, { recursive: true });
900
+ writeFileSync3(opts.confPath, content);
457
901
  }
458
902
  clear(opts) {
459
903
  this.write(EMPTY_CONFIG2, opts);
@@ -461,8 +905,8 @@ var NginxProvider = class {
461
905
  };
462
906
 
463
907
  // 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";
908
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
909
+ import { dirname as dirname4 } from "path";
466
910
  var EMPTY_CONFIG3 = "# devup: no healthy services\n";
467
911
  var CaddyProvider = class {
468
912
  name = "caddy";
@@ -485,9 +929,9 @@ var CaddyProvider = class {
485
929
  return blocks.join("\n\n") + "\n";
486
930
  }
487
931
  write(content, opts) {
488
- const dir = dirname3(opts.confPath);
489
- if (!existsSync5(dir)) mkdirSync3(dir, { recursive: true });
490
- writeFileSync3(opts.confPath, content);
932
+ const dir = dirname4(opts.confPath);
933
+ if (!existsSync7(dir)) mkdirSync3(dir, { recursive: true });
934
+ writeFileSync4(opts.confPath, content);
491
935
  }
492
936
  clear(opts) {
493
937
  this.write(EMPTY_CONFIG3, opts);
@@ -509,201 +953,23 @@ function detectProxyProvider(name) {
509
953
  return factory();
510
954
  }
511
955
 
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
956
  // src/tui/App.tsx
617
- import { useEffect as useEffect5, useState as useState5, useCallback as useCallback3, useRef as useRef3 } from "react";
957
+ import { useEffect as useEffect5, useState as useState6, useCallback as useCallback3, useRef as useRef3 } from "react";
618
958
  import { Box as Box6, Text as Text6, useStdout } from "ink";
619
959
 
620
960
  // src/tui/hooks/useProcessManager.ts
621
961
  import { useState, useEffect, useRef, useCallback } from "react";
622
962
 
623
963
  // 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
- }
964
+ import { spawn as spawn3 } from "child_process";
965
+ import { existsSync as existsSync9 } from "fs";
966
+ import { join as join4, resolve as resolve3 } from "path";
701
967
 
702
968
  // src/process/installer.ts
703
- import { spawn } from "child_process";
704
- import { existsSync as existsSync7 } from "fs";
969
+ import { spawn as spawn2 } from "child_process";
970
+ import { existsSync as existsSync8 } from "fs";
705
971
  function installService(cwd, env, onLog) {
706
- if (!existsSync7(cwd)) {
972
+ if (!existsSync8(cwd)) {
707
973
  onLog?.(`\u26A0 directory not found: ${cwd}`);
708
974
  return Promise.resolve(false);
709
975
  }
@@ -712,9 +978,9 @@ function installService(cwd, env, onLog) {
712
978
  return Promise.resolve(true);
713
979
  }
714
980
  onLog?.("\u{1F4E6} npm install...");
715
- return new Promise((resolve3) => {
981
+ return new Promise((resolve4) => {
716
982
  const command = process.platform === "win32" ? "npm.cmd" : "npm";
717
- const proc = spawn(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
983
+ const proc = spawn2(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
718
984
  let stderr = "";
719
985
  proc.stderr?.on("data", (d) => {
720
986
  stderr += d.toString();
@@ -722,16 +988,16 @@ function installService(cwd, env, onLog) {
722
988
  proc.on("close", (code) => {
723
989
  if (code !== 0) {
724
990
  onLog?.(`\u26A0 npm install failed: ${stderr.split("\n")[0]}`);
725
- resolve3(false);
991
+ resolve4(false);
726
992
  } else {
727
993
  writeInstallStamp(cwd);
728
994
  onLog?.("\u2705 dependencies ready");
729
- resolve3(true);
995
+ resolve4(true);
730
996
  }
731
997
  });
732
998
  proc.on("error", (err) => {
733
999
  onLog?.(`\u26A0 spawn error: ${err.message}`);
734
- resolve3(false);
1000
+ resolve4(false);
735
1001
  });
736
1002
  });
737
1003
  }
@@ -749,6 +1015,26 @@ function compileReadyPattern(pattern) {
749
1015
  return null;
750
1016
  }
751
1017
  }
1018
+ function extractWatchPaths(args) {
1019
+ const watchFlags = /* @__PURE__ */ new Set(["--watch", "--watch-path"]);
1020
+ const out = [];
1021
+ for (let i = 0; i < args.length; i++) {
1022
+ const a = args[i];
1023
+ if (watchFlags.has(a)) {
1024
+ const v = args[i + 1];
1025
+ if (v && !v.startsWith("-")) {
1026
+ out.push(v);
1027
+ i++;
1028
+ }
1029
+ continue;
1030
+ }
1031
+ const eq = a.indexOf("=");
1032
+ if (eq > 0 && watchFlags.has(a.slice(0, eq))) {
1033
+ out.push(a.slice(eq + 1));
1034
+ }
1035
+ }
1036
+ return out;
1037
+ }
752
1038
  function lineBuffer(onLine) {
753
1039
  let buf = "";
754
1040
  return {
@@ -783,12 +1069,12 @@ var ProcessManager = class {
783
1069
  this.events = opts.events;
784
1070
  }
785
1071
  async install(svc, colorIdx) {
786
- const cwd = join3(this.baseCwd, svc.cwd);
1072
+ const cwd = join4(this.baseCwd, svc.cwd);
787
1073
  const idx = colorIdx ?? this.state.get(svc.name)?.colorIdx ?? 0;
788
1074
  return installService(cwd, this.env, (msg) => this.log(svc.name, msg, idx));
789
1075
  }
790
1076
  async start(svc, colorIdx, isRestart = false) {
791
- const cwd = join3(this.baseCwd, svc.cwd);
1077
+ const cwd = join4(this.baseCwd, svc.cwd);
792
1078
  if (svc.type === "api") {
793
1079
  const occupied = await checkPort(svc.port);
794
1080
  if (occupied && !isRestart) {
@@ -804,8 +1090,14 @@ var ProcessManager = class {
804
1090
  }
805
1091
  }
806
1092
  const args = buildProcessArgs(svc);
1093
+ const missingWatchPaths = extractWatchPaths(args).filter((p) => !existsSync9(resolve3(cwd, p)));
1094
+ if (missingWatchPaths.length) {
1095
+ this.log(svc.name, `\u26A0 missing watch paths: ${missingWatchPaths.join(", ")}`, colorIdx);
1096
+ this.recordCrashedState(svc, colorIdx);
1097
+ return;
1098
+ }
807
1099
  const env = buildProcessEnv(svc, this.env);
808
- const proc = spawn2(svc.cmd, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
1100
+ const proc = spawn3(svc.cmd, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
809
1101
  const prev = this.state.get(svc.name);
810
1102
  const state = {
811
1103
  svc,
@@ -831,12 +1123,14 @@ var ProcessManager = class {
831
1123
  this.events.onStateChange(svc.name, state);
832
1124
  }
833
1125
  };
1126
+ const errorRegex = compileReadyPattern(svc.errorPattern);
1127
+ const countsAsError = (line) => errorRegex ? errorRegex.test(line) : true;
834
1128
  const stdoutBuf = lineBuffer((line) => {
835
1129
  markReadyIfMatch(line);
836
1130
  this.log(svc.name, line, colorIdx);
837
1131
  });
838
1132
  const stderrBuf = lineBuffer((line) => {
839
- state.errors += 1;
1133
+ if (countsAsError(line)) state.errors += 1;
840
1134
  markReadyIfMatch(line);
841
1135
  this.log(svc.name, line, colorIdx);
842
1136
  });
@@ -877,29 +1171,29 @@ var ProcessManager = class {
877
1171
  }
878
1172
  runPreBuild(svc, cwd, colorIdx) {
879
1173
  this.log(svc.name, `\u{1F528} preBuild: ${svc.preBuild}`, colorIdx);
880
- return new Promise((resolve3) => {
1174
+ return new Promise((resolve4) => {
881
1175
  const isWin = process.platform === "win32";
882
1176
  const shell = isWin ? "cmd.exe" : "sh";
883
1177
  const shellFlag = isWin ? "/c" : "-c";
884
1178
  const env = buildProcessEnv(svc, this.env);
885
- const child = spawn2(shell, [shellFlag, svc.preBuild], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
1179
+ const child = spawn3(shell, [shellFlag, svc.preBuild], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
886
1180
  const outBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
887
1181
  const errBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
888
1182
  child.stdout?.on("data", (d) => outBuf.push(d));
889
1183
  child.stderr?.on("data", (d) => errBuf.push(d));
890
1184
  child.on("error", (err) => {
891
1185
  this.log(svc.name, `[build] \u274C ${err.message}`, colorIdx);
892
- resolve3(false);
1186
+ resolve4(false);
893
1187
  });
894
1188
  child.on("close", (code) => {
895
1189
  outBuf.flush();
896
1190
  errBuf.flush();
897
1191
  if (code === 0) {
898
1192
  this.log(svc.name, `[build] \u2705 done`, colorIdx);
899
- resolve3(true);
1193
+ resolve4(true);
900
1194
  } else {
901
1195
  this.log(svc.name, `[build] \u274C exited with code ${code}`, colorIdx);
902
- resolve3(false);
1196
+ resolve4(false);
903
1197
  }
904
1198
  });
905
1199
  });
@@ -909,7 +1203,7 @@ var ProcessManager = class {
909
1203
  const isWin = process.platform === "win32";
910
1204
  const shell = isWin ? "cmd.exe" : "sh";
911
1205
  const shellFlag = isWin ? "/c" : "-c";
912
- const child = spawn2(shell, [shellFlag, svc.watchBuild], {
1206
+ const child = spawn3(shell, [shellFlag, svc.watchBuild], {
913
1207
  cwd,
914
1208
  env,
915
1209
  detached: true,
@@ -971,6 +1265,10 @@ var ProcessManager = class {
971
1265
  st.health = st.status === "idle" ? "idle" : "down";
972
1266
  continue;
973
1267
  }
1268
+ const startPeriodMs = (st.svc.healthCheck?.startPeriod ?? 0) * 1e3;
1269
+ if (startPeriodMs > 0 && st.startedAt && Date.now() - st.startedAt < startPeriodMs) {
1270
+ continue;
1271
+ }
974
1272
  const isUp = await checkHealth(st.svc.port, st.svc.healthCheck);
975
1273
  const prev = st.health;
976
1274
  st.health = deriveHealth(isUp, st.status);
@@ -992,14 +1290,14 @@ var ProcessManager = class {
992
1290
  }
993
1291
  for (const st of this.state.values()) this.stopWatchProc(st);
994
1292
  const waits = procs.map(
995
- (p) => p.exitCode !== null || p.signalCode !== null ? Promise.resolve() : new Promise((resolve3) => p.once("close", () => resolve3()))
1293
+ (p) => p.exitCode !== null || p.signalCode !== null ? Promise.resolve() : new Promise((resolve4) => p.once("close", () => resolve4()))
996
1294
  );
997
1295
  let timedOut = false;
998
1296
  await Promise.race([
999
1297
  Promise.all(waits),
1000
- new Promise((resolve3) => setTimeout(() => {
1298
+ new Promise((resolve4) => setTimeout(() => {
1001
1299
  timedOut = true;
1002
- resolve3();
1300
+ resolve4();
1003
1301
  }, grace))
1004
1302
  ]);
1005
1303
  if (timedOut) {
@@ -1010,7 +1308,7 @@ var ProcessManager = class {
1010
1308
  }
1011
1309
  await Promise.race([
1012
1310
  Promise.all(waits),
1013
- new Promise((resolve3) => setTimeout(resolve3, 1e3))
1311
+ new Promise((resolve4) => setTimeout(resolve4, 1e3))
1014
1312
  ]);
1015
1313
  }
1016
1314
  }
@@ -1042,7 +1340,7 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
1042
1340
  events: {
1043
1341
  onLog: (svcName, text, colorIdx) => {
1044
1342
  sinkRef.current?.write(svcName, text);
1045
- const entry = { svcName, text, colorIdx, ts: Date.now() };
1343
+ const entry = { svcName, text, colorIdx, ts: Date.now(), level: detectLogLevel(text) };
1046
1344
  if (pausedRef.current) {
1047
1345
  pendingLogsRef.current.push(entry);
1048
1346
  if (pendingLogsRef.current.length > 5e3) {
@@ -1100,7 +1398,7 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
1100
1398
  }, []);
1101
1399
  const pushLog = useCallback((svcName, text, colorIdx = 0) => {
1102
1400
  sinkRef.current?.write(svcName, text);
1103
- const entry = { svcName, text, colorIdx, ts: Date.now() };
1401
+ const entry = { svcName, text, colorIdx, ts: Date.now(), level: detectLogLevel(text) };
1104
1402
  if (pausedRef.current) {
1105
1403
  pendingLogsRef.current.push(entry);
1106
1404
  if (pendingLogsRef.current.length > 5e3) {
@@ -1173,8 +1471,11 @@ function useKeyBindings(opts) {
1173
1471
  sortIdx: 0,
1174
1472
  proxyEnabled: false,
1175
1473
  logsScrollOffset: 0,
1176
- statsScrollOffset: 0
1474
+ statsScrollOffset: 0,
1475
+ levelFilter: "all",
1476
+ verboseStats: false
1177
1477
  });
1478
+ const LEVEL_CYCLE = ["all", "error", "warn"];
1178
1479
  const setModal = useCallback2((modal) => setState((s) => ({ ...s, modal })), []);
1179
1480
  const setFilter = useCallback2((f) => setState((s) => ({ ...s, logFilter: f, modal: "none" })), []);
1180
1481
  const setSearch = useCallback2((t) => setState((s) => ({ ...s, searchTerm: t, modal: "none" })), []);
@@ -1196,14 +1497,15 @@ function useKeyBindings(opts) {
1196
1497
  else if (input === "r") setModal("restart");
1197
1498
  else if (input === "o") setModal("open");
1198
1499
  else if (input === "/") setModal("search");
1199
- else if (input === "a") setState((s) => ({ ...s, logFilter: null, searchTerm: null }));
1500
+ else if (input === "a") setState((s) => ({ ...s, logFilter: null, searchTerm: null, levelFilter: "all" }));
1200
1501
  else if (input === "p") setState((s) => ({ ...s, logsPaused: !s.logsPaused }));
1201
1502
  else if (input === "t") setState((s) => ({ ...s, showTimestamps: !s.showTimestamps }));
1202
1503
  else if (input === "s") setState((s) => ({ ...s, sortIdx: (s.sortIdx + 1) % SORT_MODES.length }));
1203
1504
  else if (input === "T") {
1204
1505
  opts.onToggleProxy();
1205
1506
  setState((s) => ({ ...s, proxyEnabled: !s.proxyEnabled }));
1206
- }
1507
+ } else if (input === "L") setState((s) => ({ ...s, levelFilter: LEVEL_CYCLE[(LEVEL_CYCLE.indexOf(s.levelFilter) + 1) % LEVEL_CYCLE.length] }));
1508
+ else if (input === "v") setState((s) => ({ ...s, verboseStats: !s.verboseStats }));
1207
1509
  }, { isActive });
1208
1510
  return {
1209
1511
  ...state,
@@ -1245,11 +1547,12 @@ function useProxySync(provider, opts, states, enabled) {
1245
1547
  }
1246
1548
 
1247
1549
  // src/tui/LogsPanel.tsx
1248
- import { useEffect as useEffect3 } from "react";
1550
+ import { useEffect as useEffect3, useMemo } from "react";
1249
1551
  import { Box, Text } from "ink";
1250
1552
  import { jsx, jsxs } from "react/jsx-runtime";
1251
- function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLen, height, focused, scrollOffset, resetScroll }) {
1252
- const filtered = filter ? logs.filter((l) => l.svcName === filter) : logs;
1553
+ function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLen, height, focused, scrollOffset, resetScroll, levelFilter = "all" }) {
1554
+ const byService = filter ? logs.filter((l) => l.svcName === filter) : logs;
1555
+ const filtered = levelFilter === "all" ? byService : levelFilter === "error" ? byService.filter((l) => l.level === "error") : byService.filter((l) => l.level === "error" || l.level === "warn");
1253
1556
  const contentHeight = Math.max(1, height - 2);
1254
1557
  const totalLines = filtered.length;
1255
1558
  const maxOffset = Math.max(0, totalLines - contentHeight);
@@ -1260,11 +1563,14 @@ function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLe
1260
1563
  useEffect3(() => {
1261
1564
  resetScroll();
1262
1565
  }, [filter, searchTerm, resetScroll]);
1566
+ const matcher = useMemo(() => compileSearchPattern(searchTerm), [searchTerm]);
1263
1567
  const scrolled = effectiveOffset > 0;
1264
1568
  const label = [
1265
1569
  "Logs",
1266
1570
  filter ? `[${filter}]` : "",
1267
1571
  searchTerm ? `/${searchTerm}` : "",
1572
+ matcher?.invalid ? "(invalid regex)" : "",
1573
+ levelFilter !== "all" ? `[level: ${levelFilter}${levelFilter === "warn" ? "+error" : ""}]` : "",
1268
1574
  paused ? "[PAUSED]" : "",
1269
1575
  scrolled ? "[SCROLL]" : "",
1270
1576
  `${filtered.length} lines`,
@@ -1280,7 +1586,7 @@ function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLe
1280
1586
  const color = tagColors[entry.colorIdx % tagColors.length];
1281
1587
  const ts = showTimestamps ? new Date(entry.ts).toLocaleTimeString("en-GB") + " " : "";
1282
1588
  const line = entry.text;
1283
- const isMatch = searchTerm && line.toLowerCase().includes(searchTerm.toLowerCase());
1589
+ const isMatch = matcher ? matcher.test(line) : false;
1284
1590
  return /* @__PURE__ */ jsxs(Box, { children: [
1285
1591
  showTimestamps && /* @__PURE__ */ jsx(Text, { dimColor: true, children: ts }),
1286
1592
  /* @__PURE__ */ jsxs(Text, { color, children: [
@@ -1296,7 +1602,7 @@ function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLe
1296
1602
  }
1297
1603
 
1298
1604
  // src/tui/StatsPanel.tsx
1299
- import { useEffect as useEffect4 } from "react";
1605
+ import { useEffect as useEffect4, useState as useState3 } from "react";
1300
1606
  import { Box as Box2, Text as Text2 } from "ink";
1301
1607
  import os from "os";
1302
1608
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
@@ -1306,29 +1612,71 @@ var H = {
1306
1612
  down: { c: "\u25CF", color: "red" },
1307
1613
  idle: { c: "\u25CB", color: "blue" }
1308
1614
  };
1309
- function Row({ name, st, stat, ml }) {
1310
- const h = H[st.health] ?? H["down"];
1615
+ var MAX_RESTARTS2 = 3;
1616
+ function isCrashLooped(st) {
1617
+ return st.status === "crashed" && st.restarts >= MAX_RESTARTS2;
1618
+ }
1619
+ function Row({ name, st, stat, ml, verbose }) {
1620
+ const looped = isCrashLooped(st);
1621
+ 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
1622
  const color = tagColors[st.colorIdx % tagColors.length];
1312
- const sc = st.status === "running" ? "green" : st.status === "starting" ? "yellow" : st.status === "idle" ? "blue" : "red";
1623
+ const sc = looped ? "red" : st.status === "running" ? "green" : st.status === "starting" ? "yellow" : st.status === "idle" ? "blue" : "red";
1624
+ const statusLabel = looped ? "looping" : st.status;
1313
1625
  const up = st.startedAt ? fmtUptime(Date.now() - st.startedAt) : "-";
1314
- return /* @__PURE__ */ jsxs2(Text2, { children: [
1315
- /* @__PURE__ */ jsx2(Text2, { color: h.color, children: h.c }),
1316
- " ",
1317
- /* @__PURE__ */ jsx2(Text2, { color, children: name.padEnd(ml) }),
1318
- " ",
1319
- String(st.svc.port).padStart(5),
1320
- " ",
1321
- /* @__PURE__ */ jsx2(Text2, { color: sc, children: st.status.padEnd(8) }),
1322
- " ",
1323
- (stat?.cpu ?? "-").padStart(6),
1324
- " ",
1325
- (stat?.mem ?? "-").padStart(8),
1326
- " ",
1327
- String(st.errors).padStart(3),
1328
- " ",
1329
- String(st.restarts).padStart(3),
1330
- " ",
1331
- up.padStart(6)
1626
+ if (!verbose) {
1627
+ return /* @__PURE__ */ jsxs2(Text2, { children: [
1628
+ indicator,
1629
+ " ",
1630
+ /* @__PURE__ */ jsx2(Text2, { color, children: name.padEnd(ml) }),
1631
+ " ",
1632
+ String(st.svc.port).padStart(5),
1633
+ " ",
1634
+ /* @__PURE__ */ jsx2(Text2, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
1635
+ " ",
1636
+ (stat?.cpu ?? "-").padStart(6),
1637
+ " ",
1638
+ (stat?.mem ?? "-").padStart(8),
1639
+ " ",
1640
+ String(st.errors).padStart(3),
1641
+ " ",
1642
+ String(st.restarts).padStart(3),
1643
+ " ",
1644
+ up.padStart(6)
1645
+ ] });
1646
+ }
1647
+ const resolvedArgs = buildProcessArgs(st.svc).join(" ");
1648
+ const env = redactSecrets(st.svc.extraEnv);
1649
+ const envStr = Object.entries(env).map(([k, v]) => `${k}=${v}`).join(" ");
1650
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
1651
+ /* @__PURE__ */ jsxs2(Text2, { children: [
1652
+ indicator,
1653
+ " ",
1654
+ /* @__PURE__ */ jsx2(Text2, { color, children: name.padEnd(ml) }),
1655
+ " ",
1656
+ String(st.svc.port).padStart(5),
1657
+ " ",
1658
+ /* @__PURE__ */ jsx2(Text2, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
1659
+ " ",
1660
+ (stat?.cpu ?? "-").padStart(6),
1661
+ " ",
1662
+ (stat?.mem ?? "-").padStart(8),
1663
+ " ",
1664
+ String(st.errors).padStart(3),
1665
+ " ",
1666
+ String(st.restarts).padStart(3),
1667
+ " ",
1668
+ up.padStart(6)
1669
+ ] }),
1670
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1671
+ " cmd: ",
1672
+ st.svc.cmd,
1673
+ " ",
1674
+ resolvedArgs
1675
+ ] }),
1676
+ envStr && /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1677
+ " env: ",
1678
+ envStr
1679
+ ] })
1332
1680
  ] });
1333
1681
  }
1334
1682
  function ColHeader({ ml }) {
@@ -1347,7 +1695,7 @@ function ColHeader({ ml }) {
1347
1695
  "Up".padStart(6)
1348
1696
  ] });
1349
1697
  }
1350
- function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scrollOffset, resetScroll }) {
1698
+ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scrollOffset, resetScroll, verbose = false }) {
1351
1699
  const names = [...states.keys()];
1352
1700
  const stObj = Object.fromEntries([...states].map(([k, v]) => [k, { errors: v.errors }]));
1353
1701
  const statsObj = Object.fromEntries([...stats].map(([k, v]) => [k, v]));
@@ -1385,6 +1733,13 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1385
1733
  const totalRowsLong = Math.max(apis.length, webs.length);
1386
1734
  const positionInfo = focused && totalRowsLong > 0 ? `(${effectiveOffset + 1}-${Math.min(effectiveOffset + rowsPerCol, totalRowsLong)}/${totalRowsLong})` : "";
1387
1735
  const scrolled = effectiveOffset > 0;
1736
+ const loopedCount = [...states.values()].filter(isCrashLooped).length;
1737
+ const ramPct = parseFloat(usedGB) / parseFloat(totalGB) * 100;
1738
+ const [ramBanner, setRamBanner] = useState3(false);
1739
+ useEffect4(() => {
1740
+ setRamBanner((prev) => nextRamBannerVisibility(ramPct, prev));
1741
+ }, [ramPct]);
1742
+ const topConsumers = ramBanner ? [...stats.entries()].map(([n, s]) => ({ name: n, mb: parseFloat(s.mem) || 0 })).sort((a, b) => b.mb - a.mb).slice(0, 3) : [];
1388
1743
  return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "green" : "gray", height, children: [
1389
1744
  /* @__PURE__ */ jsxs2(Box2, { children: [
1390
1745
  /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "green", children: [
@@ -1392,6 +1747,11 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1392
1747
  positionInfo
1393
1748
  ] }),
1394
1749
  scrolled && /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: " [SCROLL]" }),
1750
+ loopedCount > 0 && /* @__PURE__ */ jsxs2(Text2, { color: "red", bold: true, children: [
1751
+ " \u26A0 ",
1752
+ loopedCount,
1753
+ " need attention"
1754
+ ] }),
1395
1755
  /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1396
1756
  " System: ",
1397
1757
  cpus,
@@ -1421,6 +1781,14 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1421
1781
  sortMode
1422
1782
  ] })
1423
1783
  ] }),
1784
+ ramBanner && /* @__PURE__ */ jsxs2(Box2, { children: [
1785
+ /* @__PURE__ */ jsxs2(Text2, { color: "yellow", bold: true, children: [
1786
+ " \u26A0 RAM ",
1787
+ ramPct.toFixed(0),
1788
+ "% \u2014 top: "
1789
+ ] }),
1790
+ /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: topConsumers.map((c) => `${c.name} ${c.mb.toFixed(0)}MB`).join(", ") })
1791
+ ] }),
1424
1792
  /* @__PURE__ */ jsxs2(Box2, { flexGrow: 1, children: [
1425
1793
  /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexGrow: 1, flexBasis: 0, children: [
1426
1794
  /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "cyan", children: [
@@ -1429,7 +1797,7 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1429
1797
  ")"
1430
1798
  ] }),
1431
1799
  /* @__PURE__ */ jsx2(ColHeader, { ml }),
1432
- visibleApis.map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml }, n))
1800
+ visibleApis.map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml, verbose }, n))
1433
1801
  ] }),
1434
1802
  /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", width: 1, children: Array.from({ length: contentHeight }, (_, i) => /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\u2502" }, i)) }),
1435
1803
  /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexGrow: 1, flexBasis: 0, children: [
@@ -1439,7 +1807,7 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1439
1807
  ")"
1440
1808
  ] }),
1441
1809
  /* @__PURE__ */ jsx2(ColHeader, { ml }),
1442
- visibleWebs.map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml }, n))
1810
+ visibleWebs.map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml, verbose }, n))
1443
1811
  ] })
1444
1812
  ] })
1445
1813
  ] });
@@ -1464,6 +1832,8 @@ function StatusBar() {
1464
1832
  " Clear ",
1465
1833
  /* @__PURE__ */ jsx3(Text3, { bold: true, children: "f" }),
1466
1834
  " Filter ",
1835
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "L" }),
1836
+ " Level ",
1467
1837
  /* @__PURE__ */ jsx3(Text3, { bold: true, children: "a" }),
1468
1838
  " All ",
1469
1839
  /* @__PURE__ */ jsx3(Text3, { bold: true, children: "r" }),
@@ -1478,48 +1848,86 @@ function StatusBar() {
1478
1848
  " Pause ",
1479
1849
  /* @__PURE__ */ jsx3(Text3, { bold: true, children: "t" }),
1480
1850
  " Time ",
1851
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "v" }),
1852
+ " Verbose ",
1481
1853
  /* @__PURE__ */ jsx3(Text3, { bold: true, children: "T" }),
1482
1854
  " Proxy"
1483
1855
  ] }) });
1484
1856
  }
1485
1857
 
1486
1858
  // src/tui/ServiceList.tsx
1487
- import { useState as useState3 } from "react";
1859
+ import { useState as useState4, useMemo as useMemo2 } from "react";
1488
1860
  import { Box as Box4, Text as Text4, useInput as useInput2 } from "ink";
1489
1861
  import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1490
1862
  function ServiceList({ title, services, onSelect, onClose, filterType }) {
1491
- const names = [...services.keys()].filter((n) => !filterType || services.get(n).svc.type === filterType);
1492
- const [idx, setIdx] = useState3(0);
1863
+ const allNames = useMemo2(
1864
+ () => [...services.keys()].filter((n) => !filterType || services.get(n).svc.type === filterType),
1865
+ [services, filterType]
1866
+ );
1867
+ const [idx, setIdx] = useState4(0);
1868
+ const [query, setQuery] = useState4("");
1869
+ const names = useMemo2(() => {
1870
+ if (!query) return allNames;
1871
+ const q = query.toLowerCase();
1872
+ return allNames.filter((n) => n.toLowerCase().includes(q));
1873
+ }, [allNames, query]);
1874
+ const clamped = Math.min(idx, Math.max(0, names.length - 1));
1493
1875
  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));
1876
+ if (key.escape) {
1877
+ if (query) setQuery("");
1878
+ else onClose();
1879
+ return;
1880
+ }
1881
+ if (key.return) {
1882
+ if (names[clamped]) onSelect(names[clamped]);
1883
+ return;
1884
+ }
1885
+ if (key.upArrow) {
1886
+ setIdx((i) => Math.max(0, i - 1));
1887
+ return;
1888
+ }
1889
+ if (key.downArrow) {
1890
+ setIdx((i) => Math.min(names.length - 1, i + 1));
1891
+ return;
1892
+ }
1893
+ if (key.backspace || key.delete) {
1894
+ setQuery((q) => q.slice(0, -1));
1895
+ setIdx(0);
1896
+ return;
1897
+ }
1898
+ if (input && !key.ctrl && !key.meta && input.length === 1) {
1899
+ setQuery((q) => q + input);
1900
+ setIdx(0);
1901
+ }
1499
1902
  }, { isActive: process.stdin.isTTY ?? false });
1500
1903
  return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
1501
1904
  /* @__PURE__ */ jsxs4(Text4, { bold: true, color: "cyan", children: [
1502
1905
  " ",
1503
1906
  title,
1504
- " "
1907
+ " ",
1908
+ query && /* @__PURE__ */ jsxs4(Text4, { color: "yellow", children: [
1909
+ "[",
1910
+ query,
1911
+ "]"
1912
+ ] })
1505
1913
  ] }),
1506
- names.map((name, i) => /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs4(Text4, { color: i === idx ? "cyan" : void 0, inverse: i === idx, children: [
1914
+ 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
1915
  " ",
1508
1916
  name,
1509
1917
  " :",
1510
1918
  services.get(name).svc.port,
1511
1919
  " "
1512
1920
  ] }) }, name)),
1513
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u2191\u2193 navigate Enter select Esc close" })
1921
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "type to filter \u2191\u2193 navigate Enter select Esc clear/close" })
1514
1922
  ] });
1515
1923
  }
1516
1924
 
1517
1925
  // src/tui/SearchInput.tsx
1518
- import { useState as useState4 } from "react";
1926
+ import { useState as useState5 } from "react";
1519
1927
  import { Box as Box5, Text as Text5, useInput as useInput3 } from "ink";
1520
1928
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1521
1929
  function SearchInput({ onSubmit, onClose }) {
1522
- const [value, setValue] = useState4("");
1930
+ const [value, setValue] = useState5("");
1523
1931
  useInput3((input, key) => {
1524
1932
  if (key.escape) onClose();
1525
1933
  else if (key.return) onSubmit(value.trim() || null);
@@ -1647,8 +2055,8 @@ function createLazyProxy(opts) {
1647
2055
  }
1648
2056
 
1649
2057
  // src/process/external.ts
1650
- import { spawn as spawn3 } from "child_process";
1651
- import { join as join4 } from "path";
2058
+ import { spawn as spawn4 } from "child_process";
2059
+ import { join as join5 } from "path";
1652
2060
  var DEFAULT_START_TIMEOUT_S = 60;
1653
2061
  async function startExternals(externals, opts) {
1654
2062
  const procs = [];
@@ -1681,16 +2089,16 @@ async function stopExternals(procs, platform, opts = {}) {
1681
2089
  if (pid) platform.killTree(pid);
1682
2090
  if (svc.stopCmd) {
1683
2091
  opts.onLog?.(svc.name, `\u{1F9F9} ${svc.stopCmd}`);
1684
- await new Promise((resolve3) => {
2092
+ await new Promise((resolve4) => {
1685
2093
  const isWin = process.platform === "win32";
1686
2094
  const shell = isWin ? "cmd.exe" : "sh";
1687
2095
  const flag = isWin ? "/c" : "-c";
1688
- const cwd = svc.cwd ? join4(opts.baseCwd, svc.cwd) : opts.baseCwd;
2096
+ const cwd = svc.cwd ? join5(opts.baseCwd, svc.cwd) : opts.baseCwd;
1689
2097
  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);
2098
+ const child = spawn4(shell, [flag, svc.stopCmd], { cwd, env, stdio: "ignore" });
2099
+ child.on("close", () => resolve4());
2100
+ child.on("error", () => resolve4());
2101
+ setTimeout(() => resolve4(), 1e4);
1694
2102
  });
1695
2103
  }
1696
2104
  } catch {
@@ -1702,10 +2110,10 @@ function spawnExternal(svc, opts) {
1702
2110
  const isWin = process.platform === "win32";
1703
2111
  const shell = isWin ? "cmd.exe" : "sh";
1704
2112
  const flag = isWin ? "/c" : "-c";
1705
- const cwd = svc.cwd ? join4(opts.baseCwd, svc.cwd) : opts.baseCwd;
2113
+ const cwd = svc.cwd ? join5(opts.baseCwd, svc.cwd) : opts.baseCwd;
1706
2114
  const env = { ...opts.env, ...svc.extraEnv ?? {} };
1707
2115
  opts.onLog?.(svc.name, `\u{1F680} ${svc.cmd}`);
1708
- const child = spawn3(shell, [flag, svc.cmd], {
2116
+ const child = spawn4(shell, [flag, svc.cmd], {
1709
2117
  cwd,
1710
2118
  env,
1711
2119
  detached: true,
@@ -1726,11 +2134,36 @@ async function waitHealthy(svc, timeoutMs) {
1726
2134
  return false;
1727
2135
  }
1728
2136
 
2137
+ // src/tui/tips.ts
2138
+ function pickTip(state) {
2139
+ if (state.crashLoopedCount > 0 && !state.shown.has("crashed")) {
2140
+ return { id: "crashed", message: "tip: press r to restart, or check the log of the failing service" };
2141
+ }
2142
+ if (state.totalLogs > 1e3 && !state.hasSearch && !state.shown.has("search")) {
2143
+ return { id: "search", message: "tip: press / to search in logs" };
2144
+ }
2145
+ if (state.totalLogs > 500 && !state.hasFilter && !state.shown.has("filter")) {
2146
+ return { id: "filter", message: "tip: press f to filter logs by service" };
2147
+ }
2148
+ return null;
2149
+ }
2150
+
1729
2151
  // src/tui/App.tsx
1730
2152
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
2153
+ function buildServiceUrl(name, port, proxyActive, proxyOpts) {
2154
+ if (proxyActive && proxyOpts) {
2155
+ const sub = proxyOpts.routes[name];
2156
+ if (sub !== void 0) {
2157
+ const host = sub ? `${sub}.${proxyOpts.domain}` : proxyOpts.domain;
2158
+ const scheme = proxyOpts.tls ? "https" : "http";
2159
+ return `${scheme}://${host}`;
2160
+ }
2161
+ }
2162
+ return `http://localhost:${port}`;
2163
+ }
1731
2164
  function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts, logSink }) {
1732
2165
  const { stdout } = useStdout();
1733
- const [rows, setRows] = useState5(stdout?.rows ?? 40);
2166
+ const [rows, setRows] = useState6(stdout?.rows ?? 40);
1734
2167
  useEffect5(() => {
1735
2168
  if (!stdout) return;
1736
2169
  const onResize = () => setRows(stdout.rows ?? 40);
@@ -1743,9 +2176,11 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1743
2176
  const statsHeight = rows - logsHeight - 2;
1744
2177
  const maxNameLen = Math.max(...services.map((s) => s.name.length), 10);
1745
2178
  const pm = useProcessManager(platform, baseCwd, env, logSink);
1746
- const [booted, setBooted] = useState5(false);
2179
+ const [booted, setBooted] = useState6(false);
1747
2180
  const lazyProxies = useRef3(/* @__PURE__ */ new Map());
1748
2181
  const externals = useRef3([]);
2182
+ const shownTips = useRef3(/* @__PURE__ */ new Set());
2183
+ const [activeTip, setActiveTip] = useState6(null);
1749
2184
  const kb = useKeyBindings({
1750
2185
  onQuit: () => {
1751
2186
  void shutdown();
@@ -1771,6 +2206,21 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1771
2206
  useEffect5(() => {
1772
2207
  pm.setPaused(kb.logsPaused || kb.logsScrollOffset > 0);
1773
2208
  }, [kb.logsPaused, kb.logsScrollOffset, pm]);
2209
+ useEffect5(() => {
2210
+ const tip = pickTip({
2211
+ totalLogs: pm.logs.length,
2212
+ hasSearch: !!kb.searchTerm,
2213
+ hasFilter: !!kb.logFilter,
2214
+ crashLoopedCount: [...pm.states.values()].filter(isCrashLooped).length,
2215
+ shown: shownTips.current
2216
+ });
2217
+ if (tip && tip.id !== activeTip) {
2218
+ shownTips.current.add(tip.id);
2219
+ setActiveTip(tip.message);
2220
+ const timer = setTimeout(() => setActiveTip(null), 12e3);
2221
+ return () => clearTimeout(timer);
2222
+ }
2223
+ }, [pm.logs.length, pm.states, kb.searchTerm, kb.logFilter, activeTip]);
1774
2224
  useProxySync(proxyProvider, proxyOpts, pm.states, kb.proxyEnabled);
1775
2225
  useEffect5(() => {
1776
2226
  if (booted || !pm.manager) return;
@@ -1885,23 +2335,32 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1885
2335
  }, [pm, kb]);
1886
2336
  const handleOpenSelect = useCallback3((name) => {
1887
2337
  const st = pm.states.get(name);
1888
- if (st) platform.openBrowser(`http://localhost:${st.svc.port}`);
2338
+ if (st) {
2339
+ const url = buildServiceUrl(name, st.svc.port, cliArgs.proxy, proxyOpts);
2340
+ platform.openBrowser(url);
2341
+ }
1889
2342
  kb.setModal("none");
1890
- }, [pm, platform, kb]);
2343
+ }, [pm, platform, kb, cliArgs.proxy, proxyOpts]);
1891
2344
  const icon = config.icon ?? "\u{1F4E6}";
1892
2345
  const modeLabel = cliArgs.lazy && config.lazy ? "lazy" : "normal";
1893
2346
  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
- ] }) }),
2347
+ /* @__PURE__ */ jsxs6(Box6, { children: [
2348
+ /* @__PURE__ */ jsxs6(Text6, { bold: true, color: "cyan", children: [
2349
+ " ",
2350
+ icon,
2351
+ " ",
2352
+ config.name,
2353
+ " \u2014 devup \u2014 ",
2354
+ services.length,
2355
+ " services (",
2356
+ modeLabel,
2357
+ ") "
2358
+ ] }),
2359
+ activeTip && /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
2360
+ " \xB7 ",
2361
+ activeTip
2362
+ ] })
2363
+ ] }),
1905
2364
  /* @__PURE__ */ jsx6(
1906
2365
  LogsPanel,
1907
2366
  {
@@ -1914,7 +2373,8 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1914
2373
  height: logsHeight,
1915
2374
  focused: kb.panel === "logs",
1916
2375
  scrollOffset: kb.logsScrollOffset,
1917
- resetScroll: kb.resetLogsScroll
2376
+ resetScroll: kb.resetLogsScroll,
2377
+ levelFilter: kb.levelFilter
1918
2378
  }
1919
2379
  ),
1920
2380
  /* @__PURE__ */ jsx6(
@@ -1927,7 +2387,8 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1927
2387
  height: statsHeight,
1928
2388
  focused: kb.panel === "stats",
1929
2389
  scrollOffset: kb.statsScrollOffset,
1930
- resetScroll: kb.resetStatsScroll
2390
+ resetScroll: kb.resetStatsScroll,
2391
+ verbose: kb.verboseStats
1931
2392
  }
1932
2393
  ),
1933
2394
  kb.modal === "filter" && /* @__PURE__ */ jsx6(ServiceList, { title: "Filter by service", services: pm.states, onSelect: handleFilterSelect, onClose: () => kb.setModal("none") }),
@@ -1939,23 +2400,23 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1939
2400
  }
1940
2401
 
1941
2402
  // 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";
2403
+ import { existsSync as existsSync10, mkdirSync as mkdirSync4, renameSync, createWriteStream } from "fs";
2404
+ import { join as join6, dirname as dirname5 } from "path";
2405
+ import { homedir as homedir2 } from "os";
1945
2406
  var LogSink = class {
1946
2407
  dir;
1947
2408
  rotateOnStart;
1948
2409
  streams = /* @__PURE__ */ new Map();
1949
2410
  seen = /* @__PURE__ */ new Set();
1950
2411
  constructor(opts) {
1951
- const root = opts.rootDir ?? join5(homedir(), ".devup", "logs");
1952
- this.dir = join5(root, sanitize(opts.projectName));
2412
+ const root = opts.rootDir ?? join6(homedir2(), ".devup", "logs");
2413
+ this.dir = join6(root, sanitize2(opts.projectName));
1953
2414
  this.rotateOnStart = opts.rotateOnStart ?? true;
1954
2415
  mkdirSync4(this.dir, { recursive: true });
1955
2416
  }
1956
2417
  /** Returns the file path for a service log (useful for tests / UI). */
1957
2418
  pathFor(svcName) {
1958
- return join5(this.dir, `${sanitize(svcName)}.log`);
2419
+ return join6(this.dir, `${sanitize2(svcName)}.log`);
1959
2420
  }
1960
2421
  write(svcName, line) {
1961
2422
  const stream = this.streamFor(svcName);
@@ -1974,9 +2435,9 @@ var LogSink = class {
1974
2435
  let s = this.streams.get(svcName);
1975
2436
  if (s) return s;
1976
2437
  const file = this.pathFor(svcName);
1977
- if (this.rotateOnStart && !this.seen.has(svcName) && existsSync8(file)) {
2438
+ if (this.rotateOnStart && !this.seen.has(svcName) && existsSync10(file)) {
1978
2439
  try {
1979
- mkdirSync4(dirname4(file), { recursive: true });
2440
+ mkdirSync4(dirname5(file), { recursive: true });
1980
2441
  renameSync(file, file + ".prev");
1981
2442
  } catch {
1982
2443
  }
@@ -1989,7 +2450,7 @@ var LogSink = class {
1989
2450
  return s;
1990
2451
  }
1991
2452
  };
1992
- function sanitize(name) {
2453
+ function sanitize2(name) {
1993
2454
  return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
1994
2455
  }
1995
2456
 
@@ -2160,9 +2621,46 @@ function defineConfig(config) {
2160
2621
  }
2161
2622
 
2162
2623
  // src/index.ts
2624
+ function readVersion() {
2625
+ try {
2626
+ const here = dirname6(fileURLToPath2(import.meta.url));
2627
+ const pkgPath = join7(here, "..", "package.json");
2628
+ return JSON.parse(readFileSync2(pkgPath, "utf8")).version ?? "unknown";
2629
+ } catch {
2630
+ return "unknown";
2631
+ }
2632
+ }
2163
2633
  async function main() {
2634
+ const raw = process.argv.slice(2);
2635
+ if (raw.includes("-v") || raw.includes("--version")) {
2636
+ console.log(readVersion());
2637
+ return;
2638
+ }
2639
+ if (raw.includes("-h") || raw.includes("--help")) {
2640
+ console.log(USAGE);
2641
+ return;
2642
+ }
2643
+ const subcmd = detectSubcommand(raw);
2644
+ if (subcmd === "help") {
2645
+ process.exit(runHelp(raw.slice(1)));
2646
+ }
2164
2647
  const cwd = process.cwd();
2165
- const cliArgs = parseCliArgs(process.argv.slice(2));
2648
+ const cliArgs = parseCliArgs(raw);
2649
+ if (subcmd) {
2650
+ const subArgs = raw.slice(1);
2651
+ let cfgPath;
2652
+ try {
2653
+ cfgPath = findConfigFile(cwd, cliArgs.configPath);
2654
+ } catch (e) {
2655
+ console.error(`\u274C ${e.message}`);
2656
+ process.exit(1);
2657
+ }
2658
+ const cfg = await loadConfig(cfgPath);
2659
+ const subOpts = { config: cfg, baseCwd: cwd, env: process.env, logDir: cliArgs.logDir };
2660
+ if (subcmd === "logs") process.exit(await runLogs(subArgs, subOpts));
2661
+ if (subcmd === "install") process.exit(await runInstall(subOpts));
2662
+ if (subcmd === "status") process.exit(await runStatus(subOpts));
2663
+ }
2166
2664
  let configPath;
2167
2665
  try {
2168
2666
  configPath = findConfigFile(cwd, cliArgs.configPath);
@@ -2189,7 +2687,7 @@ ${formatValidationErrors(errors)}`);
2189
2687
  process.exit(1);
2190
2688
  }
2191
2689
  const platform = await detectPlatform();
2192
- const envFile = config.envFile ? join6(cwd, config.envFile) : join6(cwd, ".env");
2690
+ const envFile = config.envFile ? join7(cwd, config.envFile) : join7(cwd, ".env");
2193
2691
  const env = parseEnvFile(envFile, process.env);
2194
2692
  if (config.env) {
2195
2693
  for (const [k, v] of Object.entries(config.env)) {
@@ -2206,7 +2704,7 @@ ${formatValidationErrors(errors)}`);
2206
2704
  routes: config.proxy.routes,
2207
2705
  tls: cliArgs.proxyTls ?? config.proxy.tls ?? true,
2208
2706
  entrypoint: cliArgs.proxyEntrypoint ?? config.proxy.entrypoint ?? "websecure",
2209
- confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join6(homedir2(), ".traefik", "traefik_conf.yaml")
2707
+ confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join7(homedir3(), ".traefik", "traefik_conf.yaml")
2210
2708
  };
2211
2709
  }
2212
2710
  if (cliArgs.dryRun) {