@gachlab/devup 0.7.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 (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/index.js +1050 -856
  3. package/dist/index.js.map +1 -1
  4. package/dist/process/health-poller.d.ts +15 -0
  5. package/dist/process/health-poller.d.ts.map +1 -0
  6. package/dist/process/internals.d.ts +14 -0
  7. package/dist/process/internals.d.ts.map +1 -0
  8. package/dist/process/lifecycle.d.ts +31 -0
  9. package/dist/process/lifecycle.d.ts.map +1 -0
  10. package/dist/process/manager.d.ts +11 -13
  11. package/dist/process/manager.d.ts.map +1 -1
  12. package/dist/process/restarter.d.ts +26 -0
  13. package/dist/process/restarter.d.ts.map +1 -0
  14. package/dist/process/spawner.d.ts +38 -0
  15. package/dist/process/spawner.d.ts.map +1 -0
  16. package/dist/tui/App.d.ts.map +1 -1
  17. package/dist/tui/hooks/useBootSequence.d.ts +20 -0
  18. package/dist/tui/hooks/useBootSequence.d.ts.map +1 -0
  19. package/dist/tui/hooks/useContextualTips.d.ts +6 -0
  20. package/dist/tui/hooks/useContextualTips.d.ts.map +1 -0
  21. package/dist/tui/hooks/useControlPlane.d.ts +10 -0
  22. package/dist/tui/hooks/useControlPlane.d.ts.map +1 -0
  23. package/dist/tui/hooks/useHotReload.d.ts +7 -0
  24. package/dist/tui/hooks/useHotReload.d.ts.map +1 -0
  25. package/dist/tui/hooks/useLogsPause.d.ts +4 -0
  26. package/dist/tui/hooks/useLogsPause.d.ts.map +1 -0
  27. package/dist/tui/hooks/useTerminalSize.d.ts +4 -0
  28. package/dist/tui/hooks/useTerminalSize.d.ts.map +1 -0
  29. package/dist/utils/colors.d.ts +4 -0
  30. package/dist/utils/colors.d.ts.map +1 -0
  31. package/dist/utils/env.d.ts +5 -0
  32. package/dist/utils/env.d.ts.map +1 -0
  33. package/dist/utils/format.d.ts +3 -0
  34. package/dist/utils/format.d.ts.map +1 -0
  35. package/dist/utils/install-stamp.d.ts +6 -0
  36. package/dist/utils/install-stamp.d.ts.map +1 -0
  37. package/dist/utils/phases.d.ts +4 -0
  38. package/dist/utils/phases.d.ts.map +1 -0
  39. package/dist/utils/process-args.d.ts +8 -0
  40. package/dist/utils/process-args.d.ts.map +1 -0
  41. package/dist/utils/redact.d.ts +4 -0
  42. package/dist/utils/redact.d.ts.map +1 -0
  43. package/dist/utils/search.d.ts +17 -0
  44. package/dist/utils/search.d.ts.map +1 -0
  45. package/dist/utils/stats.d.ts +19 -0
  46. package/dist/utils/stats.d.ts.map +1 -0
  47. package/dist/utils.d.ts +10 -41
  48. package/dist/utils.d.ts.map +1 -1
  49. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3,8 +3,8 @@
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 dirname7, join as join8 } 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
9
  import { homedir as homedir4 } from "os";
10
10
 
@@ -430,7 +430,7 @@ function filterServices(services, args, config) {
430
430
 
431
431
  // src/orchestrator/subcommands.ts
432
432
  import { spawn } from "child_process";
433
- import { createReadStream, watchFile, unwatchFile, existsSync as existsSync4, statSync } from "fs";
433
+ import { createReadStream, watchFile, unwatchFile, existsSync as existsSync5, statSync } from "fs";
434
434
  import { readFile as readFile2 } from "fs/promises";
435
435
  import { join as join3, dirname } from "path";
436
436
  import { fileURLToPath } from "url";
@@ -512,10 +512,8 @@ function deriveHealth(isUp, currentStatus) {
512
512
  return currentStatus === "starting" ? "wait" : "down";
513
513
  }
514
514
 
515
- // src/utils.ts
516
- import { existsSync as existsSync3, readFileSync, writeFileSync } from "fs";
517
- import { createHash } from "crypto";
518
- import { join as join2 } from "path";
515
+ // src/utils/env.ts
516
+ import { existsSync as existsSync3, readFileSync } from "fs";
519
517
  function parseEnvFile(filePath, baseEnv = {}) {
520
518
  const env = { ...baseEnv };
521
519
  if (!existsSync3(filePath)) return env;
@@ -533,25 +531,21 @@ function parseEnvFile(filePath, baseEnv = {}) {
533
531
  }
534
532
  return env;
535
533
  }
536
- function nextRamBannerVisibility(usagePct, previousVisible, highWatermark = 80, lowWatermark = 75) {
537
- if (usagePct >= highWatermark) return true;
538
- if (usagePct < lowWatermark) return false;
539
- return previousVisible;
540
- }
541
- function redactSecrets(env) {
542
- if (!env) return {};
543
- const out = {};
544
- for (const [k, v] of Object.entries(env)) {
545
- out[k] = /secret|token|password|api[_-]?key|auth/i.test(k) ? "***" : v;
546
- }
547
- return out;
548
- }
549
- function detectLogLevel(line) {
550
- const l = line.toLowerCase();
551
- if (/\b(?:error|err|fail(?:ed|ure|ures|s)?|fatal|exception|crash(?:ed|es)?)\b/.test(l) || /❌|✗|⛔/.test(line)) return "error";
552
- if (/\b(?:warn(?:ed|ing|s|ings)?|deprec)\b/.test(l) || /⚠/.test(line)) return "warn";
553
- 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`;
554
546
  }
547
+
548
+ // src/utils/search.ts
555
549
  function compileSearchPattern(term) {
556
550
  if (!term) return null;
557
551
  const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(term);
@@ -568,51 +562,47 @@ function compileSearchPattern(term) {
568
562
  const lower = term.toLowerCase();
569
563
  return { test: (l) => l.toLowerCase().includes(lower) };
570
564
  }
571
- function fmtUptime(ms) {
572
- if (!ms || ms < 0) return "-";
573
- const s = Math.floor(ms / 1e3);
574
- if (s < 60) return `${s}s`;
575
- const m = Math.floor(s / 60);
576
- if (m < 60) return `${m}m${s % 60}s`;
577
- const h = Math.floor(m / 60);
578
- if (h < 24) return `${h}h${m % 60}m`;
579
- const d = Math.floor(h / 24);
580
- 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;
581
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";
582
586
  function needsInstall(fullCwd) {
583
587
  const nm = join2(fullCwd, "node_modules");
584
- if (!existsSync3(nm)) return true;
588
+ if (!existsSync4(nm)) return true;
585
589
  try {
586
- 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");
587
591
  const stampFile = join2(nm, ".install-stamp");
588
- if (existsSync3(stampFile) && readFileSync(stampFile, "utf8") === pkgHash) return false;
592
+ if (existsSync4(stampFile) && readFileSync2(stampFile, "utf8") === pkgHash) return false;
589
593
  } catch {
590
594
  }
591
595
  return true;
592
596
  }
593
597
  function writeInstallStamp(fullCwd) {
594
598
  try {
595
- 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");
596
600
  writeFileSync(join2(fullCwd, "node_modules", ".install-stamp"), pkgHash);
597
601
  } catch {
598
602
  }
599
603
  }
600
- function sortServiceNames(names, sortMode, statsMap, procState) {
601
- if (sortMode === "name") return names.slice().sort();
602
- return names.slice().sort((a, b) => {
603
- if (sortMode === "mem") {
604
- return (parseFloat(statsMap[b]?.mem ?? "0") || 0) - (parseFloat(statsMap[a]?.mem ?? "0") || 0);
605
- }
606
- return (procState[b]?.errors ?? 0) - (procState[a]?.errors ?? 0);
607
- });
608
- }
609
- function groupByPhase(services) {
610
- const phases = {};
611
- for (const s of services) {
612
- (phases[s.phase] ??= []).push(s);
613
- }
614
- return phases;
615
- }
604
+
605
+ // src/utils/process-args.ts
616
606
  function buildProcessArgs(svc) {
617
607
  const extra = svc.nodeArgs ?? [];
618
608
  if (!svc.maxMem) return [...extra, ...svc.args];
@@ -630,11 +620,38 @@ function buildProcessEnv(svc, baseEnv) {
630
620
  }
631
621
  return env;
632
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
+ }
633
634
  function calcCpuPercent(totalCpuSec, prevCpu, prevTime) {
634
635
  const elapsed = (Date.now() - prevTime) / 1e3;
635
636
  const cpuDelta = totalCpuSec - prevCpu;
636
637
  return elapsed > 0 ? cpuDelta / elapsed * 100 : 0;
637
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
638
655
  var tagColors = [
639
656
  "cyan",
640
657
  "yellow",
@@ -678,7 +695,7 @@ async function runLogs(argv, opts) {
678
695
  return 1;
679
696
  }
680
697
  const file = join3(logRoot(opts.config, opts.logDir), `${sanitize(svcArg)}.log`);
681
- if (!existsSync4(file)) {
698
+ if (!existsSync5(file)) {
682
699
  out(`No log file yet for "${svcArg}" (${file})`);
683
700
  return follow ? await followFile(file, out) : 1;
684
701
  }
@@ -696,7 +713,7 @@ async function streamFile(file, out) {
696
713
  }
697
714
  async function followFile(file, out, startAt = 0) {
698
715
  let pos = startAt;
699
- while (!existsSync4(file)) await new Promise((r) => setTimeout(r, 500));
716
+ while (!existsSync5(file)) await new Promise((r) => setTimeout(r, 500));
700
717
  return new Promise((resolve4) => {
701
718
  const tick = async () => {
702
719
  const size = statSync(file).size;
@@ -758,7 +775,7 @@ ${items.length} services up to date`);
758
775
  return 0;
759
776
  }
760
777
  function installOne(cwd, env) {
761
- if (!existsSync4(cwd)) return Promise.resolve(false);
778
+ if (!existsSync5(cwd)) return Promise.resolve(false);
762
779
  if (!needsInstall(cwd)) return Promise.resolve(true);
763
780
  return new Promise((resolve4) => {
764
781
  const command = process.platform === "win32" ? "npm.cmd" : "npm";
@@ -837,7 +854,7 @@ async function detectPlatform() {
837
854
  }
838
855
 
839
856
  // src/proxy-config/traefik.ts
840
- import { existsSync as existsSync5, mkdirSync, writeFileSync as writeFileSync2 } from "fs";
857
+ import { existsSync as existsSync6, mkdirSync, writeFileSync as writeFileSync2 } from "fs";
841
858
  import { dirname as dirname2 } from "path";
842
859
  var EMPTY_CONFIG = "http:\n routers: {}\n services: {}\n";
843
860
  var TraefikProvider = class {
@@ -876,7 +893,7 @@ ${svcs.join("\n")}
876
893
  }
877
894
  write(content, opts) {
878
895
  const dir = dirname2(opts.confPath);
879
- if (!existsSync5(dir)) mkdirSync(dir, { recursive: true });
896
+ if (!existsSync6(dir)) mkdirSync(dir, { recursive: true });
880
897
  writeFileSync2(opts.confPath, content);
881
898
  }
882
899
  clear(opts) {
@@ -885,7 +902,7 @@ ${svcs.join("\n")}
885
902
  };
886
903
 
887
904
  // src/proxy-config/nginx.ts
888
- 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";
889
906
  import { dirname as dirname3 } from "path";
890
907
  var EMPTY_CONFIG2 = "# devup: no healthy services\n";
891
908
  var NginxProvider = class {
@@ -924,7 +941,7 @@ var NginxProvider = class {
924
941
  }
925
942
  write(content, opts) {
926
943
  const dir = dirname3(opts.confPath);
927
- if (!existsSync6(dir)) mkdirSync2(dir, { recursive: true });
944
+ if (!existsSync7(dir)) mkdirSync2(dir, { recursive: true });
928
945
  writeFileSync3(opts.confPath, content);
929
946
  }
930
947
  clear(opts) {
@@ -933,7 +950,7 @@ var NginxProvider = class {
933
950
  };
934
951
 
935
952
  // src/proxy-config/caddy.ts
936
- 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";
937
954
  import { dirname as dirname4 } from "path";
938
955
  var EMPTY_CONFIG3 = "# devup: no healthy services\n";
939
956
  var CaddyProvider = class {
@@ -958,7 +975,7 @@ var CaddyProvider = class {
958
975
  }
959
976
  write(content, opts) {
960
977
  const dir = dirname4(opts.confPath);
961
- if (!existsSync7(dir)) mkdirSync3(dir, { recursive: true });
978
+ if (!existsSync8(dir)) mkdirSync3(dir, { recursive: true });
962
979
  writeFileSync4(opts.confPath, content);
963
980
  }
964
981
  clear(opts) {
@@ -982,22 +999,20 @@ function detectProxyProvider(name) {
982
999
  }
983
1000
 
984
1001
  // src/tui/App.tsx
985
- import { useEffect as useEffect5, useState as useState6, useCallback as useCallback3, useRef as useRef3 } from "react";
986
- 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";
987
1004
 
988
1005
  // src/tui/hooks/useProcessManager.ts
989
1006
  import { useState, useEffect, useRef, useCallback } from "react";
990
1007
 
991
1008
  // src/process/manager.ts
992
- import { spawn as spawn3 } from "child_process";
993
- import { existsSync as existsSync9 } from "fs";
994
- import { join as join4, resolve as resolve3 } from "path";
1009
+ import { join as join5 } from "path";
995
1010
 
996
1011
  // src/process/installer.ts
997
1012
  import { spawn as spawn2 } from "child_process";
998
- import { existsSync as existsSync8 } from "fs";
1013
+ import { existsSync as existsSync9 } from "fs";
999
1014
  function installService(cwd, env, onLog) {
1000
- if (!existsSync8(cwd)) {
1015
+ if (!existsSync9(cwd)) {
1001
1016
  onLog?.(`\u26A0 directory not found: ${cwd}`);
1002
1017
  return Promise.resolve(false);
1003
1018
  }
@@ -1030,9 +1045,32 @@ function installService(cwd, env, onLog) {
1030
1045
  });
1031
1046
  }
1032
1047
 
1033
- // src/process/manager.ts
1034
- var MAX_RESTARTS = 3;
1035
- 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
+ }
1036
1074
  function compileReadyPattern(pattern) {
1037
1075
  if (!pattern) return null;
1038
1076
  const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(pattern);
@@ -1063,43 +1101,26 @@ function extractWatchPaths(args) {
1063
1101
  }
1064
1102
  return out;
1065
1103
  }
1066
- function lineBuffer(onLine) {
1067
- let buf = "";
1068
- return {
1069
- push(chunk) {
1070
- buf += chunk.toString();
1071
- let idx;
1072
- while ((idx = buf.indexOf("\n")) !== -1) {
1073
- const line = buf.slice(0, idx).replace(/\r$/, "");
1074
- buf = buf.slice(idx + 1);
1075
- if (line.length) onLine(line);
1076
- }
1077
- },
1078
- flush() {
1079
- if (buf.length) {
1080
- onLine(buf);
1081
- buf = "";
1082
- }
1083
- }
1084
- };
1085
- }
1086
- var ProcessManager = class {
1087
- state = /* @__PURE__ */ new Map();
1088
- 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 {
1089
1109
  baseCwd;
1090
1110
  env;
1091
- platform;
1111
+ state;
1112
+ procs;
1092
1113
  events;
1114
+ lifecycle;
1115
+ onCrash;
1093
1116
  constructor(opts) {
1094
1117
  this.baseCwd = opts.baseCwd;
1095
1118
  this.env = opts.env;
1096
- this.platform = opts.platform;
1119
+ this.state = opts.state;
1120
+ this.procs = opts.procs;
1097
1121
  this.events = opts.events;
1098
- }
1099
- async install(svc, colorIdx) {
1100
- const cwd = join4(this.baseCwd, svc.cwd);
1101
- const idx = colorIdx ?? this.state.get(svc.name)?.colorIdx ?? 0;
1102
- return installService(cwd, this.env, (msg) => this.log(svc.name, msg, idx));
1122
+ this.lifecycle = opts.lifecycle;
1123
+ this.onCrash = opts.onCrash;
1103
1124
  }
1104
1125
  async start(svc, colorIdx, isRestart = false) {
1105
1126
  const cwd = join4(this.baseCwd, svc.cwd);
@@ -1118,7 +1139,7 @@ var ProcessManager = class {
1118
1139
  }
1119
1140
  }
1120
1141
  const args = buildProcessArgs(svc);
1121
- const missingWatchPaths = extractWatchPaths(args).filter((p) => !existsSync9(resolve3(cwd, p)));
1142
+ const missingWatchPaths = extractWatchPaths(args).filter((p) => !existsSync10(resolve3(cwd, p)));
1122
1143
  if (missingWatchPaths.length) {
1123
1144
  this.log(svc.name, `\u26A0 missing watch paths: ${missingWatchPaths.join(", ")}`, colorIdx);
1124
1145
  this.recordCrashedState(svc, colorIdx);
@@ -1142,6 +1163,14 @@ var ProcessManager = class {
1142
1163
  this.state.set(svc.name, state);
1143
1164
  this.procs.add(proc);
1144
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) {
1145
1174
  const readyRegex = compileReadyPattern(svc.readyPattern);
1146
1175
  const markReadyIfMatch = (line) => {
1147
1176
  if (!readyRegex || state.health === "up") return;
@@ -1166,9 +1195,11 @@ var ProcessManager = class {
1166
1195
  proc.stderr?.on("data", (d) => stderrBuf.push(d));
1167
1196
  proc.stdout?.on("end", () => stdoutBuf.flush());
1168
1197
  proc.stderr?.on("end", () => stderrBuf.flush());
1198
+ }
1199
+ wireCloseHandler(proc, svc, state, colorIdx) {
1169
1200
  proc.on("close", (code) => {
1170
1201
  this.procs.delete(proc);
1171
- this.stopWatchProc(state);
1202
+ this.lifecycle.stopWatchProc(state);
1172
1203
  if (state.intentionalStop) {
1173
1204
  state.intentionalStop = false;
1174
1205
  return;
@@ -1183,23 +1214,12 @@ var ProcessManager = class {
1183
1214
  state.health = "down";
1184
1215
  this.log(svc.name, `\u274C exited with code ${code}`, colorIdx);
1185
1216
  this.events.onStateChange(svc.name, state);
1186
- if (state.restarts < MAX_RESTARTS) {
1187
- state.restarts++;
1188
- const delay = BACKOFF_BASE_MS * Math.pow(2, state.restarts - 1);
1189
- this.log(svc.name, `\u{1F504} auto-restart ${state.restarts}/${MAX_RESTARTS} in ${delay}ms...`, colorIdx);
1190
- setTimeout(() => this.start(svc, colorIdx, true), delay);
1191
- } else {
1192
- this.log(svc.name, "\u26D4 max restarts reached", colorIdx);
1193
- }
1217
+ this.onCrash(svc, state, colorIdx);
1194
1218
  });
1195
- if (svc.watchBuild) {
1196
- state.watchProc = this.spawnWatchBuild(svc, cwd, env, colorIdx);
1197
- }
1198
- this.log(svc.name, isRestart ? `\u{1F504} restarted (:${svc.port})` : `\u{1F680} started (:${svc.port})`, colorIdx);
1199
1219
  }
1200
1220
  runPreBuild(svc, cwd, colorIdx) {
1201
1221
  this.log(svc.name, `\u{1F528} preBuild: ${svc.preBuild}`, colorIdx);
1202
- return new Promise((resolve4) => {
1222
+ return new Promise((res) => {
1203
1223
  const isWin = process.platform === "win32";
1204
1224
  const shell = isWin ? "cmd.exe" : "sh";
1205
1225
  const shellFlag = isWin ? "/c" : "-c";
@@ -1211,17 +1231,17 @@ var ProcessManager = class {
1211
1231
  child.stderr?.on("data", (d) => errBuf.push(d));
1212
1232
  child.on("error", (err) => {
1213
1233
  this.log(svc.name, `[build] \u274C ${err.message}`, colorIdx);
1214
- resolve4(false);
1234
+ res(false);
1215
1235
  });
1216
1236
  child.on("close", (code) => {
1217
1237
  outBuf.flush();
1218
1238
  errBuf.flush();
1219
1239
  if (code === 0) {
1220
1240
  this.log(svc.name, `[build] \u2705 done`, colorIdx);
1221
- resolve4(true);
1241
+ res(true);
1222
1242
  } else {
1223
1243
  this.log(svc.name, `[build] \u274C exited with code ${code}`, colorIdx);
1224
- resolve4(false);
1244
+ res(false);
1225
1245
  }
1226
1246
  });
1227
1247
  });
@@ -1244,7 +1264,8 @@ var ProcessManager = class {
1244
1264
  child.on("error", (err) => this.log(svc.name, `[watch] \u274C ${err.message}`, colorIdx));
1245
1265
  return child;
1246
1266
  }
1247
- /** 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). */
1248
1269
  recordCrashedState(svc, colorIdx) {
1249
1270
  const prev = this.state.get(svc.name);
1250
1271
  this.state.set(svc.name, {
@@ -1261,33 +1282,54 @@ var ProcessManager = class {
1261
1282
  });
1262
1283
  this.events.onStateChange(svc.name, this.state.get(svc.name));
1263
1284
  }
1264
- stop(name) {
1265
- const st = this.state.get(name);
1266
- if (!st?.proc || !st.pid) return;
1267
- st.intentionalStop = true;
1268
- this.platform.killTree(st.pid);
1269
- this.stopWatchProc(st);
1285
+ log(name, text, colorIdx) {
1286
+ this.events.onLog(name, text, colorIdx);
1270
1287
  }
1271
- stopWatchProc(state) {
1272
- const wp = state.watchProc;
1273
- if (!wp || !wp.pid) return;
1274
- try {
1275
- this.platform.killTree(wp.pid);
1276
- } catch {
1277
- }
1278
- 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;
1279
1301
  }
1280
1302
  async restart(name) {
1281
1303
  const st = this.state.get(name);
1282
1304
  if (!st) return;
1283
- this.stop(name);
1305
+ this.lifecycle.stop(name);
1284
1306
  st.restarts = 0;
1285
1307
  const delay = st.proc ? 1500 : 100;
1286
1308
  await new Promise((r) => setTimeout(r, delay));
1287
- await this.start(st.svc, st.colorIdx, true);
1288
- 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;
1289
1331
  }
1290
- async checkAllHealth() {
1332
+ async checkAll() {
1291
1333
  for (const [name, st] of this.state) {
1292
1334
  if (!st.pid || st.status === "idle") {
1293
1335
  st.health = st.status === "idle" ? "idle" : "down";
@@ -1304,6 +1346,41 @@ var ProcessManager = class {
1304
1346
  if (prev !== st.health) this.events.onStateChange(name, st);
1305
1347
  }
1306
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. */
1307
1384
  async cleanup(opts = {}) {
1308
1385
  const grace = opts.gracePeriodMs ?? 3e3;
1309
1386
  const procs = [...this.procs];
@@ -1344,8 +1421,69 @@ var ProcessManager = class {
1344
1421
  for (const st of this.state.values()) if (st.proc === proc) return st;
1345
1422
  return void 0;
1346
1423
  }
1347
- log(name, text, colorIdx) {
1348
- this.events.onLog(name, text, colorIdx);
1424
+ };
1425
+
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);
1349
1487
  }
1350
1488
  };
1351
1489
 
@@ -1574,73 +1712,352 @@ function useProxySync(provider, opts, states, enabled) {
1574
1712
  }, [provider, opts, enabled]);
1575
1713
  }
1576
1714
 
1577
- // src/tui/LogsPanel.tsx
1578
- import { useEffect as useEffect3, useMemo } from "react";
1579
- import { Box, Text } from "ink";
1580
- import { jsx, jsxs } from "react/jsx-runtime";
1581
- function resolveBorder(focused, filter, filteredColorIdx) {
1582
- if (focused) return "cyan";
1583
- if (filter && filteredColorIdx !== null && filteredColorIdx >= 0) {
1584
- return tagColors[filteredColorIdx % tagColors.length];
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);
1721
+ useEffect3(() => {
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
+ }
1585
1765
  }
1586
- return "gray";
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
+ };
1587
1792
  }
1588
- function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLen, height, focused, scrollOffset, resetScroll, levelFilter = "all", filteredColorIdx = null }) {
1589
- const byService = filter ? logs.filter((l) => l.svcName === filter) : logs;
1590
- const filtered = levelFilter === "all" ? byService : levelFilter === "error" ? byService.filter((l) => l.level === "error") : byService.filter((l) => l.level === "error" || l.level === "warn");
1591
- const contentHeight = Math.max(1, height - 2);
1592
- const totalLines = filtered.length;
1593
- const maxOffset = Math.max(0, totalLines - contentHeight);
1594
- const effectiveOffset = scrollOffset === Number.MAX_SAFE_INTEGER ? maxOffset : Math.min(scrollOffset, maxOffset);
1595
- const startIndex = Math.max(0, totalLines - contentHeight - effectiveOffset);
1596
- const endIndex = Math.min(startIndex + contentHeight, totalLines);
1597
- const visible = filtered.slice(startIndex, endIndex);
1598
- useEffect3(() => {
1599
- resetScroll();
1600
- }, [filter, searchTerm, resetScroll]);
1601
- const matcher = useMemo(() => compileSearchPattern(searchTerm), [searchTerm]);
1602
- const scrolled = effectiveOffset > 0;
1603
- const label = [
1604
- "Logs",
1605
- filter ? `[${filter}]` : "",
1606
- searchTerm ? `/${searchTerm}` : "",
1607
- matcher?.invalid ? "(invalid regex)" : "",
1608
- levelFilter !== "all" ? `[level: ${levelFilter}${levelFilter === "warn" ? "+error" : ""}]` : "",
1609
- paused ? "[PAUSED]" : "",
1610
- scrolled ? "[SCROLL]" : "",
1611
- `${filtered.length} lines`,
1612
- focused && totalLines > 0 ? `(${startIndex + 1}-${endIndex}/${totalLines})` : ""
1613
- ].filter(Boolean).join(" ");
1614
- return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: resolveBorder(focused, filter, filteredColorIdx), height, children: [
1615
- /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { bold: true, color: "cyan", children: [
1616
- " ",
1617
- label,
1618
- " "
1619
- ] }) }),
1620
- visible.map((entry, i) => {
1621
- const color = tagColors[entry.colorIdx % tagColors.length];
1622
- const ts = showTimestamps ? new Date(entry.ts).toLocaleTimeString("en-GB") + " " : "";
1623
- const line = entry.text;
1624
- const isMatch = matcher ? matcher.test(line) : false;
1625
- return /* @__PURE__ */ jsxs(Box, { children: [
1626
- showTimestamps && /* @__PURE__ */ jsx(Text, { dimColor: true, children: ts }),
1627
- /* @__PURE__ */ jsxs(Text, { color, children: [
1628
- "[",
1629
- entry.svcName.padEnd(maxNameLen),
1630
- "]"
1631
- ] }),
1632
- /* @__PURE__ */ jsx(Text, { children: " " }),
1633
- isMatch ? /* @__PURE__ */ jsx(Text, { backgroundColor: "yellow", color: "black", children: line }) : /* @__PURE__ */ jsx(Text, { children: line })
1634
- ] }, i);
1635
- })
1636
- ] });
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;
1637
2054
  }
1638
2055
 
1639
2056
  // src/tui/StatsPanel.tsx
1640
- import { useEffect as useEffect4, useState as useState3 } from "react";
1641
- 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";
1642
2059
  import os from "os";
1643
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
2060
+ import { jsx, jsxs } from "react/jsx-runtime";
1644
2061
  var H = {
1645
2062
  up: { c: "\u25CF", color: "green" },
1646
2063
  wait: { c: "\u25CF", color: "yellow" },
@@ -1653,20 +2070,20 @@ function isCrashLooped(st) {
1653
2070
  }
1654
2071
  function Row({ name, st, stat, ml, verbose }) {
1655
2072
  const looped = isCrashLooped(st);
1656
- 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 });
1657
2074
  const color = tagColors[st.colorIdx % tagColors.length];
1658
2075
  const sc = looped ? "red" : st.status === "running" ? "green" : st.status === "starting" ? "yellow" : st.status === "idle" ? "blue" : "red";
1659
2076
  const statusLabel = looped ? "looping" : st.status;
1660
2077
  const up = st.startedAt ? fmtUptime(Date.now() - st.startedAt) : "-";
1661
2078
  if (!verbose) {
1662
- return /* @__PURE__ */ jsxs2(Text2, { children: [
2079
+ return /* @__PURE__ */ jsxs(Text, { children: [
1663
2080
  indicator,
1664
2081
  " ",
1665
- /* @__PURE__ */ jsx2(Text2, { color, children: name.padEnd(ml) }),
2082
+ /* @__PURE__ */ jsx(Text, { color, children: name.padEnd(ml) }),
1666
2083
  " ",
1667
2084
  String(st.svc.port).padStart(5),
1668
2085
  " ",
1669
- /* @__PURE__ */ jsx2(Text2, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
2086
+ /* @__PURE__ */ jsx(Text, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
1670
2087
  " ",
1671
2088
  (stat?.cpu ?? "-").padStart(6),
1672
2089
  " ",
@@ -1682,15 +2099,15 @@ function Row({ name, st, stat, ml, verbose }) {
1682
2099
  const resolvedArgs = buildProcessArgs(st.svc).join(" ");
1683
2100
  const env = redactSecrets(st.svc.extraEnv);
1684
2101
  const envStr = Object.entries(env).map(([k, v]) => `${k}=${v}`).join(" ");
1685
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
1686
- /* @__PURE__ */ jsxs2(Text2, { children: [
2102
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2103
+ /* @__PURE__ */ jsxs(Text, { children: [
1687
2104
  indicator,
1688
2105
  " ",
1689
- /* @__PURE__ */ jsx2(Text2, { color, children: name.padEnd(ml) }),
2106
+ /* @__PURE__ */ jsx(Text, { color, children: name.padEnd(ml) }),
1690
2107
  " ",
1691
2108
  String(st.svc.port).padStart(5),
1692
2109
  " ",
1693
- /* @__PURE__ */ jsx2(Text2, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
2110
+ /* @__PURE__ */ jsx(Text, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
1694
2111
  " ",
1695
2112
  (stat?.cpu ?? "-").padStart(6),
1696
2113
  " ",
@@ -1702,20 +2119,20 @@ function Row({ name, st, stat, ml, verbose }) {
1702
2119
  " ",
1703
2120
  up.padStart(6)
1704
2121
  ] }),
1705
- /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
2122
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
1706
2123
  " cmd: ",
1707
2124
  st.svc.cmd,
1708
2125
  " ",
1709
2126
  resolvedArgs
1710
2127
  ] }),
1711
- envStr && /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
2128
+ envStr && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
1712
2129
  " env: ",
1713
2130
  envStr
1714
2131
  ] })
1715
2132
  ] });
1716
2133
  }
1717
2134
  function ColHeader({ ml }) {
1718
- return /* @__PURE__ */ jsxs2(Text2, { bold: true, children: [
2135
+ return /* @__PURE__ */ jsxs(Text, { bold: true, children: [
1719
2136
  "H ",
1720
2137
  "Service".padEnd(ml),
1721
2138
  " ",
@@ -1762,7 +2179,7 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1762
2179
  const webStartIndex = Math.min(effectiveOffset, Math.max(0, webs.length - rowsPerCol));
1763
2180
  const visibleApis = apis.slice(apiStartIndex, apiStartIndex + rowsPerCol);
1764
2181
  const visibleWebs = webs.slice(webStartIndex, webStartIndex + rowsPerCol);
1765
- useEffect4(() => {
2182
+ useEffect7(() => {
1766
2183
  resetScroll();
1767
2184
  }, [sortMode, resetScroll]);
1768
2185
  const totalRowsLong = Math.max(apis.length, webs.length);
@@ -1770,24 +2187,24 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1770
2187
  const scrolled = effectiveOffset > 0;
1771
2188
  const loopedCount = [...states.values()].filter(isCrashLooped).length;
1772
2189
  const ramPct = parseFloat(usedGB) / parseFloat(totalGB) * 100;
1773
- const [ramBanner, setRamBanner] = useState3(false);
1774
- useEffect4(() => {
2190
+ const [ramBanner, setRamBanner] = useState4(false);
2191
+ useEffect7(() => {
1775
2192
  setRamBanner((prev) => nextRamBannerVisibility(ramPct, prev));
1776
2193
  }, [ramPct]);
1777
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) : [];
1778
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "green" : "gray", height, children: [
1779
- /* @__PURE__ */ jsxs2(Box2, { children: [
1780
- /* @__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: [
1781
2198
  " Stats ",
1782
2199
  positionInfo
1783
2200
  ] }),
1784
- scrolled && /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: " [SCROLL]" }),
1785
- 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: [
1786
2203
  " \u26A0 ",
1787
2204
  loopedCount,
1788
2205
  " need attention"
1789
2206
  ] }),
1790
- /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
2207
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
1791
2208
  " System: ",
1792
2209
  cpus,
1793
2210
  "c Load ",
@@ -1798,8 +2215,8 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1798
2215
  totalGB,
1799
2216
  "GB"
1800
2217
  ] }),
1801
- /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: " \u2502 " }),
1802
- /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
2218
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " \u2502 " }),
2219
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
1803
2220
  "Stack: CPU ",
1804
2221
  totalCpu.toFixed(1),
1805
2222
  "% RAM ",
@@ -1811,171 +2228,68 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1811
2228
  " Svcs ",
1812
2229
  names.length
1813
2230
  ] }),
1814
- sortMode !== "name" && /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
2231
+ sortMode !== "name" && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
1815
2232
  " \u2502 Sort: ",
1816
2233
  sortMode
1817
2234
  ] })
1818
2235
  ] }),
1819
- ramBanner && /* @__PURE__ */ jsxs2(Box2, { children: [
1820
- /* @__PURE__ */ jsxs2(Text2, { color: "yellow", bold: true, children: [
2236
+ ramBanner && /* @__PURE__ */ jsxs(Box, { children: [
2237
+ /* @__PURE__ */ jsxs(Text, { color: "yellow", bold: true, children: [
1821
2238
  " \u26A0 RAM ",
1822
2239
  ramPct.toFixed(0),
1823
2240
  "% \u2014 top: "
1824
2241
  ] }),
1825
- /* @__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(", ") })
1826
2243
  ] }),
1827
- /* @__PURE__ */ jsxs2(Box2, { flexGrow: 1, children: [
1828
- /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexGrow: 1, flexBasis: 0, children: [
1829
- /* @__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: [
1830
2247
  " APIs (",
1831
2248
  apis.length,
1832
2249
  ")"
1833
- ] }),
1834
- /* @__PURE__ */ jsx2(ColHeader, { ml }),
1835
- visibleApis.map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml, verbose }, n))
1836
- ] }),
1837
- /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", width: 1, children: Array.from({ length: contentHeight }, (_, i) => /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\u2502" }, i)) }),
1838
- /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexGrow: 1, flexBasis: 0, children: [
1839
- /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "magenta", children: [
1840
- " Webs (",
1841
- webs.length,
1842
- ")"
1843
- ] }),
1844
- /* @__PURE__ */ jsx2(ColHeader, { ml }),
1845
- visibleWebs.map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml, verbose }, n))
1846
- ] })
1847
- ] })
1848
- ] });
1849
- }
1850
-
1851
- // src/tui/StatusBar.tsx
1852
- import { Box as Box3, Text as Text3 } from "ink";
1853
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1854
- function StatusBar() {
1855
- return /* @__PURE__ */ jsx3(Box3, { children: /* @__PURE__ */ jsxs3(Text3, { children: [
1856
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "q" }),
1857
- " Quit ",
1858
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Tab" }),
1859
- " Switch ",
1860
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "\u2191\u2193" }),
1861
- " Scroll ",
1862
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "PgUp/PgDn" }),
1863
- " Page ",
1864
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Ctrl+A/E" }),
1865
- " Home/End ",
1866
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "c" }),
1867
- " Clear ",
1868
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "f" }),
1869
- " Filter ",
1870
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "L" }),
1871
- " Level ",
1872
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "a" }),
1873
- " All ",
1874
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "r" }),
1875
- " Restart ",
1876
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "/" }),
1877
- " Search ",
1878
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "s" }),
1879
- " Sort ",
1880
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "o" }),
1881
- " Open ",
1882
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "p" }),
1883
- " Pause ",
1884
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "t" }),
1885
- " Time ",
1886
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "v" }),
1887
- " Verbose ",
1888
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "T" }),
1889
- " Proxy"
1890
- ] }) });
1891
- }
1892
-
1893
- // src/tui/ServiceList.tsx
1894
- import { useState as useState4, useMemo as useMemo2 } from "react";
1895
- import { Box as Box4, Text as Text4, useInput as useInput2 } from "ink";
1896
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1897
- function ServiceList({ title, services, onSelect, onClose, filterType }) {
1898
- const allNames = useMemo2(
1899
- () => [...services.keys()].filter((n) => !filterType || services.get(n).svc.type === filterType),
1900
- [services, filterType]
1901
- );
1902
- const [idx, setIdx] = useState4(0);
1903
- const [query, setQuery] = useState4("");
1904
- const names = useMemo2(() => {
1905
- if (!query) return allNames;
1906
- const q = query.toLowerCase();
1907
- return allNames.filter((n) => n.toLowerCase().includes(q));
1908
- }, [allNames, query]);
1909
- const clamped = Math.min(idx, Math.max(0, names.length - 1));
1910
- useInput2((input, key) => {
1911
- if (key.escape) {
1912
- if (query) setQuery("");
1913
- else onClose();
1914
- return;
1915
- }
1916
- if (key.return) {
1917
- if (names[clamped]) onSelect(names[clamped]);
1918
- return;
1919
- }
1920
- if (key.upArrow) {
1921
- setIdx((i) => Math.max(0, i - 1));
1922
- return;
1923
- }
1924
- if (key.downArrow) {
1925
- setIdx((i) => Math.min(names.length - 1, i + 1));
1926
- return;
1927
- }
1928
- if (key.backspace || key.delete) {
1929
- setQuery((q) => q.slice(0, -1));
1930
- setIdx(0);
1931
- return;
1932
- }
1933
- if (input && !key.ctrl && !key.meta && input.length === 1) {
1934
- setQuery((q) => q + input);
1935
- setIdx(0);
1936
- }
1937
- }, { isActive: process.stdin.isTTY ?? false });
1938
- return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
1939
- /* @__PURE__ */ jsxs4(Text4, { bold: true, color: "cyan", children: [
1940
- " ",
1941
- title,
1942
- " ",
1943
- query && /* @__PURE__ */ jsxs4(Text4, { color: "yellow", children: [
1944
- "[",
1945
- query,
1946
- "]"
2250
+ ] }),
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))
2253
+ ] }),
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: [
2257
+ " Webs (",
2258
+ webs.length,
2259
+ ")"
2260
+ ] }),
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))
1947
2263
  ] })
1948
- ] }),
1949
- 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: [
1950
- " ",
1951
- name,
1952
- " :",
1953
- services.get(name).svc.port,
1954
- " "
1955
- ] }) }, name)),
1956
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "type to filter \u2191\u2193 navigate Enter select Esc clear/close" })
2264
+ ] })
1957
2265
  ] });
1958
2266
  }
1959
2267
 
1960
- // src/tui/SearchInput.tsx
1961
- import { useState as useState5 } from "react";
1962
- import { Box as Box5, Text as Text5, useInput as useInput3 } from "ink";
1963
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1964
- function SearchInput({ onSubmit, onClose }) {
1965
- const [value, setValue] = useState5("");
1966
- useInput3((input, key) => {
1967
- if (key.escape) onClose();
1968
- else if (key.return) onSubmit(value.trim() || null);
1969
- else if (key.backspace || key.delete) setValue((v) => v.slice(0, -1));
1970
- else if (input && !key.ctrl && !key.meta) setValue((v) => v + input);
1971
- }, { isActive: process.stdin.isTTY ?? false });
1972
- return /* @__PURE__ */ jsxs5(Box5, { borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
1973
- /* @__PURE__ */ jsx5(Text5, { bold: true, color: "yellow", children: "Search: " }),
1974
- /* @__PURE__ */ jsx5(Text5, { children: value }),
1975
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u2588" })
1976
- ] });
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);
2285
+ }
2286
+ }, [totalLogs, states, hasSearch, hasFilter, activeTip]);
2287
+ return activeTip;
1977
2288
  }
1978
2289
 
2290
+ // src/tui/hooks/useBootSequence.ts
2291
+ import { useEffect as useEffect9, useState as useState6 } from "react";
2292
+
1979
2293
  // src/lazy/proxy.ts
1980
2294
  import net2 from "net";
1981
2295
  function createLazyProxy(opts) {
@@ -2066,488 +2380,116 @@ function createLazyProxy(opts) {
2066
2380
  const conns = pendingConns.splice(0);
2067
2381
  if (!ok) {
2068
2382
  for (const conn of conns) {
2069
- if (!conn.destroyed) conn.destroy();
2070
- }
2071
- return;
2072
- }
2073
- for (const conn of conns) {
2074
- if (!conn.destroyed) pipeToTarget(conn);
2075
- }
2076
- }
2077
- const server = net2.createServer({ allowHalfOpen: true }, (socket) => handleConnection(socket));
2078
- server.listen(listenPort, "0.0.0.0");
2079
- scheduleIdleCheck();
2080
- return {
2081
- server,
2082
- resetTimer: bumpActivity,
2083
- destroy: () => {
2084
- if (idleTimer) clearTimeout(idleTimer);
2085
- pendingConns.forEach((s) => s.destroy());
2086
- activeConns.forEach((s) => s.destroy());
2087
- server.close();
2088
- }
2089
- };
2090
- }
2091
-
2092
- // src/process/external.ts
2093
- import { spawn as spawn4 } from "child_process";
2094
- import { join as join5 } from "path";
2095
- var DEFAULT_START_TIMEOUT_S = 60;
2096
- async function startExternals(externals, opts) {
2097
- const procs = [];
2098
- const failed = [];
2099
- for (const svc of externals) {
2100
- const proc = spawnExternal(svc, opts);
2101
- procs.push({ svc, proc, pid: proc.pid ?? null });
2102
- if (!svc.healthCheck) {
2103
- opts.onLog?.(svc.name, "\u2705 started (no healthCheck)");
2104
- continue;
2105
- }
2106
- if (svc.healthCheck.type === "tcp" && !svc.port) {
2107
- opts.onLog?.(svc.name, "\u26A0 tcp healthCheck requires `port` \u2014 skipping wait");
2108
- continue;
2109
- }
2110
- const timeoutMs = (svc.startTimeout ?? DEFAULT_START_TIMEOUT_S) * 1e3;
2111
- const ok = await waitHealthy(svc, timeoutMs);
2112
- if (ok) {
2113
- opts.onLog?.(svc.name, "\u2705 healthy");
2114
- } else {
2115
- opts.onLog?.(svc.name, `\u274C never became healthy (timeout ${timeoutMs / 1e3}s)`);
2116
- failed.push(svc.name);
2117
- }
2118
- }
2119
- return { procs, allHealthy: failed.length === 0, failed };
2120
- }
2121
- async function stopExternals(procs, platform, opts = {}) {
2122
- for (const { svc, proc, pid } of procs) {
2123
- try {
2124
- if (pid) platform.killTree(pid);
2125
- if (svc.stopCmd) {
2126
- opts.onLog?.(svc.name, `\u{1F9F9} ${svc.stopCmd}`);
2127
- await new Promise((resolve4) => {
2128
- const isWin = process.platform === "win32";
2129
- const shell = isWin ? "cmd.exe" : "sh";
2130
- const flag = isWin ? "/c" : "-c";
2131
- const cwd = svc.cwd ? join5(opts.baseCwd, svc.cwd) : opts.baseCwd;
2132
- const env = { ...opts.env, ...svc.extraEnv ?? {} };
2133
- const child = spawn4(shell, [flag, svc.stopCmd], { cwd, env, stdio: "ignore" });
2134
- child.on("close", () => resolve4());
2135
- child.on("error", () => resolve4());
2136
- setTimeout(() => resolve4(), 1e4);
2137
- });
2138
- }
2139
- } catch {
2140
- }
2141
- void proc;
2142
- }
2143
- }
2144
- function spawnExternal(svc, opts) {
2145
- const isWin = process.platform === "win32";
2146
- const shell = isWin ? "cmd.exe" : "sh";
2147
- const flag = isWin ? "/c" : "-c";
2148
- const cwd = svc.cwd ? join5(opts.baseCwd, svc.cwd) : opts.baseCwd;
2149
- const env = { ...opts.env, ...svc.extraEnv ?? {} };
2150
- opts.onLog?.(svc.name, `\u{1F680} ${svc.cmd}`);
2151
- const child = spawn4(shell, [flag, svc.cmd], {
2152
- cwd,
2153
- env,
2154
- detached: true,
2155
- stdio: ["ignore", "pipe", "pipe"]
2156
- });
2157
- child.stdout?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
2158
- child.stderr?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
2159
- child.on("error", (err) => opts.onLog?.(svc.name, `\u274C spawn error: ${err.message}`));
2160
- return child;
2161
- }
2162
- async function waitHealthy(svc, timeoutMs) {
2163
- const deadline = Date.now() + timeoutMs;
2164
- const port = svc.port;
2165
- while (Date.now() < deadline) {
2166
- if (await checkHealth(port, svc.healthCheck)) return true;
2167
- await new Promise((r) => setTimeout(r, 500));
2168
- }
2169
- return false;
2170
- }
2171
-
2172
- // src/tui/tips.ts
2173
- function pickTip(state) {
2174
- if (state.crashLoopedCount > 0 && !state.shown.has("crashed")) {
2175
- return { id: "crashed", message: "tip: press r to restart, or check the log of the failing service" };
2176
- }
2177
- if (state.totalLogs > 1e3 && !state.hasSearch && !state.shown.has("search")) {
2178
- return { id: "search", message: "tip: press / to search in logs" };
2179
- }
2180
- if (state.totalLogs > 500 && !state.hasFilter && !state.shown.has("filter")) {
2181
- return { id: "filter", message: "tip: press f to filter logs by service" };
2182
- }
2183
- return null;
2184
- }
2185
-
2186
- // src/control-plane/socket-server.ts
2187
- import { createServer } from "net";
2188
- import { createInterface as createInterface2 } from "readline";
2189
- import { existsSync as existsSync10, unlinkSync, chmodSync, mkdirSync as mkdirSync4, statSync as statSync2 } from "fs";
2190
- import { dirname as dirname5 } from "path";
2191
- import { join as join6 } from "path";
2192
- import { homedir as homedir2 } from "os";
2193
- function defaultSocketPath(projectName) {
2194
- const safe = projectName.replace(/[^a-zA-Z0-9._-]+/g, "_") || "devup";
2195
- return join6(homedir2(), ".devup", `sock-${safe}.sock`);
2196
- }
2197
- async function startSocketServer(projectName, ctx, opts = {}) {
2198
- const path = opts.path ?? defaultSocketPath(projectName);
2199
- mkdirSync4(dirname5(path), { recursive: true });
2200
- if (existsSync10(path)) {
2201
- try {
2202
- const st = statSync2(path);
2203
- if (st.isSocket()) unlinkSync(path);
2204
- } catch {
2205
- }
2206
- }
2207
- const server = createServer((socket) => handleClient(socket, ctx));
2208
- await new Promise((resolve4, reject) => {
2209
- server.once("error", reject);
2210
- server.listen(path, () => {
2211
- server.off("error", reject);
2212
- try {
2213
- chmodSync(path, 384);
2214
- } catch {
2215
- }
2216
- opts.onLog?.(`\u{1F50C} control plane at ${path}`);
2217
- resolve4();
2218
- });
2219
- });
2220
- return {
2221
- server,
2222
- path,
2223
- async close() {
2224
- await new Promise((resolve4) => server.close(() => resolve4()));
2225
- if (existsSync10(path)) {
2226
- try {
2227
- unlinkSync(path);
2228
- } catch {
2229
- }
2230
- }
2231
- }
2232
- };
2233
- }
2234
- function handleClient(socket, ctx) {
2235
- const rl = createInterface2({ input: socket });
2236
- rl.on("line", async (line) => {
2237
- if (!line.trim()) return;
2238
- let req;
2239
- try {
2240
- req = JSON.parse(line);
2241
- } catch (e) {
2242
- respond(socket, { error: { code: -32700, message: `parse error: ${e.message}` } });
2243
- return;
2244
- }
2245
- if (typeof req.method !== "string") {
2246
- respond(socket, { id: req.id, error: { code: -32600, message: "method required" } });
2247
- return;
2248
- }
2249
- try {
2250
- const result = await dispatch(req.method, req.params ?? {}, ctx);
2251
- respond(socket, { id: req.id, result });
2252
- } catch (e) {
2253
- respond(socket, { id: req.id, error: { code: -32603, message: e.message ?? String(e) } });
2254
- }
2255
- });
2256
- socket.on("error", () => {
2257
- });
2258
- }
2259
- function respond(socket, payload) {
2260
- if (socket.writable) socket.write(JSON.stringify(payload) + "\n");
2261
- }
2262
- async function dispatch(method, params, ctx) {
2263
- switch (method) {
2264
- case "status": {
2265
- const out = [];
2266
- for (const [name, st] of ctx.states()) {
2267
- out.push({
2268
- name,
2269
- status: st.status,
2270
- health: st.health,
2271
- port: st.svc.port,
2272
- type: st.svc.type,
2273
- errors: st.errors,
2274
- restarts: st.restarts,
2275
- pid: st.pid,
2276
- startedAt: st.startedAt
2277
- });
2278
- }
2279
- return { services: out };
2280
- }
2281
- case "restart": {
2282
- const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
2283
- await ctx.restart(svc);
2284
- return { ok: true };
2285
- }
2286
- case "stop": {
2287
- const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
2288
- ctx.stop(svc);
2289
- return { ok: true };
2383
+ if (!conn.destroyed) conn.destroy();
2384
+ }
2385
+ return;
2290
2386
  }
2291
- case "logs.tail": {
2292
- const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
2293
- const lines = Math.max(1, Math.min(1e4, Number(params["lines"] ?? 100)));
2294
- return { lines: await ctx.tailLogs(svc, lines) };
2387
+ for (const conn of conns) {
2388
+ if (!conn.destroyed) pipeToTarget(conn);
2295
2389
  }
2296
- case "ping":
2297
- return { ok: true, ts: Date.now() };
2298
- default:
2299
- throw new Error(`unknown method: ${method}`);
2300
- }
2301
- }
2302
- function stringOrThrow(v, paramName) {
2303
- if (typeof v !== "string" || !v.trim()) {
2304
- throw new Error(`param "${paramName}" must be a non-empty string`);
2305
2390
  }
2306
- return v;
2391
+ const server = net2.createServer({ allowHalfOpen: true }, (socket) => handleConnection(socket));
2392
+ server.listen(listenPort, "0.0.0.0");
2393
+ scheduleIdleCheck();
2394
+ return {
2395
+ server,
2396
+ resetTimer: bumpActivity,
2397
+ destroy: () => {
2398
+ if (idleTimer) clearTimeout(idleTimer);
2399
+ pendingConns.forEach((s) => s.destroy());
2400
+ activeConns.forEach((s) => s.destroy());
2401
+ server.close();
2402
+ }
2403
+ };
2307
2404
  }
2308
2405
 
2309
- // src/tui/App.tsx
2310
- import { createInterface as createInterface3 } from "readline";
2311
- import { createReadStream as createReadStream2, existsSync as existsSync11, watch as fsWatch } from "fs";
2312
-
2313
- // src/config/diff.ts
2314
- var SPAWN_RELEVANT = [
2315
- "cwd",
2316
- "cmd",
2317
- "args",
2318
- "port",
2319
- "phase",
2320
- "maxMem",
2321
- "preBuild",
2322
- "watchBuild",
2323
- "nodeArgs",
2324
- "extraEnv",
2325
- "healthCheck",
2326
- "readyPattern",
2327
- "errorPattern",
2328
- "type"
2329
- ];
2330
- function hasSpawnRelevantChange(prev, next) {
2331
- for (const k of SPAWN_RELEVANT) {
2332
- if (JSON.stringify(prev[k]) !== JSON.stringify(next[k])) return true;
2333
- }
2334
- return false;
2335
- }
2336
- function diffServices(prev, next) {
2337
- const prevByName = new Map(prev.map((s) => [s.name, s]));
2338
- const nextByName = new Map(next.map((s) => [s.name, s]));
2339
- const added = [];
2340
- const removed = [];
2341
- const changed = [];
2342
- const unchanged = [];
2343
- for (const [name, p] of prevByName) {
2344
- if (!nextByName.has(name)) {
2345
- removed.push(name);
2406
+ // src/process/external.ts
2407
+ import { spawn as spawn4 } from "child_process";
2408
+ import { join as join7 } from "path";
2409
+ var DEFAULT_START_TIMEOUT_S = 60;
2410
+ async function startExternals(externals, opts) {
2411
+ const procs = [];
2412
+ const failed = [];
2413
+ for (const svc of externals) {
2414
+ const proc = spawnExternal(svc, opts);
2415
+ procs.push({ svc, proc, pid: proc.pid ?? null });
2416
+ if (!svc.healthCheck) {
2417
+ opts.onLog?.(svc.name, "\u2705 started (no healthCheck)");
2346
2418
  continue;
2347
2419
  }
2348
- const n = nextByName.get(name);
2349
- if (hasSpawnRelevantChange(p, n)) changed.push({ prev: p, next: n });
2350
- else unchanged.push(name);
2351
- }
2352
- for (const [name, n] of nextByName) {
2353
- if (!prevByName.has(name)) added.push(n);
2354
- }
2355
- return { added, removed, changed, unchanged };
2356
- }
2357
- function summariseDiff(d) {
2358
- const parts = [];
2359
- if (d.added.length) parts.push(`+${d.added.length} added`);
2360
- if (d.removed.length) parts.push(`-${d.removed.length} removed`);
2361
- if (d.changed.length) parts.push(`~${d.changed.length} changed`);
2362
- if (!parts.length) parts.push("no changes");
2363
- return parts.join(", ");
2364
- }
2365
-
2366
- // src/tui/App.tsx
2367
- import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
2368
- function buildServiceUrl(name, port, proxyActive, proxyOpts) {
2369
- if (proxyActive && proxyOpts) {
2370
- const sub = proxyOpts.routes[name];
2371
- if (sub !== void 0) {
2372
- const host = sub ? `${sub}.${proxyOpts.domain}` : proxyOpts.domain;
2373
- const scheme = proxyOpts.tls ? "https" : "http";
2374
- return `${scheme}://${host}`;
2420
+ if (svc.healthCheck.type === "tcp" && !svc.port) {
2421
+ opts.onLog?.(svc.name, "\u26A0 tcp healthCheck requires `port` \u2014 skipping wait");
2422
+ continue;
2423
+ }
2424
+ const timeoutMs = (svc.startTimeout ?? DEFAULT_START_TIMEOUT_S) * 1e3;
2425
+ const ok = await waitHealthy(svc, timeoutMs);
2426
+ if (ok) {
2427
+ opts.onLog?.(svc.name, "\u2705 healthy");
2428
+ } else {
2429
+ opts.onLog?.(svc.name, `\u274C never became healthy (timeout ${timeoutMs / 1e3}s)`);
2430
+ failed.push(svc.name);
2375
2431
  }
2376
2432
  }
2377
- return `http://localhost:${port}`;
2433
+ return { procs, allHealthy: failed.length === 0, failed };
2378
2434
  }
2379
- function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts, logSink }) {
2380
- const { stdout } = useStdout();
2381
- const [rows, setRows] = useState6(stdout?.rows ?? 40);
2382
- useEffect5(() => {
2383
- if (!stdout) return;
2384
- const onResize = () => setRows(stdout.rows ?? 40);
2385
- stdout.on("resize", onResize);
2386
- return () => {
2387
- stdout.off("resize", onResize);
2388
- };
2389
- }, [stdout]);
2390
- const logsHeight = Math.floor(rows * 0.65);
2391
- const statsHeight = rows - logsHeight - 2;
2392
- const maxNameLen = Math.max(...services.map((s) => s.name.length), 10);
2393
- const pm = useProcessManager(platform, baseCwd, env, logSink);
2394
- const [booted, setBooted] = useState6(false);
2395
- const lazyProxies = useRef3(/* @__PURE__ */ new Map());
2396
- const externals = useRef3([]);
2397
- const socketServer = useRef3(null);
2398
- const shownTips = useRef3(/* @__PURE__ */ new Set());
2399
- const [activeTip, setActiveTip] = useState6(null);
2400
- const kb = useKeyBindings({
2401
- onQuit: () => {
2402
- void shutdown();
2403
- },
2404
- onClearLogs: pm.clearLogs,
2405
- onToggleProxy: () => {
2406
- }
2407
- });
2408
- const shutdown = useCallback3(async () => {
2409
- lazyProxies.current.forEach((p) => p.destroy());
2410
- await socketServer.current?.close();
2411
- socketServer.current = null;
2412
- await pm.cleanup();
2413
- if (externals.current.length) {
2414
- await stopExternals(externals.current, platform, {
2415
- baseCwd,
2416
- env,
2417
- onLog: (svc, msg) => pm.pushLog(`ext:${svc}`, msg, 12)
2418
- });
2419
- externals.current = [];
2420
- }
2421
- await logSink?.close();
2422
- process.exit(0);
2423
- }, [pm, logSink, platform, baseCwd, env]);
2424
- useEffect5(() => {
2425
- if (!pm.manager) return;
2426
- let handle = null;
2427
- (async () => {
2428
- try {
2429
- handle = await startSocketServer(config.name, {
2430
- states: () => pm.manager.state,
2431
- restart: (name) => pm.manager.restart(name),
2432
- stop: (name) => pm.manager.stop(name),
2433
- tailLogs: async (svcName, lines) => {
2434
- if (!logSink) return [];
2435
- const file = logSink.pathFor(svcName);
2436
- if (!existsSync11(file)) return [];
2437
- return new Promise((resolve4, reject) => {
2438
- const buf = [];
2439
- const rl = createInterface3({ input: createReadStream2(file, { encoding: "utf8" }) });
2440
- rl.on("line", (l) => {
2441
- buf.push(l);
2442
- if (buf.length > lines) buf.shift();
2443
- });
2444
- rl.on("close", () => resolve4(buf));
2445
- rl.on("error", reject);
2446
- });
2447
- }
2448
- }, { onLog: (msg) => pm.pushLog("devup", msg, 12) });
2449
- socketServer.current = handle;
2450
- } catch (e) {
2451
- pm.pushLog("devup", `\u26A0 control plane disabled: ${e.message}`, 5);
2452
- }
2453
- })();
2454
- return () => {
2455
- void handle?.close();
2456
- };
2457
- }, [pm.manager, config.name, logSink]);
2458
- useEffect5(() => {
2459
- if (!cliArgs.watchConfig || !pm.manager) return;
2460
- let watcher = null;
2461
- let configPath;
2435
+ async function stopExternals(procs, platform, opts = {}) {
2436
+ for (const { svc, proc, pid } of procs) {
2462
2437
  try {
2463
- configPath = findConfigFile(baseCwd, cliArgs.configPath);
2464
- } catch (e) {
2465
- pm.pushLog("devup", `\u26A0 watch-config disabled: ${e.message}`, 5);
2466
- return;
2467
- }
2468
- pm.pushLog("devup", `\u{1F440} watching ${configPath}`, 12);
2469
- let reloadInFlight = false;
2470
- let reloadAgain = false;
2471
- const reload = async () => {
2472
- if (reloadInFlight) {
2473
- reloadAgain = true;
2474
- return;
2475
- }
2476
- reloadInFlight = true;
2477
- try {
2478
- const nextCfg = await loadConfig(configPath);
2479
- const errs = validateConfig(nextCfg, baseCwd);
2480
- if (errs.length) {
2481
- pm.pushLog("devup", `\u26A0 config reload failed:
2482
- ${formatValidationErrors(errs)}`, 5);
2483
- return;
2484
- }
2485
- const mgr = pm.manager;
2486
- const currentSvcs = [...mgr.state.values()].map((s) => s.svc);
2487
- const diff = diffServices(currentSvcs, nextCfg.services);
2488
- if (!diff.added.length && !diff.removed.length && !diff.changed.length) return;
2489
- for (const name of diff.removed) {
2490
- mgr.stop(name);
2491
- mgr.state.delete(name);
2492
- }
2493
- let colorIdx = currentSvcs.length;
2494
- for (const { next } of diff.changed) {
2495
- const prev = mgr.state.get(next.name);
2496
- const ci = prev?.colorIdx ?? colorIdx++;
2497
- mgr.stop(next.name);
2498
- await new Promise((r) => setTimeout(r, 800));
2499
- await mgr.install(next, ci);
2500
- await mgr.start(next, ci, true);
2501
- }
2502
- for (const next of diff.added) {
2503
- const ci = colorIdx++;
2504
- await mgr.install(next, ci);
2505
- await mgr.start(next, ci);
2506
- }
2507
- pm.pushLog("devup", `\u{1F501} config reloaded: ${summariseDiff(diff)}`, 12);
2508
- } catch (e) {
2509
- pm.pushLog("devup", `\u26A0 config reload error: ${e.message}`, 5);
2510
- } finally {
2511
- reloadInFlight = false;
2512
- if (reloadAgain) {
2513
- reloadAgain = false;
2514
- void reload();
2515
- }
2438
+ if (pid) platform.killTree(pid);
2439
+ if (svc.stopCmd) {
2440
+ opts.onLog?.(svc.name, `\u{1F9F9} ${svc.stopCmd}`);
2441
+ await new Promise((resolve4) => {
2442
+ const isWin = process.platform === "win32";
2443
+ const shell = isWin ? "cmd.exe" : "sh";
2444
+ const flag = isWin ? "/c" : "-c";
2445
+ const cwd = svc.cwd ? join7(opts.baseCwd, svc.cwd) : opts.baseCwd;
2446
+ const env = { ...opts.env, ...svc.extraEnv ?? {} };
2447
+ const child = spawn4(shell, [flag, svc.stopCmd], { cwd, env, stdio: "ignore" });
2448
+ child.on("close", () => resolve4());
2449
+ child.on("error", () => resolve4());
2450
+ setTimeout(() => resolve4(), 1e4);
2451
+ });
2516
2452
  }
2517
- };
2518
- let debounceTimer = null;
2519
- watcher = fsWatch(configPath, () => {
2520
- if (debounceTimer) clearTimeout(debounceTimer);
2521
- debounceTimer = setTimeout(() => void reload(), 250);
2522
- });
2523
- return () => {
2524
- if (debounceTimer) clearTimeout(debounceTimer);
2525
- watcher?.close();
2526
- };
2527
- }, [cliArgs.watchConfig, cliArgs.configPath, baseCwd, pm.manager, pm]);
2528
- useEffect5(() => {
2529
- pm.setPaused(kb.logsPaused || kb.logsScrollOffset > 0);
2530
- }, [kb.logsPaused, kb.logsScrollOffset, pm]);
2531
- useEffect5(() => {
2532
- const tip = pickTip({
2533
- totalLogs: pm.logs.length,
2534
- hasSearch: !!kb.searchTerm,
2535
- hasFilter: !!kb.logFilter,
2536
- crashLoopedCount: [...pm.states.values()].filter(isCrashLooped).length,
2537
- shown: shownTips.current
2538
- });
2539
- if (tip && tip.id !== activeTip) {
2540
- shownTips.current.add(tip.id);
2541
- setActiveTip(tip.message);
2542
- const timer = setTimeout(() => setActiveTip(null), 12e3);
2543
- return () => clearTimeout(timer);
2453
+ } catch {
2544
2454
  }
2545
- }, [pm.logs.length, pm.states, kb.searchTerm, kb.logFilter, activeTip]);
2546
- useProxySync(proxyProvider, proxyOpts, pm.states, kb.proxyEnabled);
2547
- useEffect5(() => {
2548
- 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;
2549
2491
  setBooted(true);
2550
- const mgr = pm.manager;
2492
+ const mgr = manager;
2551
2493
  (async () => {
2552
2494
  const lazyMode = cliArgs.lazy;
2553
2495
  const lazyTimeout = cliArgs.lazyTimeout;
@@ -2556,11 +2498,11 @@ ${formatValidationErrors(errs)}`, 5);
2556
2498
  baseCwd,
2557
2499
  env,
2558
2500
  platform,
2559
- onLog: (svc, msg) => pm.pushLog(`ext:${svc}`, msg, 12)
2501
+ onLog: (svc, msg) => pushLog(`ext:${svc}`, msg, 12)
2560
2502
  });
2561
- externals.current = result.procs;
2503
+ refs.externals.current = result.procs;
2562
2504
  if (!result.allHealthy) {
2563
- 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);
2564
2506
  return;
2565
2507
  }
2566
2508
  }
@@ -2628,7 +2570,7 @@ ${formatValidationErrors(errs)}`, 5);
2628
2570
  return !!st && !!st.proc && !st.proc.killed && st.health === "up";
2629
2571
  }
2630
2572
  });
2631
- lazyProxies.current.set(svc.name, proxy);
2573
+ refs.lazyProxies.current.set(svc.name, proxy);
2632
2574
  }
2633
2575
  } else {
2634
2576
  const phases = groupByPhase(services);
@@ -2649,7 +2591,259 @@ ${formatValidationErrors(errs)}`, 5);
2649
2591
  }
2650
2592
  }
2651
2593
  })();
2652
- }, [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
+ );
2653
2847
  const handleFilterSelect = useCallback3((name) => kb.setFilter(name), [kb]);
2654
2848
  const handleRestartSelect = useCallback3((name) => {
2655
2849
  pm.restart(name);
@@ -2723,8 +2917,8 @@ ${formatValidationErrors(errs)}`, 5);
2723
2917
  }
2724
2918
 
2725
2919
  // src/process/log-sink.ts
2726
- import { existsSync as existsSync12, mkdirSync as mkdirSync5, renameSync, createWriteStream } from "fs";
2727
- import { join as join7, dirname as dirname6 } from "path";
2920
+ import { existsSync as existsSync13, mkdirSync as mkdirSync5, renameSync, createWriteStream } from "fs";
2921
+ import { join as join8, dirname as dirname6 } from "path";
2728
2922
  import { homedir as homedir3 } from "os";
2729
2923
  var LogSink = class {
2730
2924
  dir;
@@ -2732,14 +2926,14 @@ var LogSink = class {
2732
2926
  streams = /* @__PURE__ */ new Map();
2733
2927
  seen = /* @__PURE__ */ new Set();
2734
2928
  constructor(opts) {
2735
- const root = opts.rootDir ?? join7(homedir3(), ".devup", "logs");
2736
- this.dir = join7(root, sanitize2(opts.projectName));
2929
+ const root = opts.rootDir ?? join8(homedir3(), ".devup", "logs");
2930
+ this.dir = join8(root, sanitize2(opts.projectName));
2737
2931
  this.rotateOnStart = opts.rotateOnStart ?? true;
2738
2932
  mkdirSync5(this.dir, { recursive: true });
2739
2933
  }
2740
2934
  /** Returns the file path for a service log (useful for tests / UI). */
2741
2935
  pathFor(svcName) {
2742
- return join7(this.dir, `${sanitize2(svcName)}.log`);
2936
+ return join8(this.dir, `${sanitize2(svcName)}.log`);
2743
2937
  }
2744
2938
  write(svcName, line) {
2745
2939
  const stream = this.streamFor(svcName);
@@ -2758,7 +2952,7 @@ var LogSink = class {
2758
2952
  let s = this.streams.get(svcName);
2759
2953
  if (s) return s;
2760
2954
  const file = this.pathFor(svcName);
2761
- if (this.rotateOnStart && !this.seen.has(svcName) && existsSync12(file)) {
2955
+ if (this.rotateOnStart && !this.seen.has(svcName) && existsSync13(file)) {
2762
2956
  try {
2763
2957
  mkdirSync5(dirname6(file), { recursive: true });
2764
2958
  renameSync(file, file + ".prev");
@@ -2947,8 +3141,8 @@ function defineConfig(config) {
2947
3141
  function readVersion() {
2948
3142
  try {
2949
3143
  const here = dirname7(fileURLToPath2(import.meta.url));
2950
- const pkgPath = join8(here, "..", "package.json");
2951
- return JSON.parse(readFileSync2(pkgPath, "utf8")).version ?? "unknown";
3144
+ const pkgPath = join9(here, "..", "package.json");
3145
+ return JSON.parse(readFileSync3(pkgPath, "utf8")).version ?? "unknown";
2952
3146
  } catch {
2953
3147
  return "unknown";
2954
3148
  }
@@ -3015,7 +3209,7 @@ ${formatValidationWarnings(warnings)}`);
3015
3209
  process.exit(1);
3016
3210
  }
3017
3211
  const platform = await detectPlatform();
3018
- const envFile = config.envFile ? join8(cwd, config.envFile) : join8(cwd, ".env");
3212
+ const envFile = config.envFile ? join9(cwd, config.envFile) : join9(cwd, ".env");
3019
3213
  const env = parseEnvFile(envFile, process.env);
3020
3214
  if (config.env) {
3021
3215
  for (const [k, v] of Object.entries(config.env)) {
@@ -3032,7 +3226,7 @@ ${formatValidationWarnings(warnings)}`);
3032
3226
  routes: config.proxy.routes,
3033
3227
  tls: cliArgs.proxyTls ?? config.proxy.tls ?? true,
3034
3228
  entrypoint: cliArgs.proxyEntrypoint ?? config.proxy.entrypoint ?? "websecure",
3035
- confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join8(homedir4(), ".traefik", "traefik_conf.yaml")
3229
+ confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join9(homedir4(), ".traefik", "traefik_conf.yaml")
3036
3230
  };
3037
3231
  }
3038
3232
  if (cliArgs.dryRun) {