@gachlab/devup 0.5.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +74 -454
  3. package/dist/config/cli.d.ts +2 -1
  4. package/dist/config/cli.d.ts.map +1 -1
  5. package/dist/config/diff.d.ts +19 -0
  6. package/dist/config/diff.d.ts.map +1 -0
  7. package/dist/config/validator.d.ts +9 -0
  8. package/dist/config/validator.d.ts.map +1 -1
  9. package/dist/control-plane/socket-server.d.ts +31 -0
  10. package/dist/control-plane/socket-server.d.ts.map +1 -0
  11. package/dist/index.js +1047 -525
  12. package/dist/index.js.map +1 -1
  13. package/dist/process/health-poller.d.ts +15 -0
  14. package/dist/process/health-poller.d.ts.map +1 -0
  15. package/dist/process/internals.d.ts +14 -0
  16. package/dist/process/internals.d.ts.map +1 -0
  17. package/dist/process/lifecycle.d.ts +31 -0
  18. package/dist/process/lifecycle.d.ts.map +1 -0
  19. package/dist/process/manager.d.ts +11 -13
  20. package/dist/process/manager.d.ts.map +1 -1
  21. package/dist/process/restarter.d.ts +26 -0
  22. package/dist/process/restarter.d.ts.map +1 -0
  23. package/dist/process/spawner.d.ts +38 -0
  24. package/dist/process/spawner.d.ts.map +1 -0
  25. package/dist/tui/App.d.ts.map +1 -1
  26. package/dist/tui/LogsPanel.d.ts +9 -1
  27. package/dist/tui/LogsPanel.d.ts.map +1 -1
  28. package/dist/tui/hooks/useBootSequence.d.ts +20 -0
  29. package/dist/tui/hooks/useBootSequence.d.ts.map +1 -0
  30. package/dist/tui/hooks/useContextualTips.d.ts +6 -0
  31. package/dist/tui/hooks/useContextualTips.d.ts.map +1 -0
  32. package/dist/tui/hooks/useControlPlane.d.ts +10 -0
  33. package/dist/tui/hooks/useControlPlane.d.ts.map +1 -0
  34. package/dist/tui/hooks/useHotReload.d.ts +7 -0
  35. package/dist/tui/hooks/useHotReload.d.ts.map +1 -0
  36. package/dist/tui/hooks/useLogsPause.d.ts +4 -0
  37. package/dist/tui/hooks/useLogsPause.d.ts.map +1 -0
  38. package/dist/tui/hooks/useTerminalSize.d.ts +4 -0
  39. package/dist/tui/hooks/useTerminalSize.d.ts.map +1 -0
  40. package/dist/utils/colors.d.ts +4 -0
  41. package/dist/utils/colors.d.ts.map +1 -0
  42. package/dist/utils/env.d.ts +5 -0
  43. package/dist/utils/env.d.ts.map +1 -0
  44. package/dist/utils/format.d.ts +3 -0
  45. package/dist/utils/format.d.ts.map +1 -0
  46. package/dist/utils/install-stamp.d.ts +6 -0
  47. package/dist/utils/install-stamp.d.ts.map +1 -0
  48. package/dist/utils/phases.d.ts +4 -0
  49. package/dist/utils/phases.d.ts.map +1 -0
  50. package/dist/utils/process-args.d.ts +8 -0
  51. package/dist/utils/process-args.d.ts.map +1 -0
  52. package/dist/utils/redact.d.ts +4 -0
  53. package/dist/utils/redact.d.ts.map +1 -0
  54. package/dist/utils/search.d.ts +17 -0
  55. package/dist/utils/search.d.ts.map +1 -0
  56. package/dist/utils/stats.d.ts +19 -0
  57. package/dist/utils/stats.d.ts.map +1 -0
  58. package/dist/utils.d.ts +10 -41
  59. package/dist/utils.d.ts.map +1 -1
  60. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3,10 +3,10 @@
3
3
  // src/index.ts
4
4
  import React7 from "react";
5
5
  import { render } from "ink";
6
- import { readFileSync as readFileSync2 } from "fs";
7
- import { dirname as dirname6, join as join7 } from "path";
6
+ import { readFileSync as readFileSync3 } from "fs";
7
+ import { dirname as dirname7, join as join9 } from "path";
8
8
  import { fileURLToPath as fileURLToPath2 } from "url";
9
- import { homedir as homedir3 } from "os";
9
+ import { homedir as homedir4 } from "os";
10
10
 
11
11
  // src/config/loader.ts
12
12
  import { existsSync } from "fs";
@@ -74,6 +74,26 @@ function rewriteServicePort(svc) {
74
74
  }
75
75
 
76
76
  // src/config/validator.ts
77
+ function collectWarnings(config) {
78
+ const warnings = [];
79
+ if (!config.services?.length) return warnings;
80
+ for (const svc of config.services) {
81
+ const ep = svc.extraEnv?.["PORT"];
82
+ if (ep !== void 0) {
83
+ const expected = String(svc.port);
84
+ if (ep !== expected) {
85
+ warnings.push({
86
+ field: `services[${svc.name}].extraEnv.PORT`,
87
+ message: `extraEnv.PORT="${ep}" does not match port=${svc.port}. devup will health-check :${svc.port} but the service will probably bind to :${ep}.`
88
+ });
89
+ }
90
+ }
91
+ }
92
+ return warnings;
93
+ }
94
+ function formatValidationWarnings(warnings) {
95
+ return warnings.map((w) => ` \u26A0 ${w.field}: ${w.message}`).join("\n");
96
+ }
77
97
  function validateConfig(config, cwd) {
78
98
  const errors = [];
79
99
  if (!config.name?.trim()) {
@@ -272,6 +292,10 @@ Log files:
272
292
  --no-log-file Disable persistent log files
273
293
  --log-dir <path> Override log root (default: ~/.devup/logs)
274
294
 
295
+ Hot reload:
296
+ --watch-config Watch devup.config.* and apply add/remove/restart
297
+ service changes without exiting the TUI
298
+
275
299
  Other:
276
300
  -h, --help Show this help and exit
277
301
  -v, --version Show version and exit
@@ -288,7 +312,8 @@ function parseCliArgs(argv) {
288
312
  dryRun: false,
289
313
  once: false,
290
314
  onceTimeout: DEFAULT_ONCE_TIMEOUT,
291
- logFile: true
315
+ logFile: true,
316
+ watchConfig: false
292
317
  };
293
318
  for (let i = 0; i < argv.length; i++) {
294
319
  const arg = argv[i];
@@ -362,6 +387,9 @@ function parseCliArgs(argv) {
362
387
  args.logDir = next;
363
388
  i++;
364
389
  break;
390
+ case "--watch-config":
391
+ args.watchConfig = true;
392
+ break;
365
393
  }
366
394
  }
367
395
  return args;
@@ -402,7 +430,7 @@ function filterServices(services, args, config) {
402
430
 
403
431
  // src/orchestrator/subcommands.ts
404
432
  import { spawn } from "child_process";
405
- import { createReadStream, watchFile, unwatchFile, existsSync as existsSync4, statSync } from "fs";
433
+ import { createReadStream, watchFile, unwatchFile, existsSync as existsSync5, statSync } from "fs";
406
434
  import { readFile as readFile2 } from "fs/promises";
407
435
  import { join as join3, dirname } from "path";
408
436
  import { fileURLToPath } from "url";
@@ -484,10 +512,8 @@ function deriveHealth(isUp, currentStatus) {
484
512
  return currentStatus === "starting" ? "wait" : "down";
485
513
  }
486
514
 
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";
515
+ // src/utils/env.ts
516
+ import { existsSync as existsSync3, readFileSync } from "fs";
491
517
  function parseEnvFile(filePath, baseEnv = {}) {
492
518
  const env = { ...baseEnv };
493
519
  if (!existsSync3(filePath)) return env;
@@ -505,25 +531,21 @@ function parseEnvFile(filePath, baseEnv = {}) {
505
531
  }
506
532
  return env;
507
533
  }
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";
534
+
535
+ // src/utils/format.ts
536
+ function fmtUptime(ms) {
537
+ if (!ms || ms < 0) return "-";
538
+ const s = Math.floor(ms / 1e3);
539
+ if (s < 60) return `${s}s`;
540
+ const m = Math.floor(s / 60);
541
+ if (m < 60) return `${m}m${s % 60}s`;
542
+ const h = Math.floor(m / 60);
543
+ if (h < 24) return `${h}h${m % 60}m`;
544
+ const d = Math.floor(h / 24);
545
+ return `${d}d${h % 24}h`;
526
546
  }
547
+
548
+ // src/utils/search.ts
527
549
  function compileSearchPattern(term) {
528
550
  if (!term) return null;
529
551
  const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(term);
@@ -540,51 +562,47 @@ function compileSearchPattern(term) {
540
562
  const lower = term.toLowerCase();
541
563
  return { test: (l) => l.toLowerCase().includes(lower) };
542
564
  }
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`;
565
+ function detectLogLevel(line) {
566
+ const l = line.toLowerCase();
567
+ if (/\b(?:error|err|fail(?:ed|ure|ures|s)?|fatal|exception|crash(?:ed|es)?)\b/.test(l) || /❌|✗|⛔/.test(line)) return "error";
568
+ if (/\b(?:warn(?:ed|ing|s|ings)?|deprec)\b/.test(l) || /⚠/.test(line)) return "warn";
569
+ return "info";
570
+ }
571
+
572
+ // src/utils/redact.ts
573
+ function redactSecrets(env) {
574
+ if (!env) return {};
575
+ const out = {};
576
+ for (const [k, v] of Object.entries(env)) {
577
+ out[k] = /secret|token|password|api[_-]?key|auth/i.test(k) ? "***" : v;
578
+ }
579
+ return out;
553
580
  }
581
+
582
+ // src/utils/install-stamp.ts
583
+ import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync } from "fs";
584
+ import { createHash } from "crypto";
585
+ import { join as join2 } from "path";
554
586
  function needsInstall(fullCwd) {
555
587
  const nm = join2(fullCwd, "node_modules");
556
- if (!existsSync3(nm)) return true;
588
+ if (!existsSync4(nm)) return true;
557
589
  try {
558
- const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
590
+ const pkgHash = createHash("md5").update(readFileSync2(join2(fullCwd, "package.json"))).digest("hex");
559
591
  const stampFile = join2(nm, ".install-stamp");
560
- if (existsSync3(stampFile) && readFileSync(stampFile, "utf8") === pkgHash) return false;
592
+ if (existsSync4(stampFile) && readFileSync2(stampFile, "utf8") === pkgHash) return false;
561
593
  } catch {
562
594
  }
563
595
  return true;
564
596
  }
565
597
  function writeInstallStamp(fullCwd) {
566
598
  try {
567
- const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
599
+ const pkgHash = createHash("md5").update(readFileSync2(join2(fullCwd, "package.json"))).digest("hex");
568
600
  writeFileSync(join2(fullCwd, "node_modules", ".install-stamp"), pkgHash);
569
601
  } catch {
570
602
  }
571
603
  }
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
- }
604
+
605
+ // src/utils/process-args.ts
588
606
  function buildProcessArgs(svc) {
589
607
  const extra = svc.nodeArgs ?? [];
590
608
  if (!svc.maxMem) return [...extra, ...svc.args];
@@ -602,11 +620,38 @@ function buildProcessEnv(svc, baseEnv) {
602
620
  }
603
621
  return env;
604
622
  }
623
+
624
+ // src/utils/stats.ts
625
+ function sortServiceNames(names, sortMode, statsMap, procState) {
626
+ if (sortMode === "name") return names.slice().sort();
627
+ return names.slice().sort((a, b) => {
628
+ if (sortMode === "mem") {
629
+ return (parseFloat(statsMap[b]?.mem ?? "0") || 0) - (parseFloat(statsMap[a]?.mem ?? "0") || 0);
630
+ }
631
+ return (procState[b]?.errors ?? 0) - (procState[a]?.errors ?? 0);
632
+ });
633
+ }
605
634
  function calcCpuPercent(totalCpuSec, prevCpu, prevTime) {
606
635
  const elapsed = (Date.now() - prevTime) / 1e3;
607
636
  const cpuDelta = totalCpuSec - prevCpu;
608
637
  return elapsed > 0 ? cpuDelta / elapsed * 100 : 0;
609
638
  }
639
+ function nextRamBannerVisibility(usagePct, previousVisible, highWatermark = 80, lowWatermark = 75) {
640
+ if (usagePct >= highWatermark) return true;
641
+ if (usagePct < lowWatermark) return false;
642
+ return previousVisible;
643
+ }
644
+
645
+ // src/utils/phases.ts
646
+ function groupByPhase(services) {
647
+ const phases = {};
648
+ for (const s of services) {
649
+ (phases[s.phase] ??= []).push(s);
650
+ }
651
+ return phases;
652
+ }
653
+
654
+ // src/utils/colors.ts
610
655
  var tagColors = [
611
656
  "cyan",
612
657
  "yellow",
@@ -650,7 +695,7 @@ async function runLogs(argv, opts) {
650
695
  return 1;
651
696
  }
652
697
  const file = join3(logRoot(opts.config, opts.logDir), `${sanitize(svcArg)}.log`);
653
- if (!existsSync4(file)) {
698
+ if (!existsSync5(file)) {
654
699
  out(`No log file yet for "${svcArg}" (${file})`);
655
700
  return follow ? await followFile(file, out) : 1;
656
701
  }
@@ -668,7 +713,7 @@ async function streamFile(file, out) {
668
713
  }
669
714
  async function followFile(file, out, startAt = 0) {
670
715
  let pos = startAt;
671
- while (!existsSync4(file)) await new Promise((r) => setTimeout(r, 500));
716
+ while (!existsSync5(file)) await new Promise((r) => setTimeout(r, 500));
672
717
  return new Promise((resolve4) => {
673
718
  const tick = async () => {
674
719
  const size = statSync(file).size;
@@ -730,7 +775,7 @@ ${items.length} services up to date`);
730
775
  return 0;
731
776
  }
732
777
  function installOne(cwd, env) {
733
- if (!existsSync4(cwd)) return Promise.resolve(false);
778
+ if (!existsSync5(cwd)) return Promise.resolve(false);
734
779
  if (!needsInstall(cwd)) return Promise.resolve(true);
735
780
  return new Promise((resolve4) => {
736
781
  const command = process.platform === "win32" ? "npm.cmd" : "npm";
@@ -809,7 +854,7 @@ async function detectPlatform() {
809
854
  }
810
855
 
811
856
  // src/proxy-config/traefik.ts
812
- import { existsSync as existsSync5, mkdirSync, writeFileSync as writeFileSync2 } from "fs";
857
+ import { existsSync as existsSync6, mkdirSync, writeFileSync as writeFileSync2 } from "fs";
813
858
  import { dirname as dirname2 } from "path";
814
859
  var EMPTY_CONFIG = "http:\n routers: {}\n services: {}\n";
815
860
  var TraefikProvider = class {
@@ -848,7 +893,7 @@ ${svcs.join("\n")}
848
893
  }
849
894
  write(content, opts) {
850
895
  const dir = dirname2(opts.confPath);
851
- if (!existsSync5(dir)) mkdirSync(dir, { recursive: true });
896
+ if (!existsSync6(dir)) mkdirSync(dir, { recursive: true });
852
897
  writeFileSync2(opts.confPath, content);
853
898
  }
854
899
  clear(opts) {
@@ -857,7 +902,7 @@ ${svcs.join("\n")}
857
902
  };
858
903
 
859
904
  // src/proxy-config/nginx.ts
860
- import { existsSync as existsSync6, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
905
+ import { existsSync as existsSync7, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
861
906
  import { dirname as dirname3 } from "path";
862
907
  var EMPTY_CONFIG2 = "# devup: no healthy services\n";
863
908
  var NginxProvider = class {
@@ -896,7 +941,7 @@ var NginxProvider = class {
896
941
  }
897
942
  write(content, opts) {
898
943
  const dir = dirname3(opts.confPath);
899
- if (!existsSync6(dir)) mkdirSync2(dir, { recursive: true });
944
+ if (!existsSync7(dir)) mkdirSync2(dir, { recursive: true });
900
945
  writeFileSync3(opts.confPath, content);
901
946
  }
902
947
  clear(opts) {
@@ -905,7 +950,7 @@ var NginxProvider = class {
905
950
  };
906
951
 
907
952
  // src/proxy-config/caddy.ts
908
- import { existsSync as existsSync7, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
953
+ import { existsSync as existsSync8, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
909
954
  import { dirname as dirname4 } from "path";
910
955
  var EMPTY_CONFIG3 = "# devup: no healthy services\n";
911
956
  var CaddyProvider = class {
@@ -930,7 +975,7 @@ var CaddyProvider = class {
930
975
  }
931
976
  write(content, opts) {
932
977
  const dir = dirname4(opts.confPath);
933
- if (!existsSync7(dir)) mkdirSync3(dir, { recursive: true });
978
+ if (!existsSync8(dir)) mkdirSync3(dir, { recursive: true });
934
979
  writeFileSync4(opts.confPath, content);
935
980
  }
936
981
  clear(opts) {
@@ -954,22 +999,20 @@ function detectProxyProvider(name) {
954
999
  }
955
1000
 
956
1001
  // src/tui/App.tsx
957
- import { useEffect as useEffect5, useState as useState6, useCallback as useCallback3, useRef as useRef3 } from "react";
958
- import { Box as Box6, Text as Text6, useStdout } from "ink";
1002
+ import { useCallback as useCallback3, useRef as useRef5 } from "react";
1003
+ import { Box as Box6, Text as Text6 } from "ink";
959
1004
 
960
1005
  // src/tui/hooks/useProcessManager.ts
961
1006
  import { useState, useEffect, useRef, useCallback } from "react";
962
1007
 
963
1008
  // src/process/manager.ts
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";
1009
+ import { join as join5 } from "path";
967
1010
 
968
1011
  // src/process/installer.ts
969
1012
  import { spawn as spawn2 } from "child_process";
970
- import { existsSync as existsSync8 } from "fs";
1013
+ import { existsSync as existsSync9 } from "fs";
971
1014
  function installService(cwd, env, onLog) {
972
- if (!existsSync8(cwd)) {
1015
+ if (!existsSync9(cwd)) {
973
1016
  onLog?.(`\u26A0 directory not found: ${cwd}`);
974
1017
  return Promise.resolve(false);
975
1018
  }
@@ -1002,9 +1045,32 @@ function installService(cwd, env, onLog) {
1002
1045
  });
1003
1046
  }
1004
1047
 
1005
- // src/process/manager.ts
1006
- var MAX_RESTARTS = 3;
1007
- var BACKOFF_BASE_MS = 2e3;
1048
+ // src/process/spawner.ts
1049
+ import { spawn as spawn3 } from "child_process";
1050
+ import { existsSync as existsSync10 } from "fs";
1051
+ import { join as join4, resolve as resolve3 } from "path";
1052
+
1053
+ // src/process/internals.ts
1054
+ function lineBuffer(onLine) {
1055
+ let buf = "";
1056
+ return {
1057
+ push(chunk) {
1058
+ buf += chunk.toString();
1059
+ let idx;
1060
+ while ((idx = buf.indexOf("\n")) !== -1) {
1061
+ const line = buf.slice(0, idx).replace(/\r$/, "");
1062
+ buf = buf.slice(idx + 1);
1063
+ if (line.length) onLine(line);
1064
+ }
1065
+ },
1066
+ flush() {
1067
+ if (buf.length) {
1068
+ onLine(buf);
1069
+ buf = "";
1070
+ }
1071
+ }
1072
+ };
1073
+ }
1008
1074
  function compileReadyPattern(pattern) {
1009
1075
  if (!pattern) return null;
1010
1076
  const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(pattern);
@@ -1035,43 +1101,26 @@ function extractWatchPaths(args) {
1035
1101
  }
1036
1102
  return out;
1037
1103
  }
1038
- function lineBuffer(onLine) {
1039
- let buf = "";
1040
- return {
1041
- push(chunk) {
1042
- buf += chunk.toString();
1043
- let idx;
1044
- while ((idx = buf.indexOf("\n")) !== -1) {
1045
- const line = buf.slice(0, idx).replace(/\r$/, "");
1046
- buf = buf.slice(idx + 1);
1047
- if (line.length) onLine(line);
1048
- }
1049
- },
1050
- flush() {
1051
- if (buf.length) {
1052
- onLine(buf);
1053
- buf = "";
1054
- }
1055
- }
1056
- };
1057
- }
1058
- var ProcessManager = class {
1059
- state = /* @__PURE__ */ new Map();
1060
- procs = /* @__PURE__ */ new Set();
1104
+ var MAX_RESTARTS = 3;
1105
+ var BACKOFF_BASE_MS = 2e3;
1106
+
1107
+ // src/process/spawner.ts
1108
+ var Spawner = class {
1061
1109
  baseCwd;
1062
1110
  env;
1063
- platform;
1111
+ state;
1112
+ procs;
1064
1113
  events;
1114
+ lifecycle;
1115
+ onCrash;
1065
1116
  constructor(opts) {
1066
1117
  this.baseCwd = opts.baseCwd;
1067
1118
  this.env = opts.env;
1068
- this.platform = opts.platform;
1119
+ this.state = opts.state;
1120
+ this.procs = opts.procs;
1069
1121
  this.events = opts.events;
1070
- }
1071
- async install(svc, colorIdx) {
1072
- const cwd = join4(this.baseCwd, svc.cwd);
1073
- const idx = colorIdx ?? this.state.get(svc.name)?.colorIdx ?? 0;
1074
- return installService(cwd, this.env, (msg) => this.log(svc.name, msg, idx));
1122
+ this.lifecycle = opts.lifecycle;
1123
+ this.onCrash = opts.onCrash;
1075
1124
  }
1076
1125
  async start(svc, colorIdx, isRestart = false) {
1077
1126
  const cwd = join4(this.baseCwd, svc.cwd);
@@ -1090,7 +1139,7 @@ var ProcessManager = class {
1090
1139
  }
1091
1140
  }
1092
1141
  const args = buildProcessArgs(svc);
1093
- const missingWatchPaths = extractWatchPaths(args).filter((p) => !existsSync9(resolve3(cwd, p)));
1142
+ const missingWatchPaths = extractWatchPaths(args).filter((p) => !existsSync10(resolve3(cwd, p)));
1094
1143
  if (missingWatchPaths.length) {
1095
1144
  this.log(svc.name, `\u26A0 missing watch paths: ${missingWatchPaths.join(", ")}`, colorIdx);
1096
1145
  this.recordCrashedState(svc, colorIdx);
@@ -1114,6 +1163,14 @@ var ProcessManager = class {
1114
1163
  this.state.set(svc.name, state);
1115
1164
  this.procs.add(proc);
1116
1165
  this.events.onStateChange(svc.name, state);
1166
+ this.wireStdio(proc, svc, state, colorIdx);
1167
+ this.wireCloseHandler(proc, svc, state, colorIdx);
1168
+ if (svc.watchBuild) {
1169
+ state.watchProc = this.spawnWatchBuild(svc, cwd, env, colorIdx);
1170
+ }
1171
+ this.log(svc.name, isRestart ? `\u{1F504} restarted (:${svc.port})` : `\u{1F680} started (:${svc.port})`, colorIdx);
1172
+ }
1173
+ wireStdio(proc, svc, state, colorIdx) {
1117
1174
  const readyRegex = compileReadyPattern(svc.readyPattern);
1118
1175
  const markReadyIfMatch = (line) => {
1119
1176
  if (!readyRegex || state.health === "up") return;
@@ -1138,9 +1195,11 @@ var ProcessManager = class {
1138
1195
  proc.stderr?.on("data", (d) => stderrBuf.push(d));
1139
1196
  proc.stdout?.on("end", () => stdoutBuf.flush());
1140
1197
  proc.stderr?.on("end", () => stderrBuf.flush());
1198
+ }
1199
+ wireCloseHandler(proc, svc, state, colorIdx) {
1141
1200
  proc.on("close", (code) => {
1142
1201
  this.procs.delete(proc);
1143
- this.stopWatchProc(state);
1202
+ this.lifecycle.stopWatchProc(state);
1144
1203
  if (state.intentionalStop) {
1145
1204
  state.intentionalStop = false;
1146
1205
  return;
@@ -1155,23 +1214,12 @@ var ProcessManager = class {
1155
1214
  state.health = "down";
1156
1215
  this.log(svc.name, `\u274C exited with code ${code}`, colorIdx);
1157
1216
  this.events.onStateChange(svc.name, state);
1158
- if (state.restarts < MAX_RESTARTS) {
1159
- state.restarts++;
1160
- const delay = BACKOFF_BASE_MS * Math.pow(2, state.restarts - 1);
1161
- this.log(svc.name, `\u{1F504} auto-restart ${state.restarts}/${MAX_RESTARTS} in ${delay}ms...`, colorIdx);
1162
- setTimeout(() => this.start(svc, colorIdx, true), delay);
1163
- } else {
1164
- this.log(svc.name, "\u26D4 max restarts reached", colorIdx);
1165
- }
1217
+ this.onCrash(svc, state, colorIdx);
1166
1218
  });
1167
- if (svc.watchBuild) {
1168
- state.watchProc = this.spawnWatchBuild(svc, cwd, env, colorIdx);
1169
- }
1170
- this.log(svc.name, isRestart ? `\u{1F504} restarted (:${svc.port})` : `\u{1F680} started (:${svc.port})`, colorIdx);
1171
1219
  }
1172
1220
  runPreBuild(svc, cwd, colorIdx) {
1173
1221
  this.log(svc.name, `\u{1F528} preBuild: ${svc.preBuild}`, colorIdx);
1174
- return new Promise((resolve4) => {
1222
+ return new Promise((res) => {
1175
1223
  const isWin = process.platform === "win32";
1176
1224
  const shell = isWin ? "cmd.exe" : "sh";
1177
1225
  const shellFlag = isWin ? "/c" : "-c";
@@ -1183,17 +1231,17 @@ var ProcessManager = class {
1183
1231
  child.stderr?.on("data", (d) => errBuf.push(d));
1184
1232
  child.on("error", (err) => {
1185
1233
  this.log(svc.name, `[build] \u274C ${err.message}`, colorIdx);
1186
- resolve4(false);
1234
+ res(false);
1187
1235
  });
1188
1236
  child.on("close", (code) => {
1189
1237
  outBuf.flush();
1190
1238
  errBuf.flush();
1191
1239
  if (code === 0) {
1192
1240
  this.log(svc.name, `[build] \u2705 done`, colorIdx);
1193
- resolve4(true);
1241
+ res(true);
1194
1242
  } else {
1195
1243
  this.log(svc.name, `[build] \u274C exited with code ${code}`, colorIdx);
1196
- resolve4(false);
1244
+ res(false);
1197
1245
  }
1198
1246
  });
1199
1247
  });
@@ -1216,7 +1264,8 @@ var ProcessManager = class {
1216
1264
  child.on("error", (err) => this.log(svc.name, `[watch] \u274C ${err.message}`, colorIdx));
1217
1265
  return child;
1218
1266
  }
1219
- /** Create a state entry in 'crashed' status without spawning a process (used when preBuild fails). */
1267
+ /** Create a state entry in 'crashed' status without spawning a process
1268
+ * (used when preBuild fails or pre-flight checks fail). */
1220
1269
  recordCrashedState(svc, colorIdx) {
1221
1270
  const prev = this.state.get(svc.name);
1222
1271
  this.state.set(svc.name, {
@@ -1233,33 +1282,54 @@ var ProcessManager = class {
1233
1282
  });
1234
1283
  this.events.onStateChange(svc.name, this.state.get(svc.name));
1235
1284
  }
1236
- stop(name) {
1237
- const st = this.state.get(name);
1238
- if (!st?.proc || !st.pid) return;
1239
- st.intentionalStop = true;
1240
- this.platform.killTree(st.pid);
1241
- this.stopWatchProc(st);
1285
+ log(name, text, colorIdx) {
1286
+ this.events.onLog(name, text, colorIdx);
1242
1287
  }
1243
- stopWatchProc(state) {
1244
- const wp = state.watchProc;
1245
- if (!wp || !wp.pid) return;
1246
- try {
1247
- this.platform.killTree(wp.pid);
1248
- } catch {
1249
- }
1250
- state.watchProc = null;
1288
+ };
1289
+
1290
+ // src/process/restarter.ts
1291
+ var Restarter = class {
1292
+ state;
1293
+ events;
1294
+ spawner;
1295
+ lifecycle;
1296
+ constructor(opts) {
1297
+ this.state = opts.state;
1298
+ this.events = opts.events;
1299
+ this.spawner = opts.spawner;
1300
+ this.lifecycle = opts.lifecycle;
1251
1301
  }
1252
1302
  async restart(name) {
1253
1303
  const st = this.state.get(name);
1254
1304
  if (!st) return;
1255
- this.stop(name);
1305
+ this.lifecycle.stop(name);
1256
1306
  st.restarts = 0;
1257
1307
  const delay = st.proc ? 1500 : 100;
1258
1308
  await new Promise((r) => setTimeout(r, delay));
1259
- await this.start(st.svc, st.colorIdx, true);
1260
- this.log(name, "\u{1F504} manual restart", st.colorIdx);
1309
+ await this.spawner.start(st.svc, st.colorIdx, true);
1310
+ this.events.onLog(name, "\u{1F504} manual restart", st.colorIdx);
1311
+ }
1312
+ scheduleAutoRestart(svc, state, colorIdx) {
1313
+ if (state.restarts >= MAX_RESTARTS) {
1314
+ this.events.onLog(svc.name, "\u26D4 max restarts reached", colorIdx);
1315
+ return;
1316
+ }
1317
+ state.restarts++;
1318
+ const delay = BACKOFF_BASE_MS * Math.pow(2, state.restarts - 1);
1319
+ this.events.onLog(svc.name, `\u{1F504} auto-restart ${state.restarts}/${MAX_RESTARTS} in ${delay}ms...`, colorIdx);
1320
+ setTimeout(() => void this.spawner.start(svc, colorIdx, true), delay);
1321
+ }
1322
+ };
1323
+
1324
+ // src/process/health-poller.ts
1325
+ var HealthPoller = class {
1326
+ state;
1327
+ events;
1328
+ constructor(opts) {
1329
+ this.state = opts.state;
1330
+ this.events = opts.events;
1261
1331
  }
1262
- async checkAllHealth() {
1332
+ async checkAll() {
1263
1333
  for (const [name, st] of this.state) {
1264
1334
  if (!st.pid || st.status === "idle") {
1265
1335
  st.health = st.status === "idle" ? "idle" : "down";
@@ -1276,6 +1346,41 @@ var ProcessManager = class {
1276
1346
  if (prev !== st.health) this.events.onStateChange(name, st);
1277
1347
  }
1278
1348
  }
1349
+ };
1350
+
1351
+ // src/process/lifecycle.ts
1352
+ var Lifecycle = class {
1353
+ state;
1354
+ procs;
1355
+ platform;
1356
+ constructor(opts) {
1357
+ this.state = opts.state;
1358
+ this.procs = opts.procs;
1359
+ this.platform = opts.platform;
1360
+ }
1361
+ /** Manual / external stop of a single service. Marks `intentionalStop` so the
1362
+ * close handler doesn't auto-restart, kills the process tree, tears down the
1363
+ * side-car watchBuild process if any. */
1364
+ stop(name) {
1365
+ const st = this.state.get(name);
1366
+ if (!st?.proc || !st.pid) return;
1367
+ st.intentionalStop = true;
1368
+ this.platform.killTree(st.pid);
1369
+ this.stopWatchProc(st);
1370
+ }
1371
+ /** Tears down the side-car `watchBuild` process for a service (if any) and
1372
+ * clears the reference. Safe to call repeatedly. */
1373
+ stopWatchProc(state) {
1374
+ const wp = state.watchProc;
1375
+ if (!wp || !wp.pid) return;
1376
+ try {
1377
+ this.platform.killTree(wp.pid);
1378
+ } catch {
1379
+ }
1380
+ state.watchProc = null;
1381
+ }
1382
+ /** Graceful shutdown of every spawned process. Waits `gracePeriodMs` (default
1383
+ * 3000) for clean exits, then SIGKILLs anything still alive. */
1279
1384
  async cleanup(opts = {}) {
1280
1385
  const grace = opts.gracePeriodMs ?? 3e3;
1281
1386
  const procs = [...this.procs];
@@ -1316,27 +1421,88 @@ var ProcessManager = class {
1316
1421
  for (const st of this.state.values()) if (st.proc === proc) return st;
1317
1422
  return void 0;
1318
1423
  }
1319
- log(name, text, colorIdx) {
1320
- this.events.onLog(name, text, colorIdx);
1321
- }
1322
1424
  };
1323
1425
 
1324
- // src/tui/hooks/useProcessManager.ts
1325
- function useProcessManager(platform, baseCwd, env, logSink = null) {
1326
- const [states, setStates] = useState(/* @__PURE__ */ new Map());
1327
- const [logs, setLogs] = useState([]);
1328
- const [stats, setStats] = useState(/* @__PURE__ */ new Map());
1329
- const mgrRef = useRef(null);
1330
- const prevCpu = useRef(/* @__PURE__ */ new Map());
1331
- const pausedRef = useRef(false);
1332
- const pendingLogsRef = useRef([]);
1333
- const sinkRef = useRef(logSink);
1334
- sinkRef.current = logSink;
1335
- useEffect(() => {
1336
- const mgr2 = new ProcessManager({
1337
- baseCwd,
1338
- env,
1339
- platform,
1426
+ // src/process/manager.ts
1427
+ var ProcessManager = class {
1428
+ state = /* @__PURE__ */ new Map();
1429
+ procs = /* @__PURE__ */ new Set();
1430
+ baseCwd;
1431
+ env;
1432
+ events;
1433
+ spawner;
1434
+ restarter;
1435
+ healthPoller;
1436
+ lifecycle;
1437
+ constructor(opts) {
1438
+ this.baseCwd = opts.baseCwd;
1439
+ this.env = opts.env;
1440
+ this.events = opts.events;
1441
+ this.lifecycle = new Lifecycle({
1442
+ state: this.state,
1443
+ procs: this.procs,
1444
+ platform: opts.platform
1445
+ });
1446
+ let restarterRef = null;
1447
+ this.spawner = new Spawner({
1448
+ baseCwd: opts.baseCwd,
1449
+ env: opts.env,
1450
+ state: this.state,
1451
+ procs: this.procs,
1452
+ events: opts.events,
1453
+ lifecycle: this.lifecycle,
1454
+ onCrash: (svc, state, colorIdx) => restarterRef?.scheduleAutoRestart(svc, state, colorIdx)
1455
+ });
1456
+ this.restarter = new Restarter({
1457
+ state: this.state,
1458
+ events: opts.events,
1459
+ spawner: this.spawner,
1460
+ lifecycle: this.lifecycle
1461
+ });
1462
+ restarterRef = this.restarter;
1463
+ this.healthPoller = new HealthPoller({
1464
+ state: this.state,
1465
+ events: opts.events
1466
+ });
1467
+ }
1468
+ install(svc, colorIdx) {
1469
+ const cwd = join5(this.baseCwd, svc.cwd);
1470
+ const idx = colorIdx ?? this.state.get(svc.name)?.colorIdx ?? 0;
1471
+ return installService(cwd, this.env, (msg) => this.events.onLog(svc.name, msg, idx));
1472
+ }
1473
+ start(svc, colorIdx, isRestart = false) {
1474
+ return this.spawner.start(svc, colorIdx, isRestart);
1475
+ }
1476
+ stop(name) {
1477
+ this.lifecycle.stop(name);
1478
+ }
1479
+ restart(name) {
1480
+ return this.restarter.restart(name);
1481
+ }
1482
+ checkAllHealth() {
1483
+ return this.healthPoller.checkAll();
1484
+ }
1485
+ cleanup(opts = {}) {
1486
+ return this.lifecycle.cleanup(opts);
1487
+ }
1488
+ };
1489
+
1490
+ // src/tui/hooks/useProcessManager.ts
1491
+ function useProcessManager(platform, baseCwd, env, logSink = null) {
1492
+ const [states, setStates] = useState(/* @__PURE__ */ new Map());
1493
+ const [logs, setLogs] = useState([]);
1494
+ const [stats, setStats] = useState(/* @__PURE__ */ new Map());
1495
+ const mgrRef = useRef(null);
1496
+ const prevCpu = useRef(/* @__PURE__ */ new Map());
1497
+ const pausedRef = useRef(false);
1498
+ const pendingLogsRef = useRef([]);
1499
+ const sinkRef = useRef(logSink);
1500
+ sinkRef.current = logSink;
1501
+ useEffect(() => {
1502
+ const mgr2 = new ProcessManager({
1503
+ baseCwd,
1504
+ env,
1505
+ platform,
1340
1506
  events: {
1341
1507
  onLog: (svcName, text, colorIdx) => {
1342
1508
  sinkRef.current?.write(svcName, text);
@@ -1546,66 +1712,352 @@ function useProxySync(provider, opts, states, enabled) {
1546
1712
  }, [provider, opts, enabled]);
1547
1713
  }
1548
1714
 
1549
- // src/tui/LogsPanel.tsx
1550
- import { useEffect as useEffect3, useMemo } from "react";
1551
- import { Box, Text } from "ink";
1552
- import { jsx, jsxs } from "react/jsx-runtime";
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");
1556
- const contentHeight = Math.max(1, height - 2);
1557
- const totalLines = filtered.length;
1558
- const maxOffset = Math.max(0, totalLines - contentHeight);
1559
- const effectiveOffset = scrollOffset === Number.MAX_SAFE_INTEGER ? maxOffset : Math.min(scrollOffset, maxOffset);
1560
- const startIndex = Math.max(0, totalLines - contentHeight - effectiveOffset);
1561
- const endIndex = Math.min(startIndex + contentHeight, totalLines);
1562
- const visible = filtered.slice(startIndex, endIndex);
1715
+ // src/tui/hooks/useTerminalSize.ts
1716
+ import { useEffect as useEffect3, useState as useState3 } from "react";
1717
+ import { useStdout } from "ink";
1718
+ function useTerminalSize() {
1719
+ const { stdout } = useStdout();
1720
+ const [rows, setRows] = useState3(stdout?.rows ?? 40);
1563
1721
  useEffect3(() => {
1564
- resetScroll();
1565
- }, [filter, searchTerm, resetScroll]);
1566
- const matcher = useMemo(() => compileSearchPattern(searchTerm), [searchTerm]);
1567
- const scrolled = effectiveOffset > 0;
1568
- const label = [
1569
- "Logs",
1570
- filter ? `[${filter}]` : "",
1571
- searchTerm ? `/${searchTerm}` : "",
1572
- matcher?.invalid ? "(invalid regex)" : "",
1573
- levelFilter !== "all" ? `[level: ${levelFilter}${levelFilter === "warn" ? "+error" : ""}]` : "",
1574
- paused ? "[PAUSED]" : "",
1575
- scrolled ? "[SCROLL]" : "",
1576
- `${filtered.length} lines`,
1577
- focused && totalLines > 0 ? `(${startIndex + 1}-${endIndex}/${totalLines})` : ""
1578
- ].filter(Boolean).join(" ");
1579
- return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "cyan" : "gray", height, children: [
1580
- /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { bold: true, color: "cyan", children: [
1581
- " ",
1582
- label,
1583
- " "
1584
- ] }) }),
1585
- visible.map((entry, i) => {
1586
- const color = tagColors[entry.colorIdx % tagColors.length];
1587
- const ts = showTimestamps ? new Date(entry.ts).toLocaleTimeString("en-GB") + " " : "";
1588
- const line = entry.text;
1589
- const isMatch = matcher ? matcher.test(line) : false;
1590
- return /* @__PURE__ */ jsxs(Box, { children: [
1591
- showTimestamps && /* @__PURE__ */ jsx(Text, { dimColor: true, children: ts }),
1592
- /* @__PURE__ */ jsxs(Text, { color, children: [
1593
- "[",
1594
- entry.svcName.padEnd(maxNameLen),
1595
- "]"
1596
- ] }),
1597
- /* @__PURE__ */ jsx(Text, { children: " " }),
1598
- isMatch ? /* @__PURE__ */ jsx(Text, { backgroundColor: "yellow", color: "black", children: line }) : /* @__PURE__ */ jsx(Text, { children: line })
1599
- ] }, i);
1600
- })
1601
- ] });
1722
+ if (!stdout) return;
1723
+ const onResize = () => setRows(stdout.rows ?? 40);
1724
+ stdout.on("resize", onResize);
1725
+ return () => {
1726
+ stdout.off("resize", onResize);
1727
+ };
1728
+ }, [stdout]);
1729
+ return rows;
1730
+ }
1731
+
1732
+ // src/tui/hooks/useLogsPause.ts
1733
+ import { useEffect as useEffect4 } from "react";
1734
+ function useLogsPause(setPaused, logsPaused, logsScrollOffset) {
1735
+ useEffect4(() => {
1736
+ setPaused(logsPaused || logsScrollOffset > 0);
1737
+ }, [logsPaused, logsScrollOffset, setPaused]);
1738
+ }
1739
+
1740
+ // src/tui/hooks/useControlPlane.ts
1741
+ import { useEffect as useEffect5, useRef as useRef3 } from "react";
1742
+ import { createInterface as createInterface3 } from "readline";
1743
+ import { createReadStream as createReadStream2, existsSync as existsSync12 } from "fs";
1744
+
1745
+ // src/control-plane/socket-server.ts
1746
+ import { createServer } from "net";
1747
+ import { createInterface as createInterface2 } from "readline";
1748
+ import { existsSync as existsSync11, unlinkSync, chmodSync, mkdirSync as mkdirSync4, statSync as statSync2 } from "fs";
1749
+ import { dirname as dirname5 } from "path";
1750
+ import { join as join6 } from "path";
1751
+ import { homedir as homedir2 } from "os";
1752
+ function defaultSocketPath(projectName) {
1753
+ const safe = projectName.replace(/[^a-zA-Z0-9._-]+/g, "_") || "devup";
1754
+ return join6(homedir2(), ".devup", `sock-${safe}.sock`);
1755
+ }
1756
+ async function startSocketServer(projectName, ctx, opts = {}) {
1757
+ const path = opts.path ?? defaultSocketPath(projectName);
1758
+ mkdirSync4(dirname5(path), { recursive: true });
1759
+ if (existsSync11(path)) {
1760
+ try {
1761
+ const st = statSync2(path);
1762
+ if (st.isSocket()) unlinkSync(path);
1763
+ } catch {
1764
+ }
1765
+ }
1766
+ const server = createServer((socket) => handleClient(socket, ctx));
1767
+ await new Promise((resolve4, reject) => {
1768
+ server.once("error", reject);
1769
+ server.listen(path, () => {
1770
+ server.off("error", reject);
1771
+ try {
1772
+ chmodSync(path, 384);
1773
+ } catch {
1774
+ }
1775
+ opts.onLog?.(`\u{1F50C} control plane at ${path}`);
1776
+ resolve4();
1777
+ });
1778
+ });
1779
+ return {
1780
+ server,
1781
+ path,
1782
+ async close() {
1783
+ await new Promise((resolve4) => server.close(() => resolve4()));
1784
+ if (existsSync11(path)) {
1785
+ try {
1786
+ unlinkSync(path);
1787
+ } catch {
1788
+ }
1789
+ }
1790
+ }
1791
+ };
1792
+ }
1793
+ function handleClient(socket, ctx) {
1794
+ const rl = createInterface2({ input: socket });
1795
+ rl.on("line", async (line) => {
1796
+ if (!line.trim()) return;
1797
+ let req;
1798
+ try {
1799
+ req = JSON.parse(line);
1800
+ } catch (e) {
1801
+ respond(socket, { error: { code: -32700, message: `parse error: ${e.message}` } });
1802
+ return;
1803
+ }
1804
+ if (typeof req.method !== "string") {
1805
+ respond(socket, { id: req.id, error: { code: -32600, message: "method required" } });
1806
+ return;
1807
+ }
1808
+ try {
1809
+ const result = await dispatch(req.method, req.params ?? {}, ctx);
1810
+ respond(socket, { id: req.id, result });
1811
+ } catch (e) {
1812
+ respond(socket, { id: req.id, error: { code: -32603, message: e.message ?? String(e) } });
1813
+ }
1814
+ });
1815
+ socket.on("error", () => {
1816
+ });
1817
+ }
1818
+ function respond(socket, payload) {
1819
+ if (socket.writable) socket.write(JSON.stringify(payload) + "\n");
1820
+ }
1821
+ async function dispatch(method, params, ctx) {
1822
+ switch (method) {
1823
+ case "status": {
1824
+ const out = [];
1825
+ for (const [name, st] of ctx.states()) {
1826
+ out.push({
1827
+ name,
1828
+ status: st.status,
1829
+ health: st.health,
1830
+ port: st.svc.port,
1831
+ type: st.svc.type,
1832
+ errors: st.errors,
1833
+ restarts: st.restarts,
1834
+ pid: st.pid,
1835
+ startedAt: st.startedAt
1836
+ });
1837
+ }
1838
+ return { services: out };
1839
+ }
1840
+ case "restart": {
1841
+ const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
1842
+ await ctx.restart(svc);
1843
+ return { ok: true };
1844
+ }
1845
+ case "stop": {
1846
+ const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
1847
+ ctx.stop(svc);
1848
+ return { ok: true };
1849
+ }
1850
+ case "logs.tail": {
1851
+ const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
1852
+ const lines = Math.max(1, Math.min(1e4, Number(params["lines"] ?? 100)));
1853
+ return { lines: await ctx.tailLogs(svc, lines) };
1854
+ }
1855
+ case "ping":
1856
+ return { ok: true, ts: Date.now() };
1857
+ default:
1858
+ throw new Error(`unknown method: ${method}`);
1859
+ }
1860
+ }
1861
+ function stringOrThrow(v, paramName) {
1862
+ if (typeof v !== "string" || !v.trim()) {
1863
+ throw new Error(`param "${paramName}" must be a non-empty string`);
1864
+ }
1865
+ return v;
1866
+ }
1867
+
1868
+ // src/tui/hooks/useControlPlane.ts
1869
+ function useControlPlane(manager, projectName, logSink, pushLog) {
1870
+ const handleRef = useRef3(null);
1871
+ useEffect5(() => {
1872
+ if (!manager) return;
1873
+ let handle = null;
1874
+ (async () => {
1875
+ try {
1876
+ handle = await startSocketServer(projectName, {
1877
+ states: () => manager.state,
1878
+ restart: (name) => manager.restart(name),
1879
+ stop: (name) => manager.stop(name),
1880
+ tailLogs: async (svcName, lines) => {
1881
+ if (!logSink) return [];
1882
+ const file = logSink.pathFor(svcName);
1883
+ if (!existsSync12(file)) return [];
1884
+ return new Promise((resolve4, reject) => {
1885
+ const buf = [];
1886
+ const rl = createInterface3({ input: createReadStream2(file, { encoding: "utf8" }) });
1887
+ rl.on("line", (l) => {
1888
+ buf.push(l);
1889
+ if (buf.length > lines) buf.shift();
1890
+ });
1891
+ rl.on("close", () => resolve4(buf));
1892
+ rl.on("error", reject);
1893
+ });
1894
+ }
1895
+ }, { onLog: (msg) => pushLog("devup", msg, 12) });
1896
+ handleRef.current = handle;
1897
+ } catch (e) {
1898
+ pushLog("devup", `\u26A0 control plane disabled: ${e.message}`, 5);
1899
+ }
1900
+ })();
1901
+ return () => {
1902
+ void handle?.close();
1903
+ handleRef.current = null;
1904
+ };
1905
+ }, [manager, projectName, logSink, pushLog]);
1906
+ return handleRef;
1907
+ }
1908
+
1909
+ // src/tui/hooks/useHotReload.ts
1910
+ import { useEffect as useEffect6 } from "react";
1911
+ import { watch as fsWatch } from "fs";
1912
+
1913
+ // src/config/diff.ts
1914
+ var SPAWN_RELEVANT = [
1915
+ "cwd",
1916
+ "cmd",
1917
+ "args",
1918
+ "port",
1919
+ "phase",
1920
+ "maxMem",
1921
+ "preBuild",
1922
+ "watchBuild",
1923
+ "nodeArgs",
1924
+ "extraEnv",
1925
+ "healthCheck",
1926
+ "readyPattern",
1927
+ "errorPattern",
1928
+ "type"
1929
+ ];
1930
+ function hasSpawnRelevantChange(prev, next) {
1931
+ for (const k of SPAWN_RELEVANT) {
1932
+ if (JSON.stringify(prev[k]) !== JSON.stringify(next[k])) return true;
1933
+ }
1934
+ return false;
1935
+ }
1936
+ function diffServices(prev, next) {
1937
+ const prevByName = new Map(prev.map((s) => [s.name, s]));
1938
+ const nextByName = new Map(next.map((s) => [s.name, s]));
1939
+ const added = [];
1940
+ const removed = [];
1941
+ const changed = [];
1942
+ const unchanged = [];
1943
+ for (const [name, p] of prevByName) {
1944
+ if (!nextByName.has(name)) {
1945
+ removed.push(name);
1946
+ continue;
1947
+ }
1948
+ const n = nextByName.get(name);
1949
+ if (hasSpawnRelevantChange(p, n)) changed.push({ prev: p, next: n });
1950
+ else unchanged.push(name);
1951
+ }
1952
+ for (const [name, n] of nextByName) {
1953
+ if (!prevByName.has(name)) added.push(n);
1954
+ }
1955
+ return { added, removed, changed, unchanged };
1956
+ }
1957
+ function summariseDiff(d) {
1958
+ const parts = [];
1959
+ if (d.added.length) parts.push(`+${d.added.length} added`);
1960
+ if (d.removed.length) parts.push(`-${d.removed.length} removed`);
1961
+ if (d.changed.length) parts.push(`~${d.changed.length} changed`);
1962
+ if (!parts.length) parts.push("no changes");
1963
+ return parts.join(", ");
1964
+ }
1965
+
1966
+ // src/tui/hooks/useHotReload.ts
1967
+ function useHotReload(manager, cliArgs, baseCwd, pushLog) {
1968
+ useEffect6(() => {
1969
+ if (!cliArgs.watchConfig || !manager) return;
1970
+ let watcher = null;
1971
+ let configPath;
1972
+ try {
1973
+ configPath = findConfigFile(baseCwd, cliArgs.configPath);
1974
+ } catch (e) {
1975
+ pushLog("devup", `\u26A0 watch-config disabled: ${e.message}`, 5);
1976
+ return;
1977
+ }
1978
+ pushLog("devup", `\u{1F440} watching ${configPath}`, 12);
1979
+ let reloadInFlight = false;
1980
+ let reloadAgain = false;
1981
+ const reload = async () => {
1982
+ if (reloadInFlight) {
1983
+ reloadAgain = true;
1984
+ return;
1985
+ }
1986
+ reloadInFlight = true;
1987
+ try {
1988
+ const nextCfg = await loadConfig(configPath);
1989
+ const errs = validateConfig(nextCfg, baseCwd);
1990
+ if (errs.length) {
1991
+ pushLog("devup", `\u26A0 config reload failed:
1992
+ ${formatValidationErrors(errs)}`, 5);
1993
+ return;
1994
+ }
1995
+ const currentSvcs = [...manager.state.values()].map((s) => s.svc);
1996
+ const diff = diffServices(currentSvcs, nextCfg.services);
1997
+ if (!diff.added.length && !diff.removed.length && !diff.changed.length) return;
1998
+ for (const name of diff.removed) {
1999
+ manager.stop(name);
2000
+ manager.state.delete(name);
2001
+ }
2002
+ let colorIdx = currentSvcs.length;
2003
+ for (const { next } of diff.changed) {
2004
+ const prev = manager.state.get(next.name);
2005
+ const ci = prev?.colorIdx ?? colorIdx++;
2006
+ manager.stop(next.name);
2007
+ await new Promise((r) => setTimeout(r, 800));
2008
+ await manager.install(next, ci);
2009
+ await manager.start(next, ci, true);
2010
+ }
2011
+ for (const next of diff.added) {
2012
+ const ci = colorIdx++;
2013
+ await manager.install(next, ci);
2014
+ await manager.start(next, ci);
2015
+ }
2016
+ pushLog("devup", `\u{1F501} config reloaded: ${summariseDiff(diff)}`, 12);
2017
+ } catch (e) {
2018
+ pushLog("devup", `\u26A0 config reload error: ${e.message}`, 5);
2019
+ } finally {
2020
+ reloadInFlight = false;
2021
+ if (reloadAgain) {
2022
+ reloadAgain = false;
2023
+ void reload();
2024
+ }
2025
+ }
2026
+ };
2027
+ let debounceTimer = null;
2028
+ watcher = fsWatch(configPath, () => {
2029
+ if (debounceTimer) clearTimeout(debounceTimer);
2030
+ debounceTimer = setTimeout(() => void reload(), 250);
2031
+ });
2032
+ return () => {
2033
+ if (debounceTimer) clearTimeout(debounceTimer);
2034
+ watcher?.close();
2035
+ };
2036
+ }, [cliArgs.watchConfig, cliArgs.configPath, baseCwd, manager, pushLog]);
2037
+ }
2038
+
2039
+ // src/tui/hooks/useContextualTips.ts
2040
+ import { useEffect as useEffect8, useRef as useRef4, useState as useState5 } from "react";
2041
+
2042
+ // src/tui/tips.ts
2043
+ function pickTip(state) {
2044
+ if (state.crashLoopedCount > 0 && !state.shown.has("crashed")) {
2045
+ return { id: "crashed", message: "tip: press r to restart, or check the log of the failing service" };
2046
+ }
2047
+ if (state.totalLogs > 1e3 && !state.hasSearch && !state.shown.has("search")) {
2048
+ return { id: "search", message: "tip: press / to search in logs" };
2049
+ }
2050
+ if (state.totalLogs > 500 && !state.hasFilter && !state.shown.has("filter")) {
2051
+ return { id: "filter", message: "tip: press f to filter logs by service" };
2052
+ }
2053
+ return null;
1602
2054
  }
1603
2055
 
1604
2056
  // src/tui/StatsPanel.tsx
1605
- import { useEffect as useEffect4, useState as useState3 } from "react";
1606
- import { Box as Box2, Text as Text2 } from "ink";
2057
+ import { useEffect as useEffect7, useState as useState4 } from "react";
2058
+ import { Box, Text } from "ink";
1607
2059
  import os from "os";
1608
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
2060
+ import { jsx, jsxs } from "react/jsx-runtime";
1609
2061
  var H = {
1610
2062
  up: { c: "\u25CF", color: "green" },
1611
2063
  wait: { c: "\u25CF", color: "yellow" },
@@ -1618,20 +2070,20 @@ function isCrashLooped(st) {
1618
2070
  }
1619
2071
  function Row({ name, st, stat, ml, verbose }) {
1620
2072
  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 });
2073
+ const indicator = looped ? /* @__PURE__ */ jsx(Text, { color: "red", bold: true, children: "\u2716" }) : /* @__PURE__ */ jsx(Text, { color: (H[st.health] ?? H["down"]).color, children: (H[st.health] ?? H["down"]).c });
1622
2074
  const color = tagColors[st.colorIdx % tagColors.length];
1623
2075
  const sc = looped ? "red" : st.status === "running" ? "green" : st.status === "starting" ? "yellow" : st.status === "idle" ? "blue" : "red";
1624
2076
  const statusLabel = looped ? "looping" : st.status;
1625
2077
  const up = st.startedAt ? fmtUptime(Date.now() - st.startedAt) : "-";
1626
2078
  if (!verbose) {
1627
- return /* @__PURE__ */ jsxs2(Text2, { children: [
2079
+ return /* @__PURE__ */ jsxs(Text, { children: [
1628
2080
  indicator,
1629
2081
  " ",
1630
- /* @__PURE__ */ jsx2(Text2, { color, children: name.padEnd(ml) }),
2082
+ /* @__PURE__ */ jsx(Text, { color, children: name.padEnd(ml) }),
1631
2083
  " ",
1632
2084
  String(st.svc.port).padStart(5),
1633
2085
  " ",
1634
- /* @__PURE__ */ jsx2(Text2, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
2086
+ /* @__PURE__ */ jsx(Text, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
1635
2087
  " ",
1636
2088
  (stat?.cpu ?? "-").padStart(6),
1637
2089
  " ",
@@ -1647,15 +2099,15 @@ function Row({ name, st, stat, ml, verbose }) {
1647
2099
  const resolvedArgs = buildProcessArgs(st.svc).join(" ");
1648
2100
  const env = redactSecrets(st.svc.extraEnv);
1649
2101
  const envStr = Object.entries(env).map(([k, v]) => `${k}=${v}`).join(" ");
1650
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
1651
- /* @__PURE__ */ jsxs2(Text2, { children: [
2102
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2103
+ /* @__PURE__ */ jsxs(Text, { children: [
1652
2104
  indicator,
1653
2105
  " ",
1654
- /* @__PURE__ */ jsx2(Text2, { color, children: name.padEnd(ml) }),
2106
+ /* @__PURE__ */ jsx(Text, { color, children: name.padEnd(ml) }),
1655
2107
  " ",
1656
2108
  String(st.svc.port).padStart(5),
1657
2109
  " ",
1658
- /* @__PURE__ */ jsx2(Text2, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
2110
+ /* @__PURE__ */ jsx(Text, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
1659
2111
  " ",
1660
2112
  (stat?.cpu ?? "-").padStart(6),
1661
2113
  " ",
@@ -1667,20 +2119,20 @@ function Row({ name, st, stat, ml, verbose }) {
1667
2119
  " ",
1668
2120
  up.padStart(6)
1669
2121
  ] }),
1670
- /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
2122
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
1671
2123
  " cmd: ",
1672
2124
  st.svc.cmd,
1673
2125
  " ",
1674
2126
  resolvedArgs
1675
2127
  ] }),
1676
- envStr && /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
2128
+ envStr && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
1677
2129
  " env: ",
1678
2130
  envStr
1679
2131
  ] })
1680
2132
  ] });
1681
2133
  }
1682
2134
  function ColHeader({ ml }) {
1683
- return /* @__PURE__ */ jsxs2(Text2, { bold: true, children: [
2135
+ return /* @__PURE__ */ jsxs(Text, { bold: true, children: [
1684
2136
  "H ",
1685
2137
  "Service".padEnd(ml),
1686
2138
  " ",
@@ -1727,7 +2179,7 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1727
2179
  const webStartIndex = Math.min(effectiveOffset, Math.max(0, webs.length - rowsPerCol));
1728
2180
  const visibleApis = apis.slice(apiStartIndex, apiStartIndex + rowsPerCol);
1729
2181
  const visibleWebs = webs.slice(webStartIndex, webStartIndex + rowsPerCol);
1730
- useEffect4(() => {
2182
+ useEffect7(() => {
1731
2183
  resetScroll();
1732
2184
  }, [sortMode, resetScroll]);
1733
2185
  const totalRowsLong = Math.max(apis.length, webs.length);
@@ -1735,24 +2187,24 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1735
2187
  const scrolled = effectiveOffset > 0;
1736
2188
  const loopedCount = [...states.values()].filter(isCrashLooped).length;
1737
2189
  const ramPct = parseFloat(usedGB) / parseFloat(totalGB) * 100;
1738
- const [ramBanner, setRamBanner] = useState3(false);
1739
- useEffect4(() => {
2190
+ const [ramBanner, setRamBanner] = useState4(false);
2191
+ useEffect7(() => {
1740
2192
  setRamBanner((prev) => nextRamBannerVisibility(ramPct, prev));
1741
2193
  }, [ramPct]);
1742
2194
  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) : [];
1743
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "green" : "gray", height, children: [
1744
- /* @__PURE__ */ jsxs2(Box2, { children: [
1745
- /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "green", children: [
2195
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "green" : "gray", height, children: [
2196
+ /* @__PURE__ */ jsxs(Box, { children: [
2197
+ /* @__PURE__ */ jsxs(Text, { bold: true, color: "green", children: [
1746
2198
  " Stats ",
1747
2199
  positionInfo
1748
2200
  ] }),
1749
- scrolled && /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: " [SCROLL]" }),
1750
- loopedCount > 0 && /* @__PURE__ */ jsxs2(Text2, { color: "red", bold: true, children: [
2201
+ scrolled && /* @__PURE__ */ jsx(Text, { color: "yellow", children: " [SCROLL]" }),
2202
+ loopedCount > 0 && /* @__PURE__ */ jsxs(Text, { color: "red", bold: true, children: [
1751
2203
  " \u26A0 ",
1752
2204
  loopedCount,
1753
2205
  " need attention"
1754
2206
  ] }),
1755
- /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
2207
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
1756
2208
  " System: ",
1757
2209
  cpus,
1758
2210
  "c Load ",
@@ -1763,8 +2215,8 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1763
2215
  totalGB,
1764
2216
  "GB"
1765
2217
  ] }),
1766
- /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: " \u2502 " }),
1767
- /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
2218
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " \u2502 " }),
2219
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
1768
2220
  "Stack: CPU ",
1769
2221
  totalCpu.toFixed(1),
1770
2222
  "% RAM ",
@@ -1776,170 +2228,67 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1776
2228
  " Svcs ",
1777
2229
  names.length
1778
2230
  ] }),
1779
- sortMode !== "name" && /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
2231
+ sortMode !== "name" && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
1780
2232
  " \u2502 Sort: ",
1781
2233
  sortMode
1782
2234
  ] })
1783
2235
  ] }),
1784
- ramBanner && /* @__PURE__ */ jsxs2(Box2, { children: [
1785
- /* @__PURE__ */ jsxs2(Text2, { color: "yellow", bold: true, children: [
2236
+ ramBanner && /* @__PURE__ */ jsxs(Box, { children: [
2237
+ /* @__PURE__ */ jsxs(Text, { color: "yellow", bold: true, children: [
1786
2238
  " \u26A0 RAM ",
1787
2239
  ramPct.toFixed(0),
1788
2240
  "% \u2014 top: "
1789
2241
  ] }),
1790
- /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: topConsumers.map((c) => `${c.name} ${c.mb.toFixed(0)}MB`).join(", ") })
2242
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: topConsumers.map((c) => `${c.name} ${c.mb.toFixed(0)}MB`).join(", ") })
1791
2243
  ] }),
1792
- /* @__PURE__ */ jsxs2(Box2, { flexGrow: 1, children: [
1793
- /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexGrow: 1, flexBasis: 0, children: [
1794
- /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "cyan", children: [
2244
+ /* @__PURE__ */ jsxs(Box, { flexGrow: 1, children: [
2245
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", flexGrow: 1, flexBasis: 0, children: [
2246
+ /* @__PURE__ */ jsxs(Text, { bold: true, color: "cyan", children: [
1795
2247
  " APIs (",
1796
2248
  apis.length,
1797
2249
  ")"
1798
2250
  ] }),
1799
- /* @__PURE__ */ jsx2(ColHeader, { ml }),
1800
- visibleApis.map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml, verbose }, n))
2251
+ /* @__PURE__ */ jsx(ColHeader, { ml }),
2252
+ visibleApis.map((n) => /* @__PURE__ */ jsx(Row, { name: n, st: states.get(n), stat: stats.get(n), ml, verbose }, n))
1801
2253
  ] }),
1802
- /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", width: 1, children: Array.from({ length: contentHeight }, (_, i) => /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\u2502" }, i)) }),
1803
- /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexGrow: 1, flexBasis: 0, children: [
1804
- /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "magenta", children: [
2254
+ /* @__PURE__ */ jsx(Box, { flexDirection: "column", width: 1, children: Array.from({ length: contentHeight }, (_, i) => /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }, i)) }),
2255
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", flexGrow: 1, flexBasis: 0, children: [
2256
+ /* @__PURE__ */ jsxs(Text, { bold: true, color: "magenta", children: [
1805
2257
  " Webs (",
1806
2258
  webs.length,
1807
2259
  ")"
1808
2260
  ] }),
1809
- /* @__PURE__ */ jsx2(ColHeader, { ml }),
1810
- visibleWebs.map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml, verbose }, n))
2261
+ /* @__PURE__ */ jsx(ColHeader, { ml }),
2262
+ visibleWebs.map((n) => /* @__PURE__ */ jsx(Row, { name: n, st: states.get(n), stat: stats.get(n), ml, verbose }, n))
1811
2263
  ] })
1812
2264
  ] })
1813
2265
  ] });
1814
2266
  }
1815
2267
 
1816
- // src/tui/StatusBar.tsx
1817
- import { Box as Box3, Text as Text3 } from "ink";
1818
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1819
- function StatusBar() {
1820
- return /* @__PURE__ */ jsx3(Box3, { children: /* @__PURE__ */ jsxs3(Text3, { children: [
1821
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "q" }),
1822
- " Quit ",
1823
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Tab" }),
1824
- " Switch ",
1825
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "\u2191\u2193" }),
1826
- " Scroll ",
1827
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "PgUp/PgDn" }),
1828
- " Page ",
1829
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Ctrl+A/E" }),
1830
- " Home/End ",
1831
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "c" }),
1832
- " Clear ",
1833
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "f" }),
1834
- " Filter ",
1835
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "L" }),
1836
- " Level ",
1837
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "a" }),
1838
- " All ",
1839
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "r" }),
1840
- " Restart ",
1841
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "/" }),
1842
- " Search ",
1843
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "s" }),
1844
- " Sort ",
1845
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "o" }),
1846
- " Open ",
1847
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "p" }),
1848
- " Pause ",
1849
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "t" }),
1850
- " Time ",
1851
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "v" }),
1852
- " Verbose ",
1853
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "T" }),
1854
- " Proxy"
1855
- ] }) });
1856
- }
1857
-
1858
- // src/tui/ServiceList.tsx
1859
- import { useState as useState4, useMemo as useMemo2 } from "react";
1860
- import { Box as Box4, Text as Text4, useInput as useInput2 } from "ink";
1861
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1862
- function ServiceList({ title, services, onSelect, onClose, filterType }) {
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));
1875
- useInput2((input, key) => {
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);
2268
+ // src/tui/hooks/useContextualTips.ts
2269
+ function useContextualTips(totalLogs, hasSearch, hasFilter, states) {
2270
+ const shownTips = useRef4(/* @__PURE__ */ new Set());
2271
+ const [activeTip, setActiveTip] = useState5(null);
2272
+ useEffect8(() => {
2273
+ const tip = pickTip({
2274
+ totalLogs,
2275
+ hasSearch,
2276
+ hasFilter,
2277
+ crashLoopedCount: [...states.values()].filter(isCrashLooped).length,
2278
+ shown: shownTips.current
2279
+ });
2280
+ if (tip && tip.message !== activeTip) {
2281
+ shownTips.current.add(tip.id);
2282
+ setActiveTip(tip.message);
2283
+ const timer = setTimeout(() => setActiveTip(null), 12e3);
2284
+ return () => clearTimeout(timer);
1901
2285
  }
1902
- }, { isActive: process.stdin.isTTY ?? false });
1903
- return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
1904
- /* @__PURE__ */ jsxs4(Text4, { bold: true, color: "cyan", children: [
1905
- " ",
1906
- title,
1907
- " ",
1908
- query && /* @__PURE__ */ jsxs4(Text4, { color: "yellow", children: [
1909
- "[",
1910
- query,
1911
- "]"
1912
- ] })
1913
- ] }),
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: [
1915
- " ",
1916
- name,
1917
- " :",
1918
- services.get(name).svc.port,
1919
- " "
1920
- ] }) }, name)),
1921
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "type to filter \u2191\u2193 navigate Enter select Esc clear/close" })
1922
- ] });
2286
+ }, [totalLogs, states, hasSearch, hasFilter, activeTip]);
2287
+ return activeTip;
1923
2288
  }
1924
2289
 
1925
- // src/tui/SearchInput.tsx
1926
- import { useState as useState5 } from "react";
1927
- import { Box as Box5, Text as Text5, useInput as useInput3 } from "ink";
1928
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1929
- function SearchInput({ onSubmit, onClose }) {
1930
- const [value, setValue] = useState5("");
1931
- useInput3((input, key) => {
1932
- if (key.escape) onClose();
1933
- else if (key.return) onSubmit(value.trim() || null);
1934
- else if (key.backspace || key.delete) setValue((v) => v.slice(0, -1));
1935
- else if (input && !key.ctrl && !key.meta) setValue((v) => v + input);
1936
- }, { isActive: process.stdin.isTTY ?? false });
1937
- return /* @__PURE__ */ jsxs5(Box5, { borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
1938
- /* @__PURE__ */ jsx5(Text5, { bold: true, color: "yellow", children: "Search: " }),
1939
- /* @__PURE__ */ jsx5(Text5, { children: value }),
1940
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u2588" })
1941
- ] });
1942
- }
2290
+ // src/tui/hooks/useBootSequence.ts
2291
+ import { useEffect as useEffect9, useState as useState6 } from "react";
1943
2292
 
1944
2293
  // src/lazy/proxy.ts
1945
2294
  import net2 from "net";
@@ -2056,7 +2405,7 @@ function createLazyProxy(opts) {
2056
2405
 
2057
2406
  // src/process/external.ts
2058
2407
  import { spawn as spawn4 } from "child_process";
2059
- import { join as join5 } from "path";
2408
+ import { join as join7 } from "path";
2060
2409
  var DEFAULT_START_TIMEOUT_S = 60;
2061
2410
  async function startExternals(externals, opts) {
2062
2411
  const procs = [];
@@ -2093,139 +2442,54 @@ async function stopExternals(procs, platform, opts = {}) {
2093
2442
  const isWin = process.platform === "win32";
2094
2443
  const shell = isWin ? "cmd.exe" : "sh";
2095
2444
  const flag = isWin ? "/c" : "-c";
2096
- const cwd = svc.cwd ? join5(opts.baseCwd, svc.cwd) : opts.baseCwd;
2445
+ const cwd = svc.cwd ? join7(opts.baseCwd, svc.cwd) : opts.baseCwd;
2097
2446
  const env = { ...opts.env, ...svc.extraEnv ?? {} };
2098
2447
  const child = spawn4(shell, [flag, svc.stopCmd], { cwd, env, stdio: "ignore" });
2099
2448
  child.on("close", () => resolve4());
2100
2449
  child.on("error", () => resolve4());
2101
- setTimeout(() => resolve4(), 1e4);
2102
- });
2103
- }
2104
- } catch {
2105
- }
2106
- void proc;
2107
- }
2108
- }
2109
- function spawnExternal(svc, opts) {
2110
- const isWin = process.platform === "win32";
2111
- const shell = isWin ? "cmd.exe" : "sh";
2112
- const flag = isWin ? "/c" : "-c";
2113
- const cwd = svc.cwd ? join5(opts.baseCwd, svc.cwd) : opts.baseCwd;
2114
- const env = { ...opts.env, ...svc.extraEnv ?? {} };
2115
- opts.onLog?.(svc.name, `\u{1F680} ${svc.cmd}`);
2116
- const child = spawn4(shell, [flag, svc.cmd], {
2117
- cwd,
2118
- env,
2119
- detached: true,
2120
- stdio: ["ignore", "pipe", "pipe"]
2121
- });
2122
- child.stdout?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
2123
- child.stderr?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
2124
- child.on("error", (err) => opts.onLog?.(svc.name, `\u274C spawn error: ${err.message}`));
2125
- return child;
2126
- }
2127
- async function waitHealthy(svc, timeoutMs) {
2128
- const deadline = Date.now() + timeoutMs;
2129
- const port = svc.port;
2130
- while (Date.now() < deadline) {
2131
- if (await checkHealth(port, svc.healthCheck)) return true;
2132
- await new Promise((r) => setTimeout(r, 500));
2133
- }
2134
- return false;
2135
- }
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
-
2151
- // src/tui/App.tsx
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
- }
2164
- function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts, logSink }) {
2165
- const { stdout } = useStdout();
2166
- const [rows, setRows] = useState6(stdout?.rows ?? 40);
2167
- useEffect5(() => {
2168
- if (!stdout) return;
2169
- const onResize = () => setRows(stdout.rows ?? 40);
2170
- stdout.on("resize", onResize);
2171
- return () => {
2172
- stdout.off("resize", onResize);
2173
- };
2174
- }, [stdout]);
2175
- const logsHeight = Math.floor(rows * 0.65);
2176
- const statsHeight = rows - logsHeight - 2;
2177
- const maxNameLen = Math.max(...services.map((s) => s.name.length), 10);
2178
- const pm = useProcessManager(platform, baseCwd, env, logSink);
2179
- const [booted, setBooted] = useState6(false);
2180
- const lazyProxies = useRef3(/* @__PURE__ */ new Map());
2181
- const externals = useRef3([]);
2182
- const shownTips = useRef3(/* @__PURE__ */ new Set());
2183
- const [activeTip, setActiveTip] = useState6(null);
2184
- const kb = useKeyBindings({
2185
- onQuit: () => {
2186
- void shutdown();
2187
- },
2188
- onClearLogs: pm.clearLogs,
2189
- onToggleProxy: () => {
2190
- }
2191
- });
2192
- const shutdown = useCallback3(async () => {
2193
- lazyProxies.current.forEach((p) => p.destroy());
2194
- await pm.cleanup();
2195
- if (externals.current.length) {
2196
- await stopExternals(externals.current, platform, {
2197
- baseCwd,
2198
- env,
2199
- onLog: (svc, msg) => pm.pushLog(`ext:${svc}`, msg, 12)
2200
- });
2201
- externals.current = [];
2202
- }
2203
- await logSink?.close();
2204
- process.exit(0);
2205
- }, [pm, logSink, platform, baseCwd, env]);
2206
- useEffect5(() => {
2207
- pm.setPaused(kb.logsPaused || kb.logsScrollOffset > 0);
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);
2450
+ setTimeout(() => resolve4(), 1e4);
2451
+ });
2452
+ }
2453
+ } catch {
2222
2454
  }
2223
- }, [pm.logs.length, pm.states, kb.searchTerm, kb.logFilter, activeTip]);
2224
- useProxySync(proxyProvider, proxyOpts, pm.states, kb.proxyEnabled);
2225
- useEffect5(() => {
2226
- if (booted || !pm.manager) return;
2455
+ void proc;
2456
+ }
2457
+ }
2458
+ function spawnExternal(svc, opts) {
2459
+ const isWin = process.platform === "win32";
2460
+ const shell = isWin ? "cmd.exe" : "sh";
2461
+ const flag = isWin ? "/c" : "-c";
2462
+ const cwd = svc.cwd ? join7(opts.baseCwd, svc.cwd) : opts.baseCwd;
2463
+ const env = { ...opts.env, ...svc.extraEnv ?? {} };
2464
+ opts.onLog?.(svc.name, `\u{1F680} ${svc.cmd}`);
2465
+ const child = spawn4(shell, [flag, svc.cmd], {
2466
+ cwd,
2467
+ env,
2468
+ detached: true,
2469
+ stdio: ["ignore", "pipe", "pipe"]
2470
+ });
2471
+ child.stdout?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
2472
+ child.stderr?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
2473
+ child.on("error", (err) => opts.onLog?.(svc.name, `\u274C spawn error: ${err.message}`));
2474
+ return child;
2475
+ }
2476
+ async function waitHealthy(svc, timeoutMs) {
2477
+ const deadline = Date.now() + timeoutMs;
2478
+ const port = svc.port;
2479
+ while (Date.now() < deadline) {
2480
+ if (await checkHealth(port, svc.healthCheck)) return true;
2481
+ await new Promise((r) => setTimeout(r, 500));
2482
+ }
2483
+ return false;
2484
+ }
2485
+
2486
+ // src/tui/hooks/useBootSequence.ts
2487
+ function useBootSequence(manager, config, services, cliArgs, platform, env, baseCwd, refs, pushLog) {
2488
+ const [booted, setBooted] = useState6(false);
2489
+ useEffect9(() => {
2490
+ if (booted || !manager) return;
2227
2491
  setBooted(true);
2228
- const mgr = pm.manager;
2492
+ const mgr = manager;
2229
2493
  (async () => {
2230
2494
  const lazyMode = cliArgs.lazy;
2231
2495
  const lazyTimeout = cliArgs.lazyTimeout;
@@ -2234,11 +2498,11 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
2234
2498
  baseCwd,
2235
2499
  env,
2236
2500
  platform,
2237
- onLog: (svc, msg) => pm.pushLog(`ext:${svc}`, msg, 12)
2501
+ onLog: (svc, msg) => pushLog(`ext:${svc}`, msg, 12)
2238
2502
  });
2239
- externals.current = result.procs;
2503
+ refs.externals.current = result.procs;
2240
2504
  if (!result.allHealthy) {
2241
- pm.pushLog("devup", `\u274C external(s) failed: ${result.failed.join(", ")}. Aborting boot.`, 5);
2505
+ pushLog("devup", `\u274C external(s) failed: ${result.failed.join(", ")}. Aborting boot.`, 5);
2242
2506
  return;
2243
2507
  }
2244
2508
  }
@@ -2306,7 +2570,7 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
2306
2570
  return !!st && !!st.proc && !st.proc.killed && st.health === "up";
2307
2571
  }
2308
2572
  });
2309
- lazyProxies.current.set(svc.name, proxy);
2573
+ refs.lazyProxies.current.set(svc.name, proxy);
2310
2574
  }
2311
2575
  } else {
2312
2576
  const phases = groupByPhase(services);
@@ -2327,7 +2591,259 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
2327
2591
  }
2328
2592
  }
2329
2593
  })();
2330
- }, [booted, pm.manager, services, cliArgs, config.lazy]);
2594
+ }, [booted, manager, services, cliArgs, config.lazy, config.external, baseCwd, env, platform, refs, pushLog]);
2595
+ }
2596
+
2597
+ // src/tui/LogsPanel.tsx
2598
+ import { useEffect as useEffect10, useMemo } from "react";
2599
+ import { Box as Box2, Text as Text2 } from "ink";
2600
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
2601
+ function resolveBorder(focused, filter, filteredColorIdx) {
2602
+ if (focused) return "cyan";
2603
+ if (filter && filteredColorIdx !== null && filteredColorIdx >= 0) {
2604
+ return tagColors[filteredColorIdx % tagColors.length];
2605
+ }
2606
+ return "gray";
2607
+ }
2608
+ function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLen, height, focused, scrollOffset, resetScroll, levelFilter = "all", filteredColorIdx = null }) {
2609
+ const byService = filter ? logs.filter((l) => l.svcName === filter) : logs;
2610
+ const filtered = levelFilter === "all" ? byService : levelFilter === "error" ? byService.filter((l) => l.level === "error") : byService.filter((l) => l.level === "error" || l.level === "warn");
2611
+ const contentHeight = Math.max(1, height - 2);
2612
+ const totalLines = filtered.length;
2613
+ const maxOffset = Math.max(0, totalLines - contentHeight);
2614
+ const effectiveOffset = scrollOffset === Number.MAX_SAFE_INTEGER ? maxOffset : Math.min(scrollOffset, maxOffset);
2615
+ const startIndex = Math.max(0, totalLines - contentHeight - effectiveOffset);
2616
+ const endIndex = Math.min(startIndex + contentHeight, totalLines);
2617
+ const visible = filtered.slice(startIndex, endIndex);
2618
+ useEffect10(() => {
2619
+ resetScroll();
2620
+ }, [filter, searchTerm, resetScroll]);
2621
+ const matcher = useMemo(() => compileSearchPattern(searchTerm), [searchTerm]);
2622
+ const scrolled = effectiveOffset > 0;
2623
+ const label = [
2624
+ "Logs",
2625
+ filter ? `[${filter}]` : "",
2626
+ searchTerm ? `/${searchTerm}` : "",
2627
+ matcher?.invalid ? "(invalid regex)" : "",
2628
+ levelFilter !== "all" ? `[level: ${levelFilter}${levelFilter === "warn" ? "+error" : ""}]` : "",
2629
+ paused ? "[PAUSED]" : "",
2630
+ scrolled ? "[SCROLL]" : "",
2631
+ `${filtered.length} lines`,
2632
+ focused && totalLines > 0 ? `(${startIndex + 1}-${endIndex}/${totalLines})` : ""
2633
+ ].filter(Boolean).join(" ");
2634
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", borderColor: resolveBorder(focused, filter, filteredColorIdx), height, children: [
2635
+ /* @__PURE__ */ jsx2(Box2, { children: /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "cyan", children: [
2636
+ " ",
2637
+ label,
2638
+ " "
2639
+ ] }) }),
2640
+ visible.map((entry, i) => {
2641
+ const color = tagColors[entry.colorIdx % tagColors.length];
2642
+ const ts = showTimestamps ? new Date(entry.ts).toLocaleTimeString("en-GB") + " " : "";
2643
+ const line = entry.text;
2644
+ const isMatch = matcher ? matcher.test(line) : false;
2645
+ return /* @__PURE__ */ jsxs2(Box2, { children: [
2646
+ showTimestamps && /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: ts }),
2647
+ /* @__PURE__ */ jsxs2(Text2, { color, children: [
2648
+ "[",
2649
+ entry.svcName.padEnd(maxNameLen),
2650
+ "]"
2651
+ ] }),
2652
+ /* @__PURE__ */ jsx2(Text2, { children: " " }),
2653
+ isMatch ? /* @__PURE__ */ jsx2(Text2, { backgroundColor: "yellow", color: "black", children: line }) : /* @__PURE__ */ jsx2(Text2, { children: line })
2654
+ ] }, i);
2655
+ })
2656
+ ] });
2657
+ }
2658
+
2659
+ // src/tui/StatusBar.tsx
2660
+ import { Box as Box3, Text as Text3 } from "ink";
2661
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
2662
+ function StatusBar() {
2663
+ return /* @__PURE__ */ jsx3(Box3, { children: /* @__PURE__ */ jsxs3(Text3, { children: [
2664
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "q" }),
2665
+ " Quit ",
2666
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Tab" }),
2667
+ " Switch ",
2668
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "\u2191\u2193" }),
2669
+ " Scroll ",
2670
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "PgUp/PgDn" }),
2671
+ " Page ",
2672
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Ctrl+A/E" }),
2673
+ " Home/End ",
2674
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "c" }),
2675
+ " Clear ",
2676
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "f" }),
2677
+ " Filter ",
2678
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "L" }),
2679
+ " Level ",
2680
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "a" }),
2681
+ " All ",
2682
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "r" }),
2683
+ " Restart ",
2684
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "/" }),
2685
+ " Search ",
2686
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "s" }),
2687
+ " Sort ",
2688
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "o" }),
2689
+ " Open ",
2690
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "p" }),
2691
+ " Pause ",
2692
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "t" }),
2693
+ " Time ",
2694
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "v" }),
2695
+ " Verbose ",
2696
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "T" }),
2697
+ " Proxy"
2698
+ ] }) });
2699
+ }
2700
+
2701
+ // src/tui/ServiceList.tsx
2702
+ import { useState as useState7, useMemo as useMemo2 } from "react";
2703
+ import { Box as Box4, Text as Text4, useInput as useInput2 } from "ink";
2704
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
2705
+ function ServiceList({ title, services, onSelect, onClose, filterType }) {
2706
+ const allNames = useMemo2(
2707
+ () => [...services.keys()].filter((n) => !filterType || services.get(n).svc.type === filterType),
2708
+ [services, filterType]
2709
+ );
2710
+ const [idx, setIdx] = useState7(0);
2711
+ const [query, setQuery] = useState7("");
2712
+ const names = useMemo2(() => {
2713
+ if (!query) return allNames;
2714
+ const q = query.toLowerCase();
2715
+ return allNames.filter((n) => n.toLowerCase().includes(q));
2716
+ }, [allNames, query]);
2717
+ const clamped = Math.min(idx, Math.max(0, names.length - 1));
2718
+ useInput2((input, key) => {
2719
+ if (key.escape) {
2720
+ if (query) setQuery("");
2721
+ else onClose();
2722
+ return;
2723
+ }
2724
+ if (key.return) {
2725
+ if (names[clamped]) onSelect(names[clamped]);
2726
+ return;
2727
+ }
2728
+ if (key.upArrow) {
2729
+ setIdx((i) => Math.max(0, i - 1));
2730
+ return;
2731
+ }
2732
+ if (key.downArrow) {
2733
+ setIdx((i) => Math.min(names.length - 1, i + 1));
2734
+ return;
2735
+ }
2736
+ if (key.backspace || key.delete) {
2737
+ setQuery((q) => q.slice(0, -1));
2738
+ setIdx(0);
2739
+ return;
2740
+ }
2741
+ if (input && !key.ctrl && !key.meta && input.length === 1) {
2742
+ setQuery((q) => q + input);
2743
+ setIdx(0);
2744
+ }
2745
+ }, { isActive: process.stdin.isTTY ?? false });
2746
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
2747
+ /* @__PURE__ */ jsxs4(Text4, { bold: true, color: "cyan", children: [
2748
+ " ",
2749
+ title,
2750
+ " ",
2751
+ query && /* @__PURE__ */ jsxs4(Text4, { color: "yellow", children: [
2752
+ "[",
2753
+ query,
2754
+ "]"
2755
+ ] })
2756
+ ] }),
2757
+ 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: [
2758
+ " ",
2759
+ name,
2760
+ " :",
2761
+ services.get(name).svc.port,
2762
+ " "
2763
+ ] }) }, name)),
2764
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "type to filter \u2191\u2193 navigate Enter select Esc clear/close" })
2765
+ ] });
2766
+ }
2767
+
2768
+ // src/tui/SearchInput.tsx
2769
+ import { useState as useState8 } from "react";
2770
+ import { Box as Box5, Text as Text5, useInput as useInput3 } from "ink";
2771
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
2772
+ function SearchInput({ onSubmit, onClose }) {
2773
+ const [value, setValue] = useState8("");
2774
+ useInput3((input, key) => {
2775
+ if (key.escape) onClose();
2776
+ else if (key.return) onSubmit(value.trim() || null);
2777
+ else if (key.backspace || key.delete) setValue((v) => v.slice(0, -1));
2778
+ else if (input && !key.ctrl && !key.meta) setValue((v) => v + input);
2779
+ }, { isActive: process.stdin.isTTY ?? false });
2780
+ return /* @__PURE__ */ jsxs5(Box5, { borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
2781
+ /* @__PURE__ */ jsx5(Text5, { bold: true, color: "yellow", children: "Search: " }),
2782
+ /* @__PURE__ */ jsx5(Text5, { children: value }),
2783
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u2588" })
2784
+ ] });
2785
+ }
2786
+
2787
+ // src/tui/App.tsx
2788
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
2789
+ function buildServiceUrl(name, port, proxyActive, proxyOpts) {
2790
+ if (proxyActive && proxyOpts) {
2791
+ const sub = proxyOpts.routes[name];
2792
+ if (sub !== void 0) {
2793
+ const host = sub ? `${sub}.${proxyOpts.domain}` : proxyOpts.domain;
2794
+ const scheme = proxyOpts.tls ? "https" : "http";
2795
+ return `${scheme}://${host}`;
2796
+ }
2797
+ }
2798
+ return `http://localhost:${port}`;
2799
+ }
2800
+ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts, logSink }) {
2801
+ const rows = useTerminalSize();
2802
+ const logsHeight = Math.floor(rows * 0.65);
2803
+ const statsHeight = rows - logsHeight - 2;
2804
+ const maxNameLen = Math.max(...services.map((s) => s.name.length), 10);
2805
+ const pm = useProcessManager(platform, baseCwd, env, logSink);
2806
+ const lazyProxies = useRef5(/* @__PURE__ */ new Map());
2807
+ const externals = useRef5([]);
2808
+ const kb = useKeyBindings({
2809
+ onQuit: () => {
2810
+ void shutdown();
2811
+ },
2812
+ onClearLogs: pm.clearLogs,
2813
+ onToggleProxy: () => {
2814
+ }
2815
+ });
2816
+ const socketServer = useControlPlane(pm.manager, config.name, logSink, pm.pushLog);
2817
+ const shutdown = useCallback3(async () => {
2818
+ lazyProxies.current.forEach((p) => p.destroy());
2819
+ await socketServer.current?.close();
2820
+ await pm.cleanup();
2821
+ if (externals.current.length) {
2822
+ await stopExternals(externals.current, platform, {
2823
+ baseCwd,
2824
+ env,
2825
+ onLog: (svc, msg) => pm.pushLog(`ext:${svc}`, msg, 12)
2826
+ });
2827
+ externals.current = [];
2828
+ }
2829
+ await logSink?.close();
2830
+ process.exit(0);
2831
+ }, [pm, logSink, platform, baseCwd, env, socketServer]);
2832
+ useHotReload(pm.manager, cliArgs, baseCwd, pm.pushLog);
2833
+ useLogsPause(pm.setPaused, kb.logsPaused, kb.logsScrollOffset);
2834
+ const activeTip = useContextualTips(pm.logs.length, !!kb.searchTerm, !!kb.logFilter, pm.states);
2835
+ useProxySync(proxyProvider, proxyOpts, pm.states, kb.proxyEnabled);
2836
+ useBootSequence(
2837
+ pm.manager,
2838
+ config,
2839
+ services,
2840
+ cliArgs,
2841
+ platform,
2842
+ env,
2843
+ baseCwd,
2844
+ { lazyProxies, externals },
2845
+ pm.pushLog
2846
+ );
2331
2847
  const handleFilterSelect = useCallback3((name) => kb.setFilter(name), [kb]);
2332
2848
  const handleRestartSelect = useCallback3((name) => {
2333
2849
  pm.restart(name);
@@ -2374,7 +2890,8 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
2374
2890
  focused: kb.panel === "logs",
2375
2891
  scrollOffset: kb.logsScrollOffset,
2376
2892
  resetScroll: kb.resetLogsScroll,
2377
- levelFilter: kb.levelFilter
2893
+ levelFilter: kb.levelFilter,
2894
+ filteredColorIdx: kb.logFilter ? pm.states.get(kb.logFilter)?.colorIdx ?? null : null
2378
2895
  }
2379
2896
  ),
2380
2897
  /* @__PURE__ */ jsx6(
@@ -2400,23 +2917,23 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
2400
2917
  }
2401
2918
 
2402
2919
  // src/process/log-sink.ts
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";
2920
+ import { existsSync as existsSync13, mkdirSync as mkdirSync5, renameSync, createWriteStream } from "fs";
2921
+ import { join as join8, dirname as dirname6 } from "path";
2922
+ import { homedir as homedir3 } from "os";
2406
2923
  var LogSink = class {
2407
2924
  dir;
2408
2925
  rotateOnStart;
2409
2926
  streams = /* @__PURE__ */ new Map();
2410
2927
  seen = /* @__PURE__ */ new Set();
2411
2928
  constructor(opts) {
2412
- const root = opts.rootDir ?? join6(homedir2(), ".devup", "logs");
2413
- this.dir = join6(root, sanitize2(opts.projectName));
2929
+ const root = opts.rootDir ?? join8(homedir3(), ".devup", "logs");
2930
+ this.dir = join8(root, sanitize2(opts.projectName));
2414
2931
  this.rotateOnStart = opts.rotateOnStart ?? true;
2415
- mkdirSync4(this.dir, { recursive: true });
2932
+ mkdirSync5(this.dir, { recursive: true });
2416
2933
  }
2417
2934
  /** Returns the file path for a service log (useful for tests / UI). */
2418
2935
  pathFor(svcName) {
2419
- return join6(this.dir, `${sanitize2(svcName)}.log`);
2936
+ return join8(this.dir, `${sanitize2(svcName)}.log`);
2420
2937
  }
2421
2938
  write(svcName, line) {
2422
2939
  const stream = this.streamFor(svcName);
@@ -2435,9 +2952,9 @@ var LogSink = class {
2435
2952
  let s = this.streams.get(svcName);
2436
2953
  if (s) return s;
2437
2954
  const file = this.pathFor(svcName);
2438
- if (this.rotateOnStart && !this.seen.has(svcName) && existsSync10(file)) {
2955
+ if (this.rotateOnStart && !this.seen.has(svcName) && existsSync13(file)) {
2439
2956
  try {
2440
- mkdirSync4(dirname5(file), { recursive: true });
2957
+ mkdirSync5(dirname6(file), { recursive: true });
2441
2958
  renameSync(file, file + ".prev");
2442
2959
  } catch {
2443
2960
  }
@@ -2623,9 +3140,9 @@ function defineConfig(config) {
2623
3140
  // src/index.ts
2624
3141
  function readVersion() {
2625
3142
  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";
3143
+ const here = dirname7(fileURLToPath2(import.meta.url));
3144
+ const pkgPath = join9(here, "..", "package.json");
3145
+ return JSON.parse(readFileSync3(pkgPath, "utf8")).version ?? "unknown";
2629
3146
  } catch {
2630
3147
  return "unknown";
2631
3148
  }
@@ -2675,6 +3192,11 @@ async function main() {
2675
3192
  ${formatValidationErrors(errors)}`);
2676
3193
  process.exit(1);
2677
3194
  }
3195
+ const warnings = collectWarnings(config);
3196
+ if (warnings.length) {
3197
+ console.warn(`\u26A0 Config warnings:
3198
+ ${formatValidationWarnings(warnings)}`);
3199
+ }
2678
3200
  let services;
2679
3201
  try {
2680
3202
  services = filterServices(config.services, cliArgs, config);
@@ -2687,7 +3209,7 @@ ${formatValidationErrors(errors)}`);
2687
3209
  process.exit(1);
2688
3210
  }
2689
3211
  const platform = await detectPlatform();
2690
- const envFile = config.envFile ? join7(cwd, config.envFile) : join7(cwd, ".env");
3212
+ const envFile = config.envFile ? join9(cwd, config.envFile) : join9(cwd, ".env");
2691
3213
  const env = parseEnvFile(envFile, process.env);
2692
3214
  if (config.env) {
2693
3215
  for (const [k, v] of Object.entries(config.env)) {
@@ -2704,7 +3226,7 @@ ${formatValidationErrors(errors)}`);
2704
3226
  routes: config.proxy.routes,
2705
3227
  tls: cliArgs.proxyTls ?? config.proxy.tls ?? true,
2706
3228
  entrypoint: cliArgs.proxyEntrypoint ?? config.proxy.entrypoint ?? "websecure",
2707
- confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join7(homedir3(), ".traefik", "traefik_conf.yaml")
3229
+ confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join9(homedir4(), ".traefik", "traefik_conf.yaml")
2708
3230
  };
2709
3231
  }
2710
3232
  if (cliArgs.dryRun) {