@gachlab/devup 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +4 -2
  3. package/dist/control-plane/client.d.ts +17 -0
  4. package/dist/control-plane/client.d.ts.map +1 -0
  5. package/dist/control-plane/socket-server.d.ts +6 -0
  6. package/dist/control-plane/socket-server.d.ts.map +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2653 -1713
  9. package/dist/index.js.map +1 -1
  10. package/dist/orchestrator/config-watcher.d.ts +22 -0
  11. package/dist/orchestrator/config-watcher.d.ts.map +1 -0
  12. package/dist/orchestrator/daemon.d.ts +38 -0
  13. package/dist/orchestrator/daemon.d.ts.map +1 -0
  14. package/dist/orchestrator/subcommands.d.ts +7 -0
  15. package/dist/orchestrator/subcommands.d.ts.map +1 -1
  16. package/dist/process/health-poller.d.ts +15 -0
  17. package/dist/process/health-poller.d.ts.map +1 -0
  18. package/dist/process/internals.d.ts +14 -0
  19. package/dist/process/internals.d.ts.map +1 -0
  20. package/dist/process/lifecycle.d.ts +31 -0
  21. package/dist/process/lifecycle.d.ts.map +1 -0
  22. package/dist/process/manager.d.ts +11 -13
  23. package/dist/process/manager.d.ts.map +1 -1
  24. package/dist/process/restarter.d.ts +26 -0
  25. package/dist/process/restarter.d.ts.map +1 -0
  26. package/dist/process/spawner.d.ts +38 -0
  27. package/dist/process/spawner.d.ts.map +1 -0
  28. package/dist/tui/App.d.ts.map +1 -1
  29. package/dist/tui/hooks/useBootSequence.d.ts +20 -0
  30. package/dist/tui/hooks/useBootSequence.d.ts.map +1 -0
  31. package/dist/tui/hooks/useContextualTips.d.ts +6 -0
  32. package/dist/tui/hooks/useContextualTips.d.ts.map +1 -0
  33. package/dist/tui/hooks/useControlPlane.d.ts +18 -0
  34. package/dist/tui/hooks/useControlPlane.d.ts.map +1 -0
  35. package/dist/tui/hooks/useHotReload.d.ts +6 -0
  36. package/dist/tui/hooks/useHotReload.d.ts.map +1 -0
  37. package/dist/tui/hooks/useLogsPause.d.ts +4 -0
  38. package/dist/tui/hooks/useLogsPause.d.ts.map +1 -0
  39. package/dist/tui/hooks/useProcessManager.d.ts +9 -0
  40. package/dist/tui/hooks/useProcessManager.d.ts.map +1 -1
  41. package/dist/tui/hooks/useTerminalSize.d.ts +4 -0
  42. package/dist/tui/hooks/useTerminalSize.d.ts.map +1 -0
  43. package/dist/utils/broadcaster.d.ts +6 -0
  44. package/dist/utils/broadcaster.d.ts.map +1 -0
  45. package/dist/utils/colors.d.ts +4 -0
  46. package/dist/utils/colors.d.ts.map +1 -0
  47. package/dist/utils/env.d.ts +5 -0
  48. package/dist/utils/env.d.ts.map +1 -0
  49. package/dist/utils/format.d.ts +3 -0
  50. package/dist/utils/format.d.ts.map +1 -0
  51. package/dist/utils/install-stamp.d.ts +6 -0
  52. package/dist/utils/install-stamp.d.ts.map +1 -0
  53. package/dist/utils/phases.d.ts +4 -0
  54. package/dist/utils/phases.d.ts.map +1 -0
  55. package/dist/utils/process-args.d.ts +8 -0
  56. package/dist/utils/process-args.d.ts.map +1 -0
  57. package/dist/utils/redact.d.ts +4 -0
  58. package/dist/utils/redact.d.ts.map +1 -0
  59. package/dist/utils/search.d.ts +17 -0
  60. package/dist/utils/search.d.ts.map +1 -0
  61. package/dist/utils/stats.d.ts +19 -0
  62. package/dist/utils/stats.d.ts.map +1 -0
  63. package/dist/utils.d.ts +10 -41
  64. package/dist/utils.d.ts.map +1 -1
  65. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3,10 +3,10 @@
3
3
  // src/index.ts
4
4
  import React7 from "react";
5
5
  import { render } from "ink";
6
- import { readFileSync as readFileSync2 } from "fs";
7
- import { dirname as dirname7, join as join8 } from "path";
6
+ import { readFileSync as readFileSync4 } from "fs";
7
+ import { dirname as dirname7, join as join10 } from "path";
8
8
  import { fileURLToPath as fileURLToPath2 } from "url";
9
- import { homedir as homedir4 } from "os";
9
+ import { homedir as homedir5 } from "os";
10
10
 
11
11
  // src/config/loader.ts
12
12
  import { existsSync } from "fs";
@@ -429,13 +429,13 @@ function filterServices(services, args, config) {
429
429
  }
430
430
 
431
431
  // src/orchestrator/subcommands.ts
432
- import { spawn } from "child_process";
433
- import { createReadStream, watchFile, unwatchFile, existsSync as existsSync4, statSync } from "fs";
432
+ import { spawn as spawn5 } from "child_process";
433
+ import { createReadStream as createReadStream2, watchFile as watchFile2, unwatchFile as unwatchFile2, existsSync as existsSync11, statSync as statSync2 } from "fs";
434
434
  import { readFile as readFile2 } from "fs/promises";
435
- import { join as join3, dirname } from "path";
435
+ import { join as join9, dirname as dirname3 } from "path";
436
436
  import { fileURLToPath } from "url";
437
- import { homedir } from "os";
438
- import { createInterface } from "readline";
437
+ import { homedir as homedir4 } from "os";
438
+ import { createInterface as createInterface4 } from "readline";
439
439
 
440
440
  // src/process/health.ts
441
441
  import net from "net";
@@ -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",
@@ -651,353 +668,252 @@ var tagColors = [
651
668
  "white"
652
669
  ];
653
670
 
654
- // src/orchestrator/subcommands.ts
655
- var KNOWN = /* @__PURE__ */ new Set(["logs", "install", "status", "help"]);
656
- function detectSubcommand(argv) {
657
- const first = argv[0];
658
- return first && KNOWN.has(first) ? first : null;
659
- }
660
- function logRoot(config, override) {
661
- const root = override ?? join3(homedir(), ".devup", "logs");
662
- return join3(root, sanitize(config.name));
663
- }
664
- function sanitize(name) {
665
- return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
671
+ // src/control-plane/client.ts
672
+ import { createConnection } from "net";
673
+ import { createInterface as createInterface2 } from "readline";
674
+ import { existsSync as existsSync6 } from "fs";
675
+
676
+ // src/control-plane/socket-server.ts
677
+ import { createServer } from "net";
678
+ import { createInterface } from "readline";
679
+ import { existsSync as existsSync5, unlinkSync, chmodSync, mkdirSync, statSync } from "fs";
680
+ import { dirname } from "path";
681
+ import { join as join3 } from "path";
682
+ import { homedir } from "os";
683
+ function defaultSocketPath(projectName) {
684
+ const safe = projectName.replace(/[^a-zA-Z0-9._-]+/g, "_") || "devup";
685
+ return join3(homedir(), ".devup", `sock-${safe}.sock`);
666
686
  }
667
- async function runLogs(argv, opts) {
668
- const out = opts.out ?? ((l) => console.log(l));
669
- const follow = argv.includes("--follow") || argv.includes("-f");
670
- const svcArg = argv.find((a) => !a.startsWith("-"));
671
- if (!svcArg) {
672
- out("usage: devup logs <service> [--follow]");
673
- return 1;
674
- }
675
- const knownSvcs = opts.config.services.map((s) => s.name);
676
- if (!knownSvcs.includes(svcArg)) {
677
- out(`Unknown service "${svcArg}". Known: ${knownSvcs.join(", ")}`);
678
- return 1;
679
- }
680
- const file = join3(logRoot(opts.config, opts.logDir), `${sanitize(svcArg)}.log`);
681
- if (!existsSync4(file)) {
682
- out(`No log file yet for "${svcArg}" (${file})`);
683
- return follow ? await followFile(file, out) : 1;
687
+ async function startSocketServer(projectName, ctx, opts = {}) {
688
+ const path = opts.path ?? defaultSocketPath(projectName);
689
+ mkdirSync(dirname(path), { recursive: true });
690
+ if (existsSync5(path)) {
691
+ try {
692
+ const st = statSync(path);
693
+ if (st.isSocket()) unlinkSync(path);
694
+ } catch {
695
+ }
684
696
  }
685
- await streamFile(file, out);
686
- if (!follow) return 0;
687
- return await followFile(file, out, statSync(file).size);
688
- }
689
- async function streamFile(file, out) {
690
- return new Promise((resolve4, reject) => {
691
- const rl = createInterface({ input: createReadStream(file, { encoding: "utf8" }) });
692
- rl.on("line", (l) => out(l));
693
- rl.on("close", () => resolve4());
694
- rl.on("error", reject);
695
- });
696
- }
697
- async function followFile(file, out, startAt = 0) {
698
- let pos = startAt;
699
- while (!existsSync4(file)) await new Promise((r) => setTimeout(r, 500));
700
- return new Promise((resolve4) => {
701
- const tick = async () => {
702
- const size = statSync(file).size;
703
- if (size > pos) {
704
- await new Promise((res) => {
705
- const rl = createInterface({ input: createReadStream(file, { encoding: "utf8", start: pos, end: size - 1 }) });
706
- rl.on("line", (l) => out(l));
707
- rl.on("close", () => {
708
- pos = size;
709
- res();
710
- });
711
- });
712
- } else if (size < pos) {
713
- pos = 0;
697
+ const server = createServer((socket) => handleClient(socket, ctx));
698
+ await new Promise((resolve4, reject) => {
699
+ server.once("error", reject);
700
+ server.listen(path, () => {
701
+ server.off("error", reject);
702
+ try {
703
+ chmodSync(path, 384);
704
+ } catch {
714
705
  }
715
- };
716
- watchFile(file, { interval: 500 }, () => {
717
- void tick();
718
- });
719
- process.once("SIGINT", () => {
720
- unwatchFile(file);
721
- resolve4(0);
706
+ opts.onLog?.(`\u{1F50C} control plane at ${path}`);
707
+ resolve4();
722
708
  });
723
709
  });
710
+ return {
711
+ server,
712
+ path,
713
+ async close() {
714
+ await new Promise((resolve4) => server.close(() => resolve4()));
715
+ if (existsSync5(path)) {
716
+ try {
717
+ unlinkSync(path);
718
+ } catch {
719
+ }
720
+ }
721
+ }
722
+ };
724
723
  }
725
- async function runInstall(opts) {
726
- const out = opts.out ?? ((l) => console.log(l));
727
- const concurrency = opts.concurrency ?? 4;
728
- const items = opts.config.services.map((s) => ({ name: s.name, cwd: join3(opts.baseCwd, s.cwd) }));
729
- const queue = [...items];
730
- const failed = [];
731
- let inFlight = 0;
732
- await new Promise((resolve4) => {
733
- const pump = () => {
734
- while (inFlight < concurrency && queue.length) {
735
- const item = queue.shift();
736
- inFlight++;
737
- installOne(item.cwd, opts.env).then((ok) => {
738
- inFlight--;
739
- if (ok) out(`\u2713 ${item.name}`);
740
- else {
741
- failed.push(item.name);
742
- out(`\u2717 ${item.name}`);
743
- }
744
- if (queue.length === 0 && inFlight === 0) resolve4();
745
- else pump();
746
- });
724
+ function handleClient(socket, ctx) {
725
+ const rl = createInterface({ input: socket });
726
+ const unsubs = /* @__PURE__ */ new Set();
727
+ socket.on("close", () => {
728
+ for (const unsub of unsubs) unsub();
729
+ unsubs.clear();
730
+ });
731
+ rl.on("line", async (line) => {
732
+ if (!line.trim()) return;
733
+ let req;
734
+ try {
735
+ req = JSON.parse(line);
736
+ } catch (e) {
737
+ respond(socket, { error: { code: -32700, message: `parse error: ${e.message}` } });
738
+ return;
739
+ }
740
+ if (typeof req.method !== "string") {
741
+ respond(socket, { id: req.id, error: { code: -32600, message: "method required" } });
742
+ return;
743
+ }
744
+ const params = req.params ?? {};
745
+ if (req.method === "logs.follow" || req.method === "status.follow") {
746
+ try {
747
+ await handleFollow(socket, req, params, ctx, unsubs);
748
+ } catch (e) {
749
+ respond(socket, { id: req.id, error: { code: -32603, message: e.message ?? String(e) } });
747
750
  }
748
- };
749
- pump();
751
+ return;
752
+ }
753
+ try {
754
+ const result = await dispatch(req.method, params, ctx);
755
+ respond(socket, { id: req.id, result });
756
+ } catch (e) {
757
+ respond(socket, { id: req.id, error: { code: -32603, message: e.message ?? String(e) } });
758
+ }
750
759
  });
751
- if (failed.length) {
752
- out(`
753
- failed: ${failed.join(", ")}`);
754
- return 1;
760
+ socket.on("error", () => {
761
+ });
762
+ }
763
+ async function handleFollow(socket, req, params, ctx, unsubs) {
764
+ if (req.method === "logs.follow") {
765
+ const rawSvc = params["svc"] ?? params["service"];
766
+ const svcName = rawSvc != null ? stringOrThrow(rawSvc, "svc") : null;
767
+ const tail = Math.max(0, Math.min(1e3, Number(params["tail"] ?? 50)));
768
+ respond(socket, { id: req.id, result: { ok: true } });
769
+ if (svcName) {
770
+ const lines = await ctx.tailLogs(svcName, tail);
771
+ for (const l of lines) {
772
+ respond(socket, { id: req.id, event: "log", data: l });
773
+ }
774
+ }
775
+ const unsub = ctx.watchLogs(svcName, (svc, line) => {
776
+ respond(socket, { id: req.id, event: "log", data: line, svc });
777
+ });
778
+ unsubs.add(unsub);
779
+ } else {
780
+ respond(socket, { id: req.id, result: { ok: true } });
781
+ const snapshot = [];
782
+ for (const [name, st] of ctx.states()) {
783
+ snapshot.push(serializeState(name, st));
784
+ }
785
+ if (snapshot.length) {
786
+ respond(socket, { id: req.id, event: "status", data: snapshot });
787
+ }
788
+ const unsub = ctx.watchStatus((name, state) => {
789
+ respond(socket, { id: req.id, event: "status", data: [serializeState(name, state)] });
790
+ });
791
+ unsubs.add(unsub);
755
792
  }
756
- out(`
757
- ${items.length} services up to date`);
758
- return 0;
759
793
  }
760
- function installOne(cwd, env) {
761
- if (!existsSync4(cwd)) return Promise.resolve(false);
762
- if (!needsInstall(cwd)) return Promise.resolve(true);
763
- return new Promise((resolve4) => {
764
- const command = process.platform === "win32" ? "npm.cmd" : "npm";
765
- const proc = spawn(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
766
- proc.on("close", (code) => {
767
- if (code === 0) {
768
- writeInstallStamp(cwd);
769
- resolve4(true);
770
- } else resolve4(false);
771
- });
772
- proc.on("error", () => resolve4(false));
773
- });
774
- }
775
- async function runStatus(opts) {
776
- const out = opts.out ?? ((l) => console.log(l));
777
- out(`${opts.config.icon ?? "\u{1F4E6}"} ${opts.config.name} \u2014 ${opts.config.services.length} services`);
778
- out("");
779
- const maxLen = Math.max(...opts.config.services.map((s) => s.name.length), 12);
780
- out(`${"Service".padEnd(maxLen)} ${"Port".padStart(5)} ${"Type".padEnd(4)} Health`);
781
- out("-".repeat(maxLen + 24));
782
- for (const svc of opts.config.services) {
783
- const up = await checkHealth(svc.port, svc.healthCheck);
784
- const health = up ? "\u2713 up" : "\u2717 down";
785
- out(`${svc.name.padEnd(maxLen)} ${String(svc.port).padStart(5)} ${svc.type.padEnd(4)} ${health}`);
786
- }
787
- return 0;
794
+ function serializeState(name, st) {
795
+ return {
796
+ name,
797
+ status: st.status,
798
+ health: st.health,
799
+ port: st.svc.port,
800
+ type: st.svc.type,
801
+ errors: st.errors,
802
+ restarts: st.restarts,
803
+ pid: st.pid,
804
+ startedAt: st.startedAt
805
+ };
788
806
  }
789
- function runHelp(argv, opts = {}) {
790
- const out = opts.out ?? ((l) => console.log(l));
791
- const sub = argv[0];
792
- if (sub === "logs") {
793
- out("Usage: devup logs <service> [--follow|-f]");
794
- out(" Print the persisted log file for a service (works without devup running).");
795
- out(" --follow tails new lines as they are appended.");
796
- return 0;
797
- }
798
- if (sub === "install") {
799
- out("Usage: devup install");
800
- out(" Run `npm install` across every service.cwd in parallel (max 4 at a time).");
801
- out(" Skips services whose .install-stamp matches package.json hash.");
802
- return 0;
803
- }
804
- if (sub === "status") {
805
- out("Usage: devup status");
806
- out(" For each service, probes its health-check endpoint and prints up/down.");
807
- return 0;
808
- }
809
- out("Subcommands:");
810
- out(" devup logs <service> [--follow] Read the persisted log file");
811
- out(" devup install Concurrent npm install across services");
812
- out(" devup status Health check every service in config");
813
- out(" devup help [<subcommand>] Show detailed help for a subcommand");
814
- out("");
815
- out("No subcommand \u2192 launch the interactive TUI.");
816
- return 0;
807
+ function respond(socket, payload) {
808
+ if (socket.writable) socket.write(JSON.stringify(payload) + "\n");
817
809
  }
818
-
819
- // src/platform/detect.ts
820
- async function detectPlatform() {
821
- switch (process.platform) {
822
- case "linux": {
823
- const { LinuxPlatform } = await import("./linux-OQ3Q4Z2Z.js");
824
- return new LinuxPlatform();
810
+ async function dispatch(method, params, ctx) {
811
+ switch (method) {
812
+ case "status": {
813
+ const out = [];
814
+ for (const [name, st] of ctx.states()) {
815
+ out.push(serializeState(name, st));
816
+ }
817
+ return { services: out };
825
818
  }
826
- case "darwin": {
827
- const { DarwinPlatform } = await import("./darwin-3KJ3IEXN.js");
828
- return new DarwinPlatform();
819
+ case "restart": {
820
+ const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
821
+ await ctx.restart(svc);
822
+ return { ok: true };
829
823
  }
830
- case "win32": {
831
- const { Win32Platform } = await import("./win32-3X2OLSI6.js");
832
- return new Win32Platform();
824
+ case "stop": {
825
+ const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
826
+ ctx.stop(svc);
827
+ return { ok: true };
828
+ }
829
+ case "logs.tail": {
830
+ const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
831
+ const lines = Math.max(1, Math.min(1e4, Number(params["lines"] ?? 100)));
832
+ return { lines: await ctx.tailLogs(svc, lines) };
833
833
  }
834
+ case "ping":
835
+ return { ok: true, ts: Date.now() };
834
836
  default:
835
- throw new Error(`Unsupported platform: ${process.platform}`);
837
+ throw new Error(`unknown method: ${method}`);
836
838
  }
837
839
  }
838
-
839
- // src/proxy-config/traefik.ts
840
- import { existsSync as existsSync5, mkdirSync, writeFileSync as writeFileSync2 } from "fs";
841
- import { dirname as dirname2 } from "path";
842
- var EMPTY_CONFIG = "http:\n routers: {}\n services: {}\n";
843
- var TraefikProvider = class {
844
- name = "traefik";
845
- generate(services, opts) {
846
- const routers = [];
847
- const svcs = [];
848
- for (const [name, st] of services) {
849
- if (st.health !== "up") continue;
850
- const sub = opts.routes[name];
851
- if (sub === void 0) continue;
852
- const rule = sub ? `Host(\`${sub}.${opts.domain}\`)` : `Host(\`${opts.domain}\`)`;
853
- const safe = name.replace(/[^a-z0-9-]/g, "-");
854
- const port = st.realPort ?? st.port;
855
- let router = ` ${safe}:
856
- rule: "${rule}"
857
- service: ${safe}
858
- entryPoints:
859
- - ${opts.entrypoint}`;
860
- if (opts.tls) router += `
861
- tls:
862
- certResolver: le`;
863
- routers.push(router);
864
- svcs.push(` ${safe}:
865
- loadBalancer:
866
- servers:
867
- - url: "http://${opts.host}:${port}"`);
868
- }
869
- if (!routers.length) return EMPTY_CONFIG;
870
- return `http:
871
- routers:
872
- ${routers.join("\n")}
873
- services:
874
- ${svcs.join("\n")}
875
- `;
876
- }
877
- write(content, opts) {
878
- const dir = dirname2(opts.confPath);
879
- if (!existsSync5(dir)) mkdirSync(dir, { recursive: true });
880
- writeFileSync2(opts.confPath, content);
881
- }
882
- clear(opts) {
883
- this.write(EMPTY_CONFIG, opts);
840
+ function stringOrThrow(v, paramName) {
841
+ if (typeof v !== "string" || !v.trim()) {
842
+ throw new Error(`param "${paramName}" must be a non-empty string`);
884
843
  }
885
- };
844
+ return v;
845
+ }
886
846
 
887
- // src/proxy-config/nginx.ts
888
- import { existsSync as existsSync6, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
889
- import { dirname as dirname3 } from "path";
890
- var EMPTY_CONFIG2 = "# devup: no healthy services\n";
891
- var NginxProvider = class {
892
- name = "nginx";
893
- generate(services, opts) {
894
- const blocks = [];
895
- for (const [name, st] of services) {
896
- if (st.health !== "up") continue;
897
- const sub = opts.routes[name];
898
- if (sub === void 0) continue;
899
- const serverName = sub ? `${sub}.${opts.domain}` : opts.domain;
900
- const port = st.realPort ?? st.port;
901
- const listen = opts.tls ? "443 ssl" : "80";
902
- const tlsBlock = opts.tls ? ` ssl_certificate /etc/nginx/certs/${serverName}.crt;
903
- ssl_certificate_key /etc/nginx/certs/${serverName}.key;
904
- ` : "";
905
- blocks.push(
906
- `server {
907
- listen ${listen};
908
- server_name ${serverName};
909
- ` + tlsBlock + ` location / {
910
- proxy_pass http://${opts.host}:${port};
911
- proxy_set_header Host $host;
912
- proxy_set_header X-Real-IP $remote_addr;
913
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
914
- proxy_set_header X-Forwarded-Proto $scheme;
915
- proxy_http_version 1.1;
916
- proxy_set_header Upgrade $http_upgrade;
917
- proxy_set_header Connection "upgrade";
918
- }
919
- }`
920
- );
921
- }
922
- if (!blocks.length) return EMPTY_CONFIG2;
923
- return blocks.join("\n\n") + "\n";
924
- }
925
- write(content, opts) {
926
- const dir = dirname3(opts.confPath);
927
- if (!existsSync6(dir)) mkdirSync2(dir, { recursive: true });
928
- writeFileSync3(opts.confPath, content);
929
- }
930
- clear(opts) {
931
- this.write(EMPTY_CONFIG2, opts);
847
+ // src/control-plane/client.ts
848
+ function resolveSocket(projectName, overridePath) {
849
+ return overridePath ?? defaultSocketPath(projectName);
850
+ }
851
+ function assertSocketExists(socketPath, projectName) {
852
+ if (!existsSync6(socketPath)) {
853
+ throw new Error(
854
+ `devup is not running for project "${projectName}".
855
+ Start it with \`devup\` first.`
856
+ );
932
857
  }
933
- };
934
-
935
- // src/proxy-config/caddy.ts
936
- import { existsSync as existsSync7, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
937
- import { dirname as dirname4 } from "path";
938
- var EMPTY_CONFIG3 = "# devup: no healthy services\n";
939
- var CaddyProvider = class {
940
- name = "caddy";
941
- generate(services, opts) {
942
- const blocks = [];
943
- for (const [name, st] of services) {
944
- if (st.health !== "up") continue;
945
- const sub = opts.routes[name];
946
- if (sub === void 0) continue;
947
- const host = sub ? `${sub}.${opts.domain}` : opts.domain;
948
- const port = st.realPort ?? st.port;
949
- const siteAddr = opts.tls ? host : `http://${host}`;
950
- blocks.push(
951
- `${siteAddr} {
952
- reverse_proxy ${opts.host}:${port}
953
- }`
954
- );
858
+ }
859
+ function sendRpc(socketPath, method, params = {}) {
860
+ return new Promise((resolve4, reject) => {
861
+ const c = createConnection(socketPath);
862
+ c.on("error", reject);
863
+ const rl = createInterface2({ input: c });
864
+ rl.once("line", (l) => {
865
+ c.end();
866
+ try {
867
+ const msg = JSON.parse(l);
868
+ if (msg.error) reject(new Error(msg.error.message ?? String(msg.error)));
869
+ else resolve4(msg.result);
870
+ } catch (e) {
871
+ reject(e);
872
+ }
873
+ });
874
+ c.write(JSON.stringify({ id: 1, method, params }) + "\n");
875
+ });
876
+ }
877
+ function openStream(socketPath, method, params, onFrame, onError) {
878
+ const c = createConnection(socketPath);
879
+ const rl = createInterface2({ input: c });
880
+ let ackDone = false;
881
+ c.on("error", (err) => onError?.(err));
882
+ c.write(JSON.stringify({ id: 1, method, params }) + "\n");
883
+ rl.on("line", (l) => {
884
+ try {
885
+ const msg = JSON.parse(l);
886
+ if (!ackDone) {
887
+ ackDone = true;
888
+ if (msg.error) {
889
+ onError?.(new Error(msg.error.message ?? String(msg.error)));
890
+ c.destroy();
891
+ }
892
+ return;
893
+ }
894
+ if (msg.event) onFrame(msg);
895
+ } catch {
955
896
  }
956
- if (!blocks.length) return EMPTY_CONFIG3;
957
- return blocks.join("\n\n") + "\n";
958
- }
959
- write(content, opts) {
960
- const dir = dirname4(opts.confPath);
961
- if (!existsSync7(dir)) mkdirSync3(dir, { recursive: true });
962
- writeFileSync4(opts.confPath, content);
963
- }
964
- clear(opts) {
965
- this.write(EMPTY_CONFIG3, opts);
966
- }
967
- };
968
-
969
- // src/proxy-config/detect.ts
970
- var providers = {
971
- traefik: () => new TraefikProvider(),
972
- nginx: () => new NginxProvider(),
973
- caddy: () => new CaddyProvider()
974
- };
975
- function detectProxyProvider(name) {
976
- const factory = providers[name];
977
- if (!factory) {
978
- const available = Object.keys(providers).join(", ");
979
- throw new Error(`Unknown proxy provider: "${name}". Available: ${available}`);
980
- }
981
- return factory();
897
+ });
898
+ return () => c.destroy();
982
899
  }
983
900
 
984
- // 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";
987
-
988
- // src/tui/hooks/useProcessManager.ts
989
- import { useState, useEffect, useRef, useCallback } from "react";
901
+ // src/orchestrator/daemon.ts
902
+ import { spawn as spawn4 } from "child_process";
903
+ import { writeFileSync as writeFileSync2, readFileSync as readFileSync3, existsSync as existsSync10, unlinkSync as unlinkSync2, mkdirSync as mkdirSync3, createReadStream } from "fs";
904
+ import { join as join8 } from "path";
905
+ import { homedir as homedir3 } from "os";
906
+ import { setTimeout as sleep } from "timers/promises";
907
+ import { createInterface as createInterface3 } from "readline";
990
908
 
991
909
  // 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";
910
+ import { join as join5 } from "path";
995
911
 
996
912
  // src/process/installer.ts
997
- import { spawn as spawn2 } from "child_process";
998
- import { existsSync as existsSync8 } from "fs";
913
+ import { spawn } from "child_process";
914
+ import { existsSync as existsSync7 } from "fs";
999
915
  function installService(cwd, env, onLog) {
1000
- if (!existsSync8(cwd)) {
916
+ if (!existsSync7(cwd)) {
1001
917
  onLog?.(`\u26A0 directory not found: ${cwd}`);
1002
918
  return Promise.resolve(false);
1003
919
  }
@@ -1008,7 +924,7 @@ function installService(cwd, env, onLog) {
1008
924
  onLog?.("\u{1F4E6} npm install...");
1009
925
  return new Promise((resolve4) => {
1010
926
  const command = process.platform === "win32" ? "npm.cmd" : "npm";
1011
- const proc = spawn2(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
927
+ const proc = spawn(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
1012
928
  let stderr = "";
1013
929
  proc.stderr?.on("data", (d) => {
1014
930
  stderr += d.toString();
@@ -1030,9 +946,32 @@ function installService(cwd, env, onLog) {
1030
946
  });
1031
947
  }
1032
948
 
1033
- // src/process/manager.ts
1034
- var MAX_RESTARTS = 3;
1035
- var BACKOFF_BASE_MS = 2e3;
949
+ // src/process/spawner.ts
950
+ import { spawn as spawn2 } from "child_process";
951
+ import { existsSync as existsSync8 } from "fs";
952
+ import { join as join4, resolve as resolve3 } from "path";
953
+
954
+ // src/process/internals.ts
955
+ function lineBuffer(onLine) {
956
+ let buf = "";
957
+ return {
958
+ push(chunk) {
959
+ buf += chunk.toString();
960
+ let idx;
961
+ while ((idx = buf.indexOf("\n")) !== -1) {
962
+ const line = buf.slice(0, idx).replace(/\r$/, "");
963
+ buf = buf.slice(idx + 1);
964
+ if (line.length) onLine(line);
965
+ }
966
+ },
967
+ flush() {
968
+ if (buf.length) {
969
+ onLine(buf);
970
+ buf = "";
971
+ }
972
+ }
973
+ };
974
+ }
1036
975
  function compileReadyPattern(pattern) {
1037
976
  if (!pattern) return null;
1038
977
  const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(pattern);
@@ -1063,43 +1002,26 @@ function extractWatchPaths(args) {
1063
1002
  }
1064
1003
  return out;
1065
1004
  }
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();
1005
+ var MAX_RESTARTS = 3;
1006
+ var BACKOFF_BASE_MS = 2e3;
1007
+
1008
+ // src/process/spawner.ts
1009
+ var Spawner = class {
1089
1010
  baseCwd;
1090
1011
  env;
1091
- platform;
1012
+ state;
1013
+ procs;
1092
1014
  events;
1015
+ lifecycle;
1016
+ onCrash;
1093
1017
  constructor(opts) {
1094
1018
  this.baseCwd = opts.baseCwd;
1095
1019
  this.env = opts.env;
1096
- this.platform = opts.platform;
1020
+ this.state = opts.state;
1021
+ this.procs = opts.procs;
1097
1022
  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));
1023
+ this.lifecycle = opts.lifecycle;
1024
+ this.onCrash = opts.onCrash;
1103
1025
  }
1104
1026
  async start(svc, colorIdx, isRestart = false) {
1105
1027
  const cwd = join4(this.baseCwd, svc.cwd);
@@ -1118,14 +1040,14 @@ var ProcessManager = class {
1118
1040
  }
1119
1041
  }
1120
1042
  const args = buildProcessArgs(svc);
1121
- const missingWatchPaths = extractWatchPaths(args).filter((p) => !existsSync9(resolve3(cwd, p)));
1043
+ const missingWatchPaths = extractWatchPaths(args).filter((p) => !existsSync8(resolve3(cwd, p)));
1122
1044
  if (missingWatchPaths.length) {
1123
1045
  this.log(svc.name, `\u26A0 missing watch paths: ${missingWatchPaths.join(", ")}`, colorIdx);
1124
1046
  this.recordCrashedState(svc, colorIdx);
1125
1047
  return;
1126
1048
  }
1127
1049
  const env = buildProcessEnv(svc, this.env);
1128
- const proc = spawn3(svc.cmd, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
1050
+ const proc = spawn2(svc.cmd, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
1129
1051
  const prev = this.state.get(svc.name);
1130
1052
  const state = {
1131
1053
  svc,
@@ -1142,6 +1064,14 @@ var ProcessManager = class {
1142
1064
  this.state.set(svc.name, state);
1143
1065
  this.procs.add(proc);
1144
1066
  this.events.onStateChange(svc.name, state);
1067
+ this.wireStdio(proc, svc, state, colorIdx);
1068
+ this.wireCloseHandler(proc, svc, state, colorIdx);
1069
+ if (svc.watchBuild) {
1070
+ state.watchProc = this.spawnWatchBuild(svc, cwd, env, colorIdx);
1071
+ }
1072
+ this.log(svc.name, isRestart ? `\u{1F504} restarted (:${svc.port})` : `\u{1F680} started (:${svc.port})`, colorIdx);
1073
+ }
1074
+ wireStdio(proc, svc, state, colorIdx) {
1145
1075
  const readyRegex = compileReadyPattern(svc.readyPattern);
1146
1076
  const markReadyIfMatch = (line) => {
1147
1077
  if (!readyRegex || state.health === "up") return;
@@ -1166,9 +1096,11 @@ var ProcessManager = class {
1166
1096
  proc.stderr?.on("data", (d) => stderrBuf.push(d));
1167
1097
  proc.stdout?.on("end", () => stdoutBuf.flush());
1168
1098
  proc.stderr?.on("end", () => stderrBuf.flush());
1099
+ }
1100
+ wireCloseHandler(proc, svc, state, colorIdx) {
1169
1101
  proc.on("close", (code) => {
1170
1102
  this.procs.delete(proc);
1171
- this.stopWatchProc(state);
1103
+ this.lifecycle.stopWatchProc(state);
1172
1104
  if (state.intentionalStop) {
1173
1105
  state.intentionalStop = false;
1174
1106
  return;
@@ -1183,45 +1115,34 @@ var ProcessManager = class {
1183
1115
  state.health = "down";
1184
1116
  this.log(svc.name, `\u274C exited with code ${code}`, colorIdx);
1185
1117
  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
- }
1118
+ this.onCrash(svc, state, colorIdx);
1194
1119
  });
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
1120
  }
1200
1121
  runPreBuild(svc, cwd, colorIdx) {
1201
1122
  this.log(svc.name, `\u{1F528} preBuild: ${svc.preBuild}`, colorIdx);
1202
- return new Promise((resolve4) => {
1123
+ return new Promise((res) => {
1203
1124
  const isWin = process.platform === "win32";
1204
1125
  const shell = isWin ? "cmd.exe" : "sh";
1205
1126
  const shellFlag = isWin ? "/c" : "-c";
1206
1127
  const env = buildProcessEnv(svc, this.env);
1207
- const child = spawn3(shell, [shellFlag, svc.preBuild], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
1128
+ const child = spawn2(shell, [shellFlag, svc.preBuild], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
1208
1129
  const outBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
1209
1130
  const errBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
1210
1131
  child.stdout?.on("data", (d) => outBuf.push(d));
1211
1132
  child.stderr?.on("data", (d) => errBuf.push(d));
1212
1133
  child.on("error", (err) => {
1213
1134
  this.log(svc.name, `[build] \u274C ${err.message}`, colorIdx);
1214
- resolve4(false);
1135
+ res(false);
1215
1136
  });
1216
1137
  child.on("close", (code) => {
1217
1138
  outBuf.flush();
1218
1139
  errBuf.flush();
1219
1140
  if (code === 0) {
1220
1141
  this.log(svc.name, `[build] \u2705 done`, colorIdx);
1221
- resolve4(true);
1142
+ res(true);
1222
1143
  } else {
1223
1144
  this.log(svc.name, `[build] \u274C exited with code ${code}`, colorIdx);
1224
- resolve4(false);
1145
+ res(false);
1225
1146
  }
1226
1147
  });
1227
1148
  });
@@ -1231,7 +1152,7 @@ var ProcessManager = class {
1231
1152
  const isWin = process.platform === "win32";
1232
1153
  const shell = isWin ? "cmd.exe" : "sh";
1233
1154
  const shellFlag = isWin ? "/c" : "-c";
1234
- const child = spawn3(shell, [shellFlag, svc.watchBuild], {
1155
+ const child = spawn2(shell, [shellFlag, svc.watchBuild], {
1235
1156
  cwd,
1236
1157
  env,
1237
1158
  detached: true,
@@ -1244,7 +1165,8 @@ var ProcessManager = class {
1244
1165
  child.on("error", (err) => this.log(svc.name, `[watch] \u274C ${err.message}`, colorIdx));
1245
1166
  return child;
1246
1167
  }
1247
- /** Create a state entry in 'crashed' status without spawning a process (used when preBuild fails). */
1168
+ /** Create a state entry in 'crashed' status without spawning a process
1169
+ * (used when preBuild fails or pre-flight checks fail). */
1248
1170
  recordCrashedState(svc, colorIdx) {
1249
1171
  const prev = this.state.get(svc.name);
1250
1172
  this.state.set(svc.name, {
@@ -1261,33 +1183,54 @@ var ProcessManager = class {
1261
1183
  });
1262
1184
  this.events.onStateChange(svc.name, this.state.get(svc.name));
1263
1185
  }
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);
1186
+ log(name, text, colorIdx) {
1187
+ this.events.onLog(name, text, colorIdx);
1270
1188
  }
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;
1189
+ };
1190
+
1191
+ // src/process/restarter.ts
1192
+ var Restarter = class {
1193
+ state;
1194
+ events;
1195
+ spawner;
1196
+ lifecycle;
1197
+ constructor(opts) {
1198
+ this.state = opts.state;
1199
+ this.events = opts.events;
1200
+ this.spawner = opts.spawner;
1201
+ this.lifecycle = opts.lifecycle;
1279
1202
  }
1280
1203
  async restart(name) {
1281
1204
  const st = this.state.get(name);
1282
1205
  if (!st) return;
1283
- this.stop(name);
1206
+ this.lifecycle.stop(name);
1284
1207
  st.restarts = 0;
1285
1208
  const delay = st.proc ? 1500 : 100;
1286
1209
  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);
1210
+ await this.spawner.start(st.svc, st.colorIdx, true);
1211
+ this.events.onLog(name, "\u{1F504} manual restart", st.colorIdx);
1212
+ }
1213
+ scheduleAutoRestart(svc, state, colorIdx) {
1214
+ if (state.restarts >= MAX_RESTARTS) {
1215
+ this.events.onLog(svc.name, "\u26D4 max restarts reached", colorIdx);
1216
+ return;
1217
+ }
1218
+ state.restarts++;
1219
+ const delay = BACKOFF_BASE_MS * Math.pow(2, state.restarts - 1);
1220
+ this.events.onLog(svc.name, `\u{1F504} auto-restart ${state.restarts}/${MAX_RESTARTS} in ${delay}ms...`, colorIdx);
1221
+ setTimeout(() => void this.spawner.start(svc, colorIdx, true), delay);
1222
+ }
1223
+ };
1224
+
1225
+ // src/process/health-poller.ts
1226
+ var HealthPoller = class {
1227
+ state;
1228
+ events;
1229
+ constructor(opts) {
1230
+ this.state = opts.state;
1231
+ this.events = opts.events;
1289
1232
  }
1290
- async checkAllHealth() {
1233
+ async checkAll() {
1291
1234
  for (const [name, st] of this.state) {
1292
1235
  if (!st.pid || st.status === "idle") {
1293
1236
  st.health = st.status === "idle" ? "idle" : "down";
@@ -1304,6 +1247,41 @@ var ProcessManager = class {
1304
1247
  if (prev !== st.health) this.events.onStateChange(name, st);
1305
1248
  }
1306
1249
  }
1250
+ };
1251
+
1252
+ // src/process/lifecycle.ts
1253
+ var Lifecycle = class {
1254
+ state;
1255
+ procs;
1256
+ platform;
1257
+ constructor(opts) {
1258
+ this.state = opts.state;
1259
+ this.procs = opts.procs;
1260
+ this.platform = opts.platform;
1261
+ }
1262
+ /** Manual / external stop of a single service. Marks `intentionalStop` so the
1263
+ * close handler doesn't auto-restart, kills the process tree, tears down the
1264
+ * side-car watchBuild process if any. */
1265
+ stop(name) {
1266
+ const st = this.state.get(name);
1267
+ if (!st?.proc || !st.pid) return;
1268
+ st.intentionalStop = true;
1269
+ this.platform.killTree(st.pid);
1270
+ this.stopWatchProc(st);
1271
+ }
1272
+ /** Tears down the side-car `watchBuild` process for a service (if any) and
1273
+ * clears the reference. Safe to call repeatedly. */
1274
+ stopWatchProc(state) {
1275
+ const wp = state.watchProc;
1276
+ if (!wp || !wp.pid) return;
1277
+ try {
1278
+ this.platform.killTree(wp.pid);
1279
+ } catch {
1280
+ }
1281
+ state.watchProc = null;
1282
+ }
1283
+ /** Graceful shutdown of every spawned process. Waits `gracePeriodMs` (default
1284
+ * 3000) for clean exits, then SIGKILLs anything still alive. */
1307
1285
  async cleanup(opts = {}) {
1308
1286
  const grace = opts.gracePeriodMs ?? 3e3;
1309
1287
  const procs = [...this.procs];
@@ -1344,1023 +1322,2245 @@ var ProcessManager = class {
1344
1322
  for (const st of this.state.values()) if (st.proc === proc) return st;
1345
1323
  return void 0;
1346
1324
  }
1347
- log(name, text, colorIdx) {
1348
- this.events.onLog(name, text, colorIdx);
1325
+ };
1326
+
1327
+ // src/process/manager.ts
1328
+ var ProcessManager = class {
1329
+ state = /* @__PURE__ */ new Map();
1330
+ procs = /* @__PURE__ */ new Set();
1331
+ baseCwd;
1332
+ env;
1333
+ events;
1334
+ spawner;
1335
+ restarter;
1336
+ healthPoller;
1337
+ lifecycle;
1338
+ constructor(opts) {
1339
+ this.baseCwd = opts.baseCwd;
1340
+ this.env = opts.env;
1341
+ this.events = opts.events;
1342
+ this.lifecycle = new Lifecycle({
1343
+ state: this.state,
1344
+ procs: this.procs,
1345
+ platform: opts.platform
1346
+ });
1347
+ let restarterRef = null;
1348
+ this.spawner = new Spawner({
1349
+ baseCwd: opts.baseCwd,
1350
+ env: opts.env,
1351
+ state: this.state,
1352
+ procs: this.procs,
1353
+ events: opts.events,
1354
+ lifecycle: this.lifecycle,
1355
+ onCrash: (svc, state, colorIdx) => restarterRef?.scheduleAutoRestart(svc, state, colorIdx)
1356
+ });
1357
+ this.restarter = new Restarter({
1358
+ state: this.state,
1359
+ events: opts.events,
1360
+ spawner: this.spawner,
1361
+ lifecycle: this.lifecycle
1362
+ });
1363
+ restarterRef = this.restarter;
1364
+ this.healthPoller = new HealthPoller({
1365
+ state: this.state,
1366
+ events: opts.events
1367
+ });
1368
+ }
1369
+ install(svc, colorIdx) {
1370
+ const cwd = join5(this.baseCwd, svc.cwd);
1371
+ const idx = colorIdx ?? this.state.get(svc.name)?.colorIdx ?? 0;
1372
+ return installService(cwd, this.env, (msg) => this.events.onLog(svc.name, msg, idx));
1373
+ }
1374
+ start(svc, colorIdx, isRestart = false) {
1375
+ return this.spawner.start(svc, colorIdx, isRestart);
1376
+ }
1377
+ stop(name) {
1378
+ this.lifecycle.stop(name);
1379
+ }
1380
+ restart(name) {
1381
+ return this.restarter.restart(name);
1382
+ }
1383
+ checkAllHealth() {
1384
+ return this.healthPoller.checkAll();
1385
+ }
1386
+ cleanup(opts = {}) {
1387
+ return this.lifecycle.cleanup(opts);
1349
1388
  }
1350
1389
  };
1351
1390
 
1352
- // src/tui/hooks/useProcessManager.ts
1353
- function useProcessManager(platform, baseCwd, env, logSink = null) {
1354
- const [states, setStates] = useState(/* @__PURE__ */ new Map());
1355
- const [logs, setLogs] = useState([]);
1356
- const [stats, setStats] = useState(/* @__PURE__ */ new Map());
1357
- const mgrRef = useRef(null);
1358
- const prevCpu = useRef(/* @__PURE__ */ new Map());
1359
- const pausedRef = useRef(false);
1360
- const pendingLogsRef = useRef([]);
1361
- const sinkRef = useRef(logSink);
1362
- sinkRef.current = logSink;
1363
- useEffect(() => {
1364
- const mgr2 = new ProcessManager({
1365
- baseCwd,
1366
- env,
1367
- platform,
1368
- events: {
1369
- onLog: (svcName, text, colorIdx) => {
1370
- sinkRef.current?.write(svcName, text);
1371
- const entry = { svcName, text, colorIdx, ts: Date.now(), level: detectLogLevel(text) };
1372
- if (pausedRef.current) {
1373
- pendingLogsRef.current.push(entry);
1374
- if (pendingLogsRef.current.length > 5e3) {
1375
- pendingLogsRef.current = pendingLogsRef.current.slice(-5e3);
1376
- }
1377
- return;
1378
- }
1379
- setLogs((prev) => {
1380
- const next = prev.concat(entry);
1381
- return next.length > 5e3 ? next.slice(-5e3) : next;
1382
- });
1383
- },
1384
- onStateChange: () => setStates(new Map(mgr2.state))
1391
+ // src/process/log-sink.ts
1392
+ import { existsSync as existsSync9, mkdirSync as mkdirSync2, renameSync, createWriteStream } from "fs";
1393
+ import { join as join6, dirname as dirname2 } from "path";
1394
+ import { homedir as homedir2 } from "os";
1395
+ var LogSink = class {
1396
+ dir;
1397
+ rotateOnStart;
1398
+ streams = /* @__PURE__ */ new Map();
1399
+ seen = /* @__PURE__ */ new Set();
1400
+ constructor(opts) {
1401
+ const root = opts.rootDir ?? join6(homedir2(), ".devup", "logs");
1402
+ this.dir = join6(root, sanitize(opts.projectName));
1403
+ this.rotateOnStart = opts.rotateOnStart ?? true;
1404
+ mkdirSync2(this.dir, { recursive: true });
1405
+ }
1406
+ /** Returns the file path for a service log (useful for tests / UI). */
1407
+ pathFor(svcName) {
1408
+ return join6(this.dir, `${sanitize(svcName)}.log`);
1409
+ }
1410
+ write(svcName, line) {
1411
+ const stream = this.streamFor(svcName);
1412
+ stream.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
1413
+ `);
1414
+ }
1415
+ async close() {
1416
+ const closes = [...this.streams.values()].map(
1417
+ (s) => new Promise((r) => s.end(() => r()))
1418
+ );
1419
+ this.streams.clear();
1420
+ this.seen.clear();
1421
+ await Promise.all(closes);
1422
+ }
1423
+ streamFor(svcName) {
1424
+ let s = this.streams.get(svcName);
1425
+ if (s) return s;
1426
+ const file = this.pathFor(svcName);
1427
+ if (this.rotateOnStart && !this.seen.has(svcName) && existsSync9(file)) {
1428
+ try {
1429
+ mkdirSync2(dirname2(file), { recursive: true });
1430
+ renameSync(file, file + ".prev");
1431
+ } catch {
1385
1432
  }
1433
+ }
1434
+ this.seen.add(svcName);
1435
+ s = createWriteStream(file, { flags: "a" });
1436
+ s.on("error", () => {
1386
1437
  });
1387
- mgrRef.current = mgr2;
1388
- return () => {
1389
- mgr2.cleanup();
1390
- };
1391
- }, [baseCwd, env, platform]);
1392
- useEffect(() => {
1393
- const id = setInterval(async () => {
1394
- const mgr2 = mgrRef.current;
1395
- if (!mgr2) return;
1396
- await mgr2.checkAllHealth();
1397
- setStates(new Map(mgr2.state));
1398
- const pids = [];
1399
- const pidMap = /* @__PURE__ */ new Map();
1400
- for (const [name, st] of mgr2.state) {
1401
- if (st.pid) {
1402
- pids.push(st.pid);
1403
- pidMap.set(st.pid, name);
1404
- }
1438
+ this.streams.set(svcName, s);
1439
+ return s;
1440
+ }
1441
+ };
1442
+ function sanitize(name) {
1443
+ return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
1444
+ }
1445
+
1446
+ // src/utils/broadcaster.ts
1447
+ var Broadcaster = class {
1448
+ subs = /* @__PURE__ */ new Set();
1449
+ subscribe(fn) {
1450
+ this.subs.add(fn);
1451
+ return () => this.subs.delete(fn);
1452
+ }
1453
+ emit(v) {
1454
+ for (const fn of this.subs) {
1455
+ try {
1456
+ fn(v);
1457
+ } catch {
1405
1458
  }
1406
- if (pids.length) {
1407
- const raw = await platform.getProcessStats(pids);
1408
- const next = /* @__PURE__ */ new Map();
1409
- for (const [pid, data] of raw) {
1410
- const name = pidMap.get(pid);
1411
- if (!name) continue;
1412
- const prev = prevCpu.current.get(name) ?? { time: Date.now(), cpu: 0 };
1413
- const cpuPct = calcCpuPercent(data.cpuSeconds, prev.cpu, prev.time);
1414
- prevCpu.current.set(name, { time: Date.now(), cpu: data.cpuSeconds });
1415
- next.set(name, { cpu: cpuPct.toFixed(1) + "%", mem: (data.rss / 1024).toFixed(1) + " MB" });
1416
- }
1417
- setStats(next);
1418
- }
1419
- }, 3e3);
1420
- return () => clearInterval(id);
1421
- }, [platform]);
1422
- const mgr = mgrRef.current;
1423
- const clearLogs = useCallback(() => {
1424
- pendingLogsRef.current = [];
1425
- setLogs([]);
1426
- }, []);
1427
- const pushLog = useCallback((svcName, text, colorIdx = 0) => {
1428
- sinkRef.current?.write(svcName, text);
1429
- const entry = { svcName, text, colorIdx, ts: Date.now(), level: detectLogLevel(text) };
1430
- if (pausedRef.current) {
1431
- pendingLogsRef.current.push(entry);
1432
- if (pendingLogsRef.current.length > 5e3) {
1433
- pendingLogsRef.current = pendingLogsRef.current.slice(-5e3);
1434
- }
1435
- return;
1436
- }
1437
- setLogs((prev) => {
1438
- const next = prev.concat(entry);
1439
- return next.length > 5e3 ? next.slice(-5e3) : next;
1440
- });
1441
- }, []);
1442
- const setPaused = useCallback((paused) => {
1443
- pausedRef.current = paused;
1444
- if (!paused && pendingLogsRef.current.length) {
1445
- const flush = pendingLogsRef.current;
1446
- pendingLogsRef.current = [];
1447
- setLogs((prev) => {
1448
- const next = prev.concat(flush);
1449
- return next.length > 5e3 ? next.slice(-5e3) : next;
1450
- });
1451
1459
  }
1452
- }, []);
1453
- return {
1454
- states,
1455
- logs,
1456
- stats,
1457
- start: useCallback((svc, colorIdx) => mgr?.start(svc, colorIdx), [mgr]),
1458
- stop: useCallback((name) => mgr?.stop(name), [mgr]),
1459
- restart: useCallback((name) => mgr?.restart(name), [mgr]),
1460
- install: useCallback((svc, colorIdx) => mgr?.install(svc, colorIdx), [mgr]),
1461
- cleanup: useCallback(() => mgr?.cleanup(), [mgr]),
1462
- clearLogs,
1463
- setPaused,
1464
- pushLog,
1465
- manager: mgr
1466
- };
1467
- }
1460
+ }
1461
+ };
1468
1462
 
1469
- // src/tui/hooks/useKeyBindings.ts
1470
- import { useInput } from "ink";
1471
- import { useState as useState2, useCallback as useCallback2 } from "react";
1472
- var SORT_MODES = ["name", "mem", "errors"];
1473
- function scrollBy(setState, delta) {
1474
- setState((s) => {
1475
- if (s.panel === "logs") {
1476
- const next2 = s.logsScrollOffset - delta;
1477
- return { ...s, logsScrollOffset: Math.max(0, next2) };
1463
+ // src/process/external.ts
1464
+ import { spawn as spawn3 } from "child_process";
1465
+ import { join as join7 } from "path";
1466
+ var DEFAULT_START_TIMEOUT_S = 60;
1467
+ async function startExternals(externals, opts) {
1468
+ const procs = [];
1469
+ const failed = [];
1470
+ for (const svc of externals) {
1471
+ const proc = spawnExternal(svc, opts);
1472
+ procs.push({ svc, proc, pid: proc.pid ?? null });
1473
+ if (!svc.healthCheck) {
1474
+ opts.onLog?.(svc.name, "\u2705 started (no healthCheck)");
1475
+ continue;
1478
1476
  }
1479
- const next = s.statsScrollOffset + delta;
1480
- return { ...s, statsScrollOffset: Math.max(0, next) };
1481
- });
1477
+ if (svc.healthCheck.type === "tcp" && !svc.port) {
1478
+ opts.onLog?.(svc.name, "\u26A0 tcp healthCheck requires `port` \u2014 skipping wait");
1479
+ continue;
1480
+ }
1481
+ const timeoutMs = (svc.startTimeout ?? DEFAULT_START_TIMEOUT_S) * 1e3;
1482
+ const ok = await waitHealthy(svc, timeoutMs);
1483
+ if (ok) {
1484
+ opts.onLog?.(svc.name, "\u2705 healthy");
1485
+ } else {
1486
+ opts.onLog?.(svc.name, `\u274C never became healthy (timeout ${timeoutMs / 1e3}s)`);
1487
+ failed.push(svc.name);
1488
+ }
1489
+ }
1490
+ return { procs, allHealthy: failed.length === 0, failed };
1482
1491
  }
1483
- function scrollTo(setState, target) {
1484
- setState((s) => {
1485
- if (s.panel === "logs") {
1486
- return { ...s, logsScrollOffset: target === "top" ? Number.MAX_SAFE_INTEGER : 0 };
1492
+ async function stopExternals(procs, platform, opts = {}) {
1493
+ for (const { svc, proc, pid } of procs) {
1494
+ try {
1495
+ if (pid) platform.killTree(pid);
1496
+ if (svc.stopCmd) {
1497
+ opts.onLog?.(svc.name, `\u{1F9F9} ${svc.stopCmd}`);
1498
+ await new Promise((resolve4) => {
1499
+ const isWin = process.platform === "win32";
1500
+ const shell = isWin ? "cmd.exe" : "sh";
1501
+ const flag = isWin ? "/c" : "-c";
1502
+ const cwd = svc.cwd ? join7(opts.baseCwd, svc.cwd) : opts.baseCwd;
1503
+ const env = { ...opts.env, ...svc.extraEnv ?? {} };
1504
+ const child = spawn3(shell, [flag, svc.stopCmd], { cwd, env, stdio: "ignore" });
1505
+ child.on("close", () => resolve4());
1506
+ child.on("error", () => resolve4());
1507
+ setTimeout(() => resolve4(), 1e4);
1508
+ });
1509
+ }
1510
+ } catch {
1487
1511
  }
1488
- return { ...s, statsScrollOffset: target === "top" ? 0 : Number.MAX_SAFE_INTEGER };
1489
- });
1512
+ void proc;
1513
+ }
1490
1514
  }
1491
- function useKeyBindings(opts) {
1492
- const [state, setState] = useState2({
1493
- panel: "logs",
1494
- modal: "none",
1495
- logFilter: null,
1496
- searchTerm: null,
1497
- logsPaused: false,
1498
- showTimestamps: false,
1499
- sortIdx: 0,
1500
- proxyEnabled: false,
1501
- logsScrollOffset: 0,
1502
- statsScrollOffset: 0,
1503
- levelFilter: "all",
1504
- verboseStats: false
1515
+ function spawnExternal(svc, opts) {
1516
+ const isWin = process.platform === "win32";
1517
+ const shell = isWin ? "cmd.exe" : "sh";
1518
+ const flag = isWin ? "/c" : "-c";
1519
+ const cwd = svc.cwd ? join7(opts.baseCwd, svc.cwd) : opts.baseCwd;
1520
+ const env = { ...opts.env, ...svc.extraEnv ?? {} };
1521
+ opts.onLog?.(svc.name, `\u{1F680} ${svc.cmd}`);
1522
+ const child = spawn3(shell, [flag, svc.cmd], {
1523
+ cwd,
1524
+ env,
1525
+ detached: true,
1526
+ stdio: ["ignore", "pipe", "pipe"]
1505
1527
  });
1506
- const LEVEL_CYCLE = ["all", "error", "warn"];
1507
- const setModal = useCallback2((modal) => setState((s) => ({ ...s, modal })), []);
1508
- const setFilter = useCallback2((f) => setState((s) => ({ ...s, logFilter: f, modal: "none" })), []);
1509
- const setSearch = useCallback2((t) => setState((s) => ({ ...s, searchTerm: t, modal: "none" })), []);
1510
- const isActive = process.stdin.isTTY ?? false;
1511
- useInput((input, key) => {
1512
- if (state.modal !== "none") return;
1513
- if (input === "q" || key.ctrl && input === "c") opts.onQuit();
1514
- else if (key.ctrl && input === "a") scrollTo(setState, "top");
1515
- else if (key.ctrl && input === "e") scrollTo(setState, "bottom");
1516
- else if (key.ctrl && input === "b") scrollBy(setState, -10);
1517
- else if (key.ctrl && input === "f") scrollBy(setState, 10);
1518
- else if (key.upArrow) scrollBy(setState, -1);
1519
- else if (key.downArrow) scrollBy(setState, 1);
1520
- else if (input === "[") scrollBy(setState, -10);
1521
- else if (input === "]") scrollBy(setState, 10);
1522
- else if (input === "c") opts.onClearLogs();
1523
- else if (key.tab) setState((s) => ({ ...s, panel: s.panel === "logs" ? "stats" : "logs" }));
1524
- else if (input === "f") setModal("filter");
1525
- else if (input === "r") setModal("restart");
1526
- else if (input === "o") setModal("open");
1527
- else if (input === "/") setModal("search");
1528
- else if (input === "a") setState((s) => ({ ...s, logFilter: null, searchTerm: null, levelFilter: "all" }));
1529
- else if (input === "p") setState((s) => ({ ...s, logsPaused: !s.logsPaused }));
1530
- else if (input === "t") setState((s) => ({ ...s, showTimestamps: !s.showTimestamps }));
1531
- else if (input === "s") setState((s) => ({ ...s, sortIdx: (s.sortIdx + 1) % SORT_MODES.length }));
1532
- else if (input === "T") {
1533
- opts.onToggleProxy();
1534
- setState((s) => ({ ...s, proxyEnabled: !s.proxyEnabled }));
1535
- } else if (input === "L") setState((s) => ({ ...s, levelFilter: LEVEL_CYCLE[(LEVEL_CYCLE.indexOf(s.levelFilter) + 1) % LEVEL_CYCLE.length] }));
1536
- else if (input === "v") setState((s) => ({ ...s, verboseStats: !s.verboseStats }));
1537
- }, { isActive });
1538
- return {
1539
- ...state,
1540
- setModal,
1541
- setFilter,
1542
- setSearch,
1543
- sortMode: SORT_MODES[state.sortIdx],
1544
- // Funciones para resetear el scroll cuando cambia el contenido
1545
- resetLogsScroll: useCallback2(() => setState((s) => ({ ...s, logsScrollOffset: 0 })), []),
1546
- resetStatsScroll: useCallback2(() => setState((s) => ({ ...s, statsScrollOffset: 0 })), [])
1547
- };
1528
+ child.stdout?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
1529
+ child.stderr?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
1530
+ child.on("error", (err) => opts.onLog?.(svc.name, `\u274C spawn error: ${err.message}`));
1531
+ return child;
1548
1532
  }
1549
-
1550
- // src/tui/hooks/useProxySync.ts
1551
- import { useEffect as useEffect2, useRef as useRef2 } from "react";
1552
- function useProxySync(provider, opts, states, enabled) {
1553
- const statesRef = useRef2(states);
1554
- const lastContentRef = useRef2(null);
1555
- statesRef.current = states;
1556
- useEffect2(() => {
1557
- if (!provider || !opts || !enabled) return;
1558
- const sync = () => {
1559
- const svcStates = /* @__PURE__ */ new Map();
1560
- for (const [name, st] of statesRef.current) {
1561
- svcStates.set(name, { port: st.svc.port, health: st.health, realPort: st.svc.realPort });
1562
- }
1563
- const content = provider.generate(svcStates, opts);
1564
- if (content === lastContentRef.current) return;
1565
- lastContentRef.current = content;
1566
- provider.write(content, opts);
1567
- };
1568
- sync();
1569
- const id = setInterval(sync, 3e3);
1570
- return () => {
1571
- clearInterval(id);
1572
- lastContentRef.current = null;
1573
- };
1574
- }, [provider, opts, enabled]);
1533
+ async function waitHealthy(svc, timeoutMs) {
1534
+ const deadline = Date.now() + timeoutMs;
1535
+ const port = svc.port;
1536
+ while (Date.now() < deadline) {
1537
+ if (await checkHealth(port, svc.healthCheck)) return true;
1538
+ await new Promise((r) => setTimeout(r, 500));
1539
+ }
1540
+ return false;
1575
1541
  }
1576
1542
 
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];
1543
+ // src/lazy/proxy.ts
1544
+ import net2 from "net";
1545
+ function createLazyProxy(opts) {
1546
+ const { listenPort, targetPort, timeoutMin, onDemandStart, onIdleStop, isAlive, onLog } = opts;
1547
+ let idleTimer = null;
1548
+ let lastActivity = Date.now();
1549
+ let starting = false;
1550
+ let serviceReady = false;
1551
+ let pendingConns = [];
1552
+ const activeConns = /* @__PURE__ */ new Set();
1553
+ function bumpActivity() {
1554
+ lastActivity = Date.now();
1585
1555
  }
1586
- return "gray";
1587
- }
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
- ] });
1556
+ function scheduleIdleCheck() {
1557
+ if (idleTimer) clearTimeout(idleTimer);
1558
+ if (timeoutMin <= 0) return;
1559
+ const periodMs = timeoutMin * 6e4;
1560
+ idleTimer = setTimeout(() => {
1561
+ const elapsed = Date.now() - lastActivity;
1562
+ if (activeConns.size > 0 || elapsed < periodMs) {
1563
+ scheduleIdleCheck();
1564
+ return;
1565
+ }
1566
+ serviceReady = false;
1567
+ onLog?.(`\u{1F4A4} idle ${timeoutMin}min \u2014 stopping`);
1568
+ onIdleStop();
1569
+ }, periodMs);
1570
+ }
1571
+ function pipeToTarget(client) {
1572
+ const target = net2.createConnection({ port: targetPort, host: "127.0.0.1", allowHalfOpen: true });
1573
+ activeConns.add(client);
1574
+ const cleanup = () => {
1575
+ activeConns.delete(client);
1576
+ bumpActivity();
1577
+ };
1578
+ target.on("error", () => {
1579
+ client.destroy();
1580
+ cleanup();
1581
+ });
1582
+ client.on("error", () => {
1583
+ target.destroy();
1584
+ cleanup();
1585
+ });
1586
+ client.on("close", cleanup);
1587
+ target.on("close", cleanup);
1588
+ target.on("connect", () => {
1589
+ target.on("data", (chunk) => {
1590
+ bumpActivity();
1591
+ if (!client.destroyed) client.write(chunk);
1592
+ });
1593
+ client.on("data", (chunk) => {
1594
+ bumpActivity();
1595
+ if (!target.destroyed) target.write(chunk);
1596
+ });
1597
+ target.on("end", () => {
1598
+ if (!client.destroyed) client.end();
1599
+ });
1600
+ client.on("end", () => {
1601
+ if (!target.destroyed) target.end();
1602
+ });
1603
+ });
1604
+ }
1605
+ async function handleConnection(client) {
1606
+ bumpActivity();
1607
+ client.on("error", () => {
1608
+ });
1609
+ if (serviceReady && isAlive()) {
1610
+ pipeToTarget(client);
1611
+ return;
1612
+ }
1613
+ pendingConns.push(client);
1614
+ client.on("close", () => {
1615
+ pendingConns = pendingConns.filter((s) => s !== client);
1616
+ });
1617
+ if (starting) return;
1618
+ starting = true;
1619
+ onLog?.("\u26A1 on-demand start");
1620
+ let ok = false;
1621
+ try {
1622
+ await onDemandStart();
1623
+ ok = await waitForPort(targetPort, { timeout: 45e3, interval: 500 });
1624
+ if (ok) serviceReady = true;
1625
+ else onLog?.("\u26A0 timeout waiting for service");
1626
+ } catch (e) {
1627
+ onLog?.(`\u274C start failed: ${e.message}`);
1628
+ }
1629
+ starting = false;
1630
+ const conns = pendingConns.splice(0);
1631
+ if (!ok) {
1632
+ for (const conn of conns) {
1633
+ if (!conn.destroyed) conn.destroy();
1634
+ }
1635
+ return;
1636
+ }
1637
+ for (const conn of conns) {
1638
+ if (!conn.destroyed) pipeToTarget(conn);
1639
+ }
1640
+ }
1641
+ const server = net2.createServer({ allowHalfOpen: true }, (socket) => handleConnection(socket));
1642
+ server.listen(listenPort, "0.0.0.0");
1643
+ scheduleIdleCheck();
1644
+ return {
1645
+ server,
1646
+ resetTimer: bumpActivity,
1647
+ destroy: () => {
1648
+ if (idleTimer) clearTimeout(idleTimer);
1649
+ pendingConns.forEach((s) => s.destroy());
1650
+ activeConns.forEach((s) => s.destroy());
1651
+ server.close();
1652
+ }
1653
+ };
1637
1654
  }
1638
1655
 
1639
- // src/tui/StatsPanel.tsx
1640
- import { useEffect as useEffect4, useState as useState3 } from "react";
1641
- import { Box as Box2, Text as Text2 } from "ink";
1642
- import os from "os";
1643
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1644
- var H = {
1645
- up: { c: "\u25CF", color: "green" },
1646
- wait: { c: "\u25CF", color: "yellow" },
1647
- down: { c: "\u25CF", color: "red" },
1648
- idle: { c: "\u25CB", color: "blue" }
1649
- };
1650
- var MAX_RESTARTS2 = 3;
1651
- function isCrashLooped(st) {
1652
- return st.status === "crashed" && st.restarts >= MAX_RESTARTS2;
1656
+ // src/orchestrator/config-watcher.ts
1657
+ import { watchFile, unwatchFile } from "fs";
1658
+
1659
+ // src/config/diff.ts
1660
+ var SPAWN_RELEVANT = [
1661
+ "cwd",
1662
+ "cmd",
1663
+ "args",
1664
+ "port",
1665
+ "phase",
1666
+ "maxMem",
1667
+ "preBuild",
1668
+ "watchBuild",
1669
+ "nodeArgs",
1670
+ "extraEnv",
1671
+ "healthCheck",
1672
+ "readyPattern",
1673
+ "errorPattern",
1674
+ "type"
1675
+ ];
1676
+ function hasSpawnRelevantChange(prev, next) {
1677
+ for (const k of SPAWN_RELEVANT) {
1678
+ if (JSON.stringify(prev[k]) !== JSON.stringify(next[k])) return true;
1679
+ }
1680
+ return false;
1653
1681
  }
1654
- function Row({ name, st, stat, ml, verbose }) {
1655
- 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 });
1657
- const color = tagColors[st.colorIdx % tagColors.length];
1658
- const sc = looped ? "red" : st.status === "running" ? "green" : st.status === "starting" ? "yellow" : st.status === "idle" ? "blue" : "red";
1659
- const statusLabel = looped ? "looping" : st.status;
1660
- const up = st.startedAt ? fmtUptime(Date.now() - st.startedAt) : "-";
1661
- if (!verbose) {
1662
- return /* @__PURE__ */ jsxs2(Text2, { children: [
1663
- indicator,
1664
- " ",
1665
- /* @__PURE__ */ jsx2(Text2, { color, children: name.padEnd(ml) }),
1666
- " ",
1667
- String(st.svc.port).padStart(5),
1668
- " ",
1669
- /* @__PURE__ */ jsx2(Text2, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
1670
- " ",
1671
- (stat?.cpu ?? "-").padStart(6),
1672
- " ",
1673
- (stat?.mem ?? "-").padStart(8),
1674
- " ",
1675
- String(st.errors).padStart(3),
1676
- " ",
1677
- String(st.restarts).padStart(3),
1678
- " ",
1679
- up.padStart(6)
1680
- ] });
1682
+ function diffServices(prev, next) {
1683
+ const prevByName = new Map(prev.map((s) => [s.name, s]));
1684
+ const nextByName = new Map(next.map((s) => [s.name, s]));
1685
+ const added = [];
1686
+ const removed = [];
1687
+ const changed = [];
1688
+ const unchanged = [];
1689
+ for (const [name, p] of prevByName) {
1690
+ if (!nextByName.has(name)) {
1691
+ removed.push(name);
1692
+ continue;
1693
+ }
1694
+ const n = nextByName.get(name);
1695
+ if (hasSpawnRelevantChange(p, n)) changed.push({ prev: p, next: n });
1696
+ else unchanged.push(name);
1681
1697
  }
1682
- const resolvedArgs = buildProcessArgs(st.svc).join(" ");
1683
- const env = redactSecrets(st.svc.extraEnv);
1684
- const envStr = Object.entries(env).map(([k, v]) => `${k}=${v}`).join(" ");
1685
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
1686
- /* @__PURE__ */ jsxs2(Text2, { children: [
1687
- indicator,
1688
- " ",
1689
- /* @__PURE__ */ jsx2(Text2, { color, children: name.padEnd(ml) }),
1690
- " ",
1691
- String(st.svc.port).padStart(5),
1692
- " ",
1693
- /* @__PURE__ */ jsx2(Text2, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
1694
- " ",
1695
- (stat?.cpu ?? "-").padStart(6),
1696
- " ",
1697
- (stat?.mem ?? "-").padStart(8),
1698
- " ",
1699
- String(st.errors).padStart(3),
1700
- " ",
1701
- String(st.restarts).padStart(3),
1702
- " ",
1703
- up.padStart(6)
1704
- ] }),
1705
- /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1706
- " cmd: ",
1707
- st.svc.cmd,
1708
- " ",
1709
- resolvedArgs
1710
- ] }),
1711
- envStr && /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1712
- " env: ",
1713
- envStr
1714
- ] })
1715
- ] });
1698
+ for (const [name, n] of nextByName) {
1699
+ if (!prevByName.has(name)) added.push(n);
1700
+ }
1701
+ return { added, removed, changed, unchanged };
1702
+ }
1703
+ function summariseDiff(d) {
1704
+ const parts = [];
1705
+ if (d.added.length) parts.push(`+${d.added.length} added`);
1706
+ if (d.removed.length) parts.push(`-${d.removed.length} removed`);
1707
+ if (d.changed.length) parts.push(`~${d.changed.length} changed`);
1708
+ if (!parts.length) parts.push("no changes");
1709
+ return parts.join(", ");
1710
+ }
1711
+
1712
+ // src/orchestrator/config-watcher.ts
1713
+ async function applyConfigChange(opts) {
1714
+ const { configPath, baseCwd, manager, log } = opts;
1715
+ try {
1716
+ const nextCfg = await loadConfig(configPath);
1717
+ const errs = validateConfig(nextCfg, baseCwd);
1718
+ if (errs.length) {
1719
+ log(`\u26A0 config reload failed:
1720
+ ${formatValidationErrors(errs)}`);
1721
+ return;
1722
+ }
1723
+ const currentSvcs = [...manager.state.values()].map((s) => s.svc);
1724
+ const diff = diffServices(currentSvcs, nextCfg.services);
1725
+ if (!diff.added.length && !diff.removed.length && !diff.changed.length) return;
1726
+ for (const name of diff.removed) {
1727
+ manager.stop(name);
1728
+ manager.state.delete(name);
1729
+ }
1730
+ let colorIdx = currentSvcs.length;
1731
+ for (const { next } of diff.changed) {
1732
+ const prev = manager.state.get(next.name);
1733
+ const ci = prev?.colorIdx ?? colorIdx++;
1734
+ manager.stop(next.name);
1735
+ await new Promise((r) => setTimeout(r, 800));
1736
+ await manager.install(next, ci);
1737
+ await manager.start(next, ci, true);
1738
+ }
1739
+ for (const next of diff.added) {
1740
+ const ci = colorIdx++;
1741
+ await manager.install(next, ci);
1742
+ await manager.start(next, ci);
1743
+ }
1744
+ log(`\u{1F501} config reloaded: ${summariseDiff(diff)}`);
1745
+ } catch (e) {
1746
+ log(`\u26A0 config reload error: ${e.message}`);
1747
+ }
1748
+ }
1749
+ function watchConfig(opts) {
1750
+ let debounceTimer = null;
1751
+ let reloadInFlight = false;
1752
+ let reloadAgain = false;
1753
+ const trigger = async () => {
1754
+ if (reloadInFlight) {
1755
+ reloadAgain = true;
1756
+ return;
1757
+ }
1758
+ reloadInFlight = true;
1759
+ try {
1760
+ await applyConfigChange(opts);
1761
+ } finally {
1762
+ reloadInFlight = false;
1763
+ if (reloadAgain) {
1764
+ reloadAgain = false;
1765
+ void trigger();
1766
+ }
1767
+ }
1768
+ };
1769
+ const listener = (curr, prev) => {
1770
+ if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return;
1771
+ if (debounceTimer) clearTimeout(debounceTimer);
1772
+ debounceTimer = setTimeout(() => void trigger(), 250);
1773
+ };
1774
+ watchFile(opts.configPath, { interval: 500 }, listener);
1775
+ return () => {
1776
+ if (debounceTimer) clearTimeout(debounceTimer);
1777
+ unwatchFile(opts.configPath, listener);
1778
+ };
1779
+ }
1780
+
1781
+ // src/orchestrator/daemon.ts
1782
+ var SAFE = /[^a-zA-Z0-9._-]+/g;
1783
+ var sanitize2 = (n) => n.replace(SAFE, "_").replace(/^_+|_+$/g, "") || "devup";
1784
+ var devupDir = () => join8(homedir3(), ".devup");
1785
+ function pidPathFor(projectName) {
1786
+ return join8(devupDir(), `${sanitize2(projectName)}.pid`);
1787
+ }
1788
+ function bootErrorPathFor(projectName) {
1789
+ return join8(devupDir(), `${sanitize2(projectName)}.boot-error`);
1790
+ }
1791
+ function pidAlive(pid) {
1792
+ try {
1793
+ process.kill(pid, 0);
1794
+ return true;
1795
+ } catch {
1796
+ return false;
1797
+ }
1798
+ }
1799
+ function isDaemonRunning(projectName) {
1800
+ const path = pidPathFor(projectName);
1801
+ if (!existsSync10(path)) return { pid: null, stale: false };
1802
+ let pid;
1803
+ try {
1804
+ pid = Number(readFileSync3(path, "utf8").trim());
1805
+ if (!pid || !Number.isFinite(pid)) return { pid: null, stale: true };
1806
+ } catch {
1807
+ return { pid: null, stale: true };
1808
+ }
1809
+ return pidAlive(pid) ? { pid, stale: false } : { pid, stale: true };
1810
+ }
1811
+ async function daemonBody(opts) {
1812
+ const { config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts } = opts;
1813
+ const projectName = config.name;
1814
+ const errPath = bootErrorPathFor(projectName);
1815
+ const pidPath = pidPathFor(projectName);
1816
+ mkdirSync3(devupDir(), { recursive: true });
1817
+ if (existsSync10(errPath)) {
1818
+ try {
1819
+ unlinkSync2(errPath);
1820
+ } catch {
1821
+ }
1822
+ }
1823
+ const logSink = new LogSink({ projectName, rootDir: cliArgs.logDir });
1824
+ const logBus = new Broadcaster();
1825
+ const stateBus = new Broadcaster();
1826
+ const lazyProxies = /* @__PURE__ */ new Map();
1827
+ let externals = [];
1828
+ let socket = null;
1829
+ let healthTimer = null;
1830
+ let proxyTimer = null;
1831
+ let stopConfigWatcher = null;
1832
+ const writeDevupLog = (text) => {
1833
+ logSink.write("devup", text);
1834
+ logBus.emit({ svc: "devup", text });
1835
+ };
1836
+ const mgr = new ProcessManager({
1837
+ baseCwd,
1838
+ env,
1839
+ platform,
1840
+ events: {
1841
+ onLog: (svcName, text) => {
1842
+ logSink.write(svcName, text);
1843
+ logBus.emit({ svc: svcName, text });
1844
+ },
1845
+ onStateChange: (name, state) => stateBus.emit({ name, state })
1846
+ }
1847
+ });
1848
+ const cleanup = async () => {
1849
+ if (healthTimer) clearInterval(healthTimer);
1850
+ if (proxyTimer) clearInterval(proxyTimer);
1851
+ if (stopConfigWatcher) {
1852
+ try {
1853
+ stopConfigWatcher();
1854
+ } catch {
1855
+ }
1856
+ }
1857
+ for (const p of lazyProxies.values()) p.destroy();
1858
+ if (socket) await socket.close().catch(() => {
1859
+ });
1860
+ await mgr.cleanup().catch(() => {
1861
+ });
1862
+ if (externals.length) {
1863
+ await stopExternals(externals, platform, {
1864
+ baseCwd,
1865
+ env,
1866
+ onLog: (svc, msg) => logSink.write(`ext:${svc}`, msg)
1867
+ }).catch(() => {
1868
+ });
1869
+ }
1870
+ if (proxyProvider && proxyOpts && cliArgs.proxy) {
1871
+ try {
1872
+ proxyProvider.clear(proxyOpts);
1873
+ } catch {
1874
+ }
1875
+ }
1876
+ await logSink.close().catch(() => {
1877
+ });
1878
+ if (existsSync10(pidPath)) {
1879
+ try {
1880
+ unlinkSync2(pidPath);
1881
+ } catch {
1882
+ }
1883
+ }
1884
+ };
1885
+ let shuttingDown = false;
1886
+ const onSignal = () => {
1887
+ if (shuttingDown) return;
1888
+ shuttingDown = true;
1889
+ cleanup().then(() => process.exit(0), () => process.exit(1));
1890
+ };
1891
+ process.on("SIGTERM", onSignal);
1892
+ process.on("SIGINT", onSignal);
1893
+ try {
1894
+ if (config.external?.length) {
1895
+ writeDevupLog(`\u25B6 externals (${config.external.length})`);
1896
+ const result = await startExternals(config.external, {
1897
+ baseCwd,
1898
+ env,
1899
+ platform,
1900
+ onLog: (svc, msg) => {
1901
+ logSink.write(`ext:${svc}`, msg);
1902
+ logBus.emit({ svc: `ext:${svc}`, text: msg });
1903
+ }
1904
+ });
1905
+ externals = result.procs;
1906
+ if (!result.allHealthy) {
1907
+ throw new Error(`externals failed: ${result.failed.join(", ")}`);
1908
+ }
1909
+ }
1910
+ if (cliArgs.lazy && config.lazy) {
1911
+ await bootLazy(mgr, services, config.lazy, cliArgs.lazyTimeout, lazyProxies);
1912
+ } else {
1913
+ await bootNormal(mgr, services);
1914
+ }
1915
+ socket = await startSocketServer(projectName, {
1916
+ states: () => mgr.state,
1917
+ restart: (n) => mgr.restart(n),
1918
+ stop: (n) => mgr.stop(n),
1919
+ tailLogs: async (svcName, lines) => {
1920
+ const file = logSink.pathFor(svcName);
1921
+ if (!existsSync10(file)) return [];
1922
+ return new Promise((resolve4, reject) => {
1923
+ const buf = [];
1924
+ const rl = createInterface3({ input: createReadStream(file, { encoding: "utf8" }) });
1925
+ rl.on("line", (l) => {
1926
+ buf.push(l);
1927
+ if (buf.length > lines) buf.shift();
1928
+ });
1929
+ rl.on("close", () => resolve4(buf));
1930
+ rl.on("error", reject);
1931
+ });
1932
+ },
1933
+ watchLogs: (svcName, onLine) => logBus.subscribe(({ svc, text }) => {
1934
+ if (svcName === null || svc === svcName) onLine(svc, text);
1935
+ }),
1936
+ watchStatus: (onUpdate) => stateBus.subscribe(({ name, state }) => onUpdate(name, state))
1937
+ }, { onLog: (msg) => writeDevupLog(msg) });
1938
+ healthTimer = setInterval(() => {
1939
+ void mgr.checkAllHealth();
1940
+ }, 3e3);
1941
+ if (proxyProvider && proxyOpts && cliArgs.proxy) {
1942
+ let lastContent = null;
1943
+ const sync = () => {
1944
+ const svcStates = /* @__PURE__ */ new Map();
1945
+ for (const [n, st] of mgr.state) {
1946
+ svcStates.set(n, { port: st.svc.port, health: st.health, realPort: st.svc.realPort });
1947
+ }
1948
+ const content = proxyProvider.generate(svcStates, proxyOpts);
1949
+ if (content === lastContent) return;
1950
+ lastContent = content;
1951
+ try {
1952
+ proxyProvider.write(content, proxyOpts);
1953
+ } catch {
1954
+ }
1955
+ };
1956
+ sync();
1957
+ proxyTimer = setInterval(sync, 3e3);
1958
+ }
1959
+ if (cliArgs.watchConfig) {
1960
+ try {
1961
+ const configPath = findConfigFile(baseCwd, cliArgs.configPath);
1962
+ writeDevupLog(`\u{1F440} watching ${configPath}`);
1963
+ stopConfigWatcher = watchConfig({
1964
+ configPath,
1965
+ baseCwd,
1966
+ manager: mgr,
1967
+ log: (msg) => writeDevupLog(msg)
1968
+ });
1969
+ } catch (e) {
1970
+ writeDevupLog(`\u26A0 watch-config disabled: ${e.message ?? String(e)}`);
1971
+ }
1972
+ }
1973
+ writeFileSync2(pidPath, String(process.pid));
1974
+ writeDevupLog(`\u2713 daemon ready (pid=${process.pid})`);
1975
+ } catch (e) {
1976
+ try {
1977
+ writeFileSync2(errPath, e.message ?? String(e));
1978
+ } catch {
1979
+ }
1980
+ try {
1981
+ writeDevupLog(`\u274C boot failed: ${e.message ?? String(e)}`);
1982
+ } catch {
1983
+ }
1984
+ await cleanup().catch(() => {
1985
+ });
1986
+ process.exit(1);
1987
+ }
1988
+ }
1989
+ async function bootNormal(mgr, services) {
1990
+ const phases = groupByPhase(services);
1991
+ let colorIdx = 0;
1992
+ for (const num of Object.keys(phases).map(Number).sort((a, b) => a - b)) {
1993
+ for (const svc of phases[num]) {
1994
+ const ci = colorIdx++;
1995
+ await mgr.install(svc, ci);
1996
+ await mgr.start(svc, ci);
1997
+ }
1998
+ const apis = phases[num].filter((s) => s.type === "api");
1999
+ if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
2000
+ phases[num].filter((s) => s.type === "web").forEach((s) => {
2001
+ const st = mgr.state.get(s.name);
2002
+ if (st) st.status = "running";
2003
+ });
2004
+ }
2005
+ }
2006
+ async function bootLazy(mgr, services, lazyCfg, lazyTimeout, lazyProxies) {
2007
+ const { alwaysOn, lazy } = classifyServices(services, lazyCfg);
2008
+ const phases = groupByPhase(alwaysOn);
2009
+ let colorIdx = 0;
2010
+ for (const num of Object.keys(phases).map(Number).sort((a, b) => a - b)) {
2011
+ for (const svc of phases[num]) {
2012
+ const ci = colorIdx++;
2013
+ await mgr.install(svc, ci);
2014
+ await mgr.start(svc, ci);
2015
+ }
2016
+ const apis = phases[num].filter((s) => s.type === "api");
2017
+ if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
2018
+ phases[num].filter((s) => s.type === "web").forEach((s) => {
2019
+ const st = mgr.state.get(s.name);
2020
+ if (st) st.status = "running";
2021
+ });
2022
+ }
2023
+ for (const svc of lazy) {
2024
+ const ci = colorIdx++;
2025
+ const rewritten = rewriteServicePort(svc);
2026
+ mgr.state.set(svc.name, {
2027
+ svc: rewritten,
2028
+ proc: null,
2029
+ pid: null,
2030
+ status: "idle",
2031
+ health: "idle",
2032
+ errors: 0,
2033
+ restarts: 0,
2034
+ startedAt: null,
2035
+ intentionalStop: false,
2036
+ colorIdx: ci
2037
+ });
2038
+ const proxy = createLazyProxy({
2039
+ listenPort: svc.port,
2040
+ targetPort: rewritten.realPort,
2041
+ timeoutMin: lazyTimeout,
2042
+ onDemandStart: async () => {
2043
+ await mgr.install(rewritten, ci);
2044
+ await mgr.start(rewritten, ci);
2045
+ const ok = await waitForPort(rewritten.realPort, { timeout: 45e3 });
2046
+ const st = mgr.state.get(svc.name);
2047
+ if (st) {
2048
+ st.status = ok ? "running" : "timeout";
2049
+ if (ok) st.health = "up";
2050
+ }
2051
+ },
2052
+ onIdleStop: () => {
2053
+ mgr.stop(svc.name);
2054
+ const st = mgr.state.get(svc.name);
2055
+ if (st) {
2056
+ st.status = "idle";
2057
+ st.health = "idle";
2058
+ st.pid = null;
2059
+ st.proc = null;
2060
+ st.startedAt = null;
2061
+ }
2062
+ },
2063
+ isAlive: () => {
2064
+ const st = mgr.state.get(svc.name);
2065
+ return !!st && !!st.proc && !st.proc.killed && st.health === "up";
2066
+ }
2067
+ });
2068
+ lazyProxies.set(svc.name, proxy);
2069
+ }
2070
+ }
2071
+ async function runDetached(opts) {
2072
+ const out = opts.out ?? ((l) => process.stdout.write(l + "\n"));
2073
+ const projectName = opts.config.name;
2074
+ if (process.platform === "win32") {
2075
+ out("\u274C daemon mode (devup up -d) is not yet supported on Windows. Run `devup` to use the TUI instead.");
2076
+ return 1;
2077
+ }
2078
+ const existing = isDaemonRunning(projectName);
2079
+ if (existing.pid && !existing.stale) {
2080
+ out(`\u274C daemon already running for "${projectName}" (pid=${existing.pid}). Run \`devup down\` to stop it.`);
2081
+ return 1;
2082
+ }
2083
+ if (existing.stale) {
2084
+ out(`\u2139 removing stale pid file for "${projectName}"`);
2085
+ try {
2086
+ unlinkSync2(pidPathFor(projectName));
2087
+ } catch {
2088
+ }
2089
+ }
2090
+ mkdirSync3(devupDir(), { recursive: true });
2091
+ const errPath = bootErrorPathFor(projectName);
2092
+ const pidPath = pidPathFor(projectName);
2093
+ if (existsSync10(errPath)) {
2094
+ try {
2095
+ unlinkSync2(errPath);
2096
+ } catch {
2097
+ }
2098
+ }
2099
+ const filteredArgs = process.argv.slice(2).filter((arg, i) => {
2100
+ if (i === 0 && arg === "up") return false;
2101
+ if (arg === "-d" || arg === "--detach") return false;
2102
+ return true;
2103
+ });
2104
+ out(`\u23F3 starting devup in detached mode for "${projectName}"...`);
2105
+ const child = spawn4(process.execPath, [...process.execArgv, process.argv[1], ...filteredArgs], {
2106
+ detached: true,
2107
+ stdio: "ignore",
2108
+ env: { ...process.env, DEVUP_DAEMON_CHILD: "1" },
2109
+ cwd: opts.baseCwd
2110
+ });
2111
+ child.unref();
2112
+ const deadline = Date.now() + 9e4;
2113
+ while (Date.now() < deadline) {
2114
+ if (existsSync10(pidPath)) {
2115
+ const pid = Number(readFileSync3(pidPath, "utf8").trim());
2116
+ out("");
2117
+ out(`\u{1F680} devup detached (PID ${pid})`);
2118
+ out(" inspect: devup ctl status");
2119
+ out(" logs: devup ctl logs <svc> --follow");
2120
+ out(" stop: devup down");
2121
+ return 0;
2122
+ }
2123
+ if (existsSync10(errPath)) {
2124
+ const msg = readFileSync3(errPath, "utf8").trim();
2125
+ out(`\u274C daemon boot failed: ${msg}`);
2126
+ try {
2127
+ unlinkSync2(errPath);
2128
+ } catch {
2129
+ }
2130
+ return 1;
2131
+ }
2132
+ await sleep(200);
2133
+ }
2134
+ out(`\u274C daemon did not become ready within 90s. Killing child (pid=${child.pid}).`);
2135
+ if (child.pid) {
2136
+ try {
2137
+ process.kill(child.pid, "SIGTERM");
2138
+ } catch {
2139
+ }
2140
+ }
2141
+ return 1;
2142
+ }
2143
+ async function stopDaemon(projectName, opts = {}) {
2144
+ const out = opts.out ?? ((l) => process.stdout.write(l + "\n"));
2145
+ const grace = opts.gracePeriodMs ?? 1e4;
2146
+ const status = isDaemonRunning(projectName);
2147
+ if (!status.pid) {
2148
+ out(`\u2139 no daemon running for "${projectName}".`);
2149
+ return 1;
2150
+ }
2151
+ if (status.stale) {
2152
+ out(`\u2139 stale pid file for "${projectName}" (pid=${status.pid} not alive). Removing.`);
2153
+ try {
2154
+ unlinkSync2(pidPathFor(projectName));
2155
+ } catch {
2156
+ }
2157
+ return 1;
2158
+ }
2159
+ out(`\u23F3 stopping daemon (pid=${status.pid})...`);
2160
+ try {
2161
+ process.kill(status.pid, "SIGTERM");
2162
+ } catch (e) {
2163
+ out(`\u274C cannot signal pid=${status.pid}: ${e.message}`);
2164
+ return 1;
2165
+ }
2166
+ const deadline = Date.now() + grace;
2167
+ while (Date.now() < deadline) {
2168
+ if (!pidAlive(status.pid)) {
2169
+ out(`\u2713 stopped daemon (pid=${status.pid})`);
2170
+ const p2 = pidPathFor(projectName);
2171
+ if (existsSync10(p2)) {
2172
+ try {
2173
+ unlinkSync2(p2);
2174
+ } catch {
2175
+ }
2176
+ }
2177
+ return 0;
2178
+ }
2179
+ await sleep(200);
2180
+ }
2181
+ out(`\u26A0 daemon did not exit within ${(grace / 1e3).toFixed(0)}s; sending SIGKILL.`);
2182
+ try {
2183
+ process.kill(status.pid, "SIGKILL");
2184
+ } catch {
2185
+ }
2186
+ const p = pidPathFor(projectName);
2187
+ if (existsSync10(p)) {
2188
+ try {
2189
+ unlinkSync2(p);
2190
+ } catch {
2191
+ }
2192
+ }
2193
+ return 0;
2194
+ }
2195
+
2196
+ // src/orchestrator/subcommands.ts
2197
+ var KNOWN = /* @__PURE__ */ new Set(["logs", "install", "status", "help", "ctl", "up", "down"]);
2198
+ function detectSubcommand(argv) {
2199
+ const first = argv[0];
2200
+ return first && KNOWN.has(first) ? first : null;
2201
+ }
2202
+ function logRoot(config, override) {
2203
+ const root = override ?? join9(homedir4(), ".devup", "logs");
2204
+ return join9(root, sanitize3(config.name));
2205
+ }
2206
+ function sanitize3(name) {
2207
+ return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
2208
+ }
2209
+ async function runLogs(argv, opts) {
2210
+ const out = opts.out ?? ((l) => console.log(l));
2211
+ const follow = argv.includes("--follow") || argv.includes("-f");
2212
+ const svcArg = argv.find((a) => !a.startsWith("-"));
2213
+ if (!svcArg) {
2214
+ out("usage: devup logs <service> [--follow]");
2215
+ return 1;
2216
+ }
2217
+ const knownSvcs = opts.config.services.map((s) => s.name);
2218
+ if (!knownSvcs.includes(svcArg)) {
2219
+ out(`Unknown service "${svcArg}". Known: ${knownSvcs.join(", ")}`);
2220
+ return 1;
2221
+ }
2222
+ const file = join9(logRoot(opts.config, opts.logDir), `${sanitize3(svcArg)}.log`);
2223
+ if (!existsSync11(file)) {
2224
+ out(`No log file yet for "${svcArg}" (${file})`);
2225
+ return follow ? await followFile(file, out) : 1;
2226
+ }
2227
+ await streamFile(file, out);
2228
+ if (!follow) return 0;
2229
+ return await followFile(file, out, statSync2(file).size);
2230
+ }
2231
+ async function streamFile(file, out) {
2232
+ return new Promise((resolve4, reject) => {
2233
+ const rl = createInterface4({ input: createReadStream2(file, { encoding: "utf8" }) });
2234
+ rl.on("line", (l) => out(l));
2235
+ rl.on("close", () => resolve4());
2236
+ rl.on("error", reject);
2237
+ });
2238
+ }
2239
+ async function followFile(file, out, startAt = 0) {
2240
+ let pos = startAt;
2241
+ while (!existsSync11(file)) await new Promise((r) => setTimeout(r, 500));
2242
+ return new Promise((resolve4) => {
2243
+ const tick = async () => {
2244
+ const size = statSync2(file).size;
2245
+ if (size > pos) {
2246
+ await new Promise((res) => {
2247
+ const rl = createInterface4({ input: createReadStream2(file, { encoding: "utf8", start: pos, end: size - 1 }) });
2248
+ rl.on("line", (l) => out(l));
2249
+ rl.on("close", () => {
2250
+ pos = size;
2251
+ res();
2252
+ });
2253
+ });
2254
+ } else if (size < pos) {
2255
+ pos = 0;
2256
+ }
2257
+ };
2258
+ watchFile2(file, { interval: 500 }, () => {
2259
+ void tick();
2260
+ });
2261
+ process.once("SIGINT", () => {
2262
+ unwatchFile2(file);
2263
+ resolve4(0);
2264
+ });
2265
+ });
2266
+ }
2267
+ async function runInstall(opts) {
2268
+ const out = opts.out ?? ((l) => console.log(l));
2269
+ const concurrency = opts.concurrency ?? 4;
2270
+ const items = opts.config.services.map((s) => ({ name: s.name, cwd: join9(opts.baseCwd, s.cwd) }));
2271
+ const queue = [...items];
2272
+ const failed = [];
2273
+ let inFlight = 0;
2274
+ await new Promise((resolve4) => {
2275
+ const pump = () => {
2276
+ while (inFlight < concurrency && queue.length) {
2277
+ const item = queue.shift();
2278
+ inFlight++;
2279
+ installOne(item.cwd, opts.env).then((ok) => {
2280
+ inFlight--;
2281
+ if (ok) out(`\u2713 ${item.name}`);
2282
+ else {
2283
+ failed.push(item.name);
2284
+ out(`\u2717 ${item.name}`);
2285
+ }
2286
+ if (queue.length === 0 && inFlight === 0) resolve4();
2287
+ else pump();
2288
+ });
2289
+ }
2290
+ };
2291
+ pump();
2292
+ });
2293
+ if (failed.length) {
2294
+ out(`
2295
+ failed: ${failed.join(", ")}`);
2296
+ return 1;
2297
+ }
2298
+ out(`
2299
+ ${items.length} services up to date`);
2300
+ return 0;
2301
+ }
2302
+ function installOne(cwd, env) {
2303
+ if (!existsSync11(cwd)) return Promise.resolve(false);
2304
+ if (!needsInstall(cwd)) return Promise.resolve(true);
2305
+ return new Promise((resolve4) => {
2306
+ const command = process.platform === "win32" ? "npm.cmd" : "npm";
2307
+ const proc = spawn5(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
2308
+ proc.on("close", (code) => {
2309
+ if (code === 0) {
2310
+ writeInstallStamp(cwd);
2311
+ resolve4(true);
2312
+ } else resolve4(false);
2313
+ });
2314
+ proc.on("error", () => resolve4(false));
2315
+ });
2316
+ }
2317
+ async function runStatus(opts) {
2318
+ const out = opts.out ?? ((l) => console.log(l));
2319
+ out(`${opts.config.icon ?? "\u{1F4E6}"} ${opts.config.name} \u2014 ${opts.config.services.length} services`);
2320
+ out("");
2321
+ const maxLen = Math.max(...opts.config.services.map((s) => s.name.length), 12);
2322
+ out(`${"Service".padEnd(maxLen)} ${"Port".padStart(5)} ${"Type".padEnd(4)} Health`);
2323
+ out("-".repeat(maxLen + 24));
2324
+ for (const svc of opts.config.services) {
2325
+ const up = await checkHealth(svc.port, svc.healthCheck);
2326
+ const health = up ? "\u2713 up" : "\u2717 down";
2327
+ out(`${svc.name.padEnd(maxLen)} ${String(svc.port).padStart(5)} ${svc.type.padEnd(4)} ${health}`);
2328
+ }
2329
+ return 0;
2330
+ }
2331
+ function fmtStatus(rows, out) {
2332
+ const maxLen = Math.max(...rows.map((r) => r.name.length), 8);
2333
+ for (const r of rows) {
2334
+ const pid = r.pid != null ? `pid=${r.pid}` : " ";
2335
+ const name = r.name.padEnd(maxLen);
2336
+ const port = `:${r.port}`.padStart(6);
2337
+ const status = r.status.padEnd(8);
2338
+ const health = r.health.padEnd(4);
2339
+ out(`${name} ${port} ${status} ${health} ${pid} errors=${r.errors} restarts=${r.restarts}`);
2340
+ }
2341
+ }
2342
+ async function runCtl(argv, opts) {
2343
+ const out = opts.out ?? ((l) => process.stdout.write(l + "\n"));
2344
+ const method = argv[0];
2345
+ const follow = argv.includes("--follow") || argv.includes("-f");
2346
+ const socketPath = resolveSocket(opts.config.name, opts.socketPath);
2347
+ if (!method || method === "help") {
2348
+ out("Usage: devup ctl <method> [args] [--follow]");
2349
+ out(" ping Check if devup is running");
2350
+ out(" status [--follow] Service snapshot, or live updates");
2351
+ out(" logs <svc> [--follow] Tail logs (last 100), or follow live stream");
2352
+ out(" restart <svc> Restart a service");
2353
+ out(" stop <svc> Stop a service");
2354
+ return 0;
2355
+ }
2356
+ try {
2357
+ assertSocketExists(socketPath, opts.config.name);
2358
+ } catch (e) {
2359
+ out(e.message);
2360
+ return 1;
2361
+ }
2362
+ try {
2363
+ if (method === "ping") {
2364
+ const res = await sendRpc(socketPath, "ping");
2365
+ out(`pong ts=${res.ts}`);
2366
+ return 0;
2367
+ }
2368
+ if (method === "status" && !follow) {
2369
+ const res = await sendRpc(socketPath, "status");
2370
+ if (!res.services.length) {
2371
+ out("(no services)");
2372
+ return 0;
2373
+ }
2374
+ fmtStatus(res.services, out);
2375
+ return 0;
2376
+ }
2377
+ if (method === "status" && follow) {
2378
+ return await new Promise((resolve4) => {
2379
+ const abort = openStream(socketPath, "status.follow", {}, (frame) => {
2380
+ const rows = frame.data;
2381
+ const ts = (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
2382
+ for (const r of rows) {
2383
+ out(`[${ts}] ${r.name.padEnd(24)} ${r.status}/${r.health}`);
2384
+ }
2385
+ }, (err) => {
2386
+ out(`error: ${err.message}`);
2387
+ resolve4(1);
2388
+ });
2389
+ process.once("SIGINT", () => {
2390
+ abort();
2391
+ resolve4(0);
2392
+ });
2393
+ });
2394
+ }
2395
+ if (method === "logs") {
2396
+ const svc = argv.find((a, i) => i > 0 && !a.startsWith("-"));
2397
+ if (!svc) {
2398
+ out("usage: devup ctl logs <service> [--follow]");
2399
+ return 1;
2400
+ }
2401
+ if (!follow) {
2402
+ const res = await sendRpc(socketPath, "logs.tail", { svc, lines: 100 });
2403
+ for (const l of res.lines) out(l);
2404
+ return 0;
2405
+ }
2406
+ return await new Promise((resolve4) => {
2407
+ const abort = openStream(socketPath, "logs.follow", { svc, tail: 100 }, (frame) => {
2408
+ out(frame.data);
2409
+ }, (err) => {
2410
+ out(`error: ${err.message}`);
2411
+ resolve4(1);
2412
+ });
2413
+ process.once("SIGINT", () => {
2414
+ abort();
2415
+ resolve4(0);
2416
+ });
2417
+ });
2418
+ }
2419
+ if (method === "restart") {
2420
+ const svc = argv[1];
2421
+ if (!svc) {
2422
+ out("usage: devup ctl restart <service>");
2423
+ return 1;
2424
+ }
2425
+ await sendRpc(socketPath, "restart", { svc });
2426
+ out(`\u2713 restart sent to ${svc}`);
2427
+ return 0;
2428
+ }
2429
+ if (method === "stop") {
2430
+ const svc = argv[1];
2431
+ if (!svc) {
2432
+ out("usage: devup ctl stop <service>");
2433
+ return 1;
2434
+ }
2435
+ await sendRpc(socketPath, "stop", { svc });
2436
+ out(`\u2713 stop sent to ${svc}`);
2437
+ return 0;
2438
+ }
2439
+ out(`unknown ctl method: ${method}. Run \`devup ctl help\` for usage.`);
2440
+ return 1;
2441
+ } catch (e) {
2442
+ out(`error: ${e.message}`);
2443
+ return 1;
2444
+ }
2445
+ }
2446
+ async function runDown(opts) {
2447
+ const out = opts.out ?? ((l) => process.stdout.write(l + "\n"));
2448
+ return stopDaemon(opts.config.name, { out });
2449
+ }
2450
+ function runHelp(argv, opts = {}) {
2451
+ const out = opts.out ?? ((l) => console.log(l));
2452
+ const sub = argv[0];
2453
+ if (sub === "logs") {
2454
+ out("Usage: devup logs <service> [--follow|-f]");
2455
+ out(" Print the persisted log file for a service (works without devup running).");
2456
+ out(" --follow tails new lines as they are appended.");
2457
+ return 0;
2458
+ }
2459
+ if (sub === "install") {
2460
+ out("Usage: devup install");
2461
+ out(" Run `npm install` across every service.cwd in parallel (max 4 at a time).");
2462
+ out(" Skips services whose .install-stamp matches package.json hash.");
2463
+ return 0;
2464
+ }
2465
+ if (sub === "status") {
2466
+ out("Usage: devup status");
2467
+ out(" For each service, probes its health-check endpoint and prints up/down.");
2468
+ return 0;
2469
+ }
2470
+ if (sub === "ctl") {
2471
+ out("Usage: devup ctl <method> [args] [--follow]");
2472
+ out(" Send commands to a running devup process via the control plane socket.");
2473
+ out("");
2474
+ out(" ping Check if devup is running");
2475
+ out(" status [--follow] Service snapshot, or live state-change stream");
2476
+ out(" logs <svc> [--follow] Tail last 100 lines, or follow the live stream");
2477
+ out(" restart <svc> Restart the named service");
2478
+ out(" stop <svc> Stop the named service");
2479
+ out("");
2480
+ out(" devup must be running in the same project directory.");
2481
+ return 0;
2482
+ }
2483
+ if (sub === "up") {
2484
+ out("Usage: devup up -d");
2485
+ out(" Boot the stack in detached/daemon mode (like `docker compose up -d`).");
2486
+ out(" Returns immediately once the stack is healthy; services keep running.");
2487
+ out(" Use `devup ctl status`, `devup ctl logs`, or `devup down` to interact.");
2488
+ out(" Not supported on Windows yet \u2014 use `devup` (TUI) instead.");
2489
+ return 0;
2490
+ }
2491
+ if (sub === "down") {
2492
+ out("Usage: devup down");
2493
+ out(" Stop the daemon for the current project. SIGTERM with 10s grace,");
2494
+ out(" then SIGKILL. Removes the PID file and the control-plane socket.");
2495
+ return 0;
2496
+ }
2497
+ out("Subcommands:");
2498
+ out(" devup logs <service> [--follow] Read the persisted log file");
2499
+ out(" devup install Concurrent npm install across services");
2500
+ out(" devup status Health check every service in config");
2501
+ out(" devup up -d Boot the stack in detached/daemon mode");
2502
+ out(" devup down Stop the running daemon");
2503
+ out(" devup ctl <method> [args] Control a running devup (restart/stop/logs/...)");
2504
+ out(" devup help [<subcommand>] Show detailed help for a subcommand");
2505
+ out("");
2506
+ out("No subcommand \u2192 launch the interactive TUI.");
2507
+ return 0;
2508
+ }
2509
+
2510
+ // src/platform/detect.ts
2511
+ async function detectPlatform() {
2512
+ switch (process.platform) {
2513
+ case "linux": {
2514
+ const { LinuxPlatform } = await import("./linux-OQ3Q4Z2Z.js");
2515
+ return new LinuxPlatform();
2516
+ }
2517
+ case "darwin": {
2518
+ const { DarwinPlatform } = await import("./darwin-3KJ3IEXN.js");
2519
+ return new DarwinPlatform();
2520
+ }
2521
+ case "win32": {
2522
+ const { Win32Platform } = await import("./win32-3X2OLSI6.js");
2523
+ return new Win32Platform();
2524
+ }
2525
+ default:
2526
+ throw new Error(`Unsupported platform: ${process.platform}`);
2527
+ }
2528
+ }
2529
+
2530
+ // src/proxy-config/traefik.ts
2531
+ import { existsSync as existsSync12, mkdirSync as mkdirSync4, writeFileSync as writeFileSync3 } from "fs";
2532
+ import { dirname as dirname4 } from "path";
2533
+ var EMPTY_CONFIG = "http:\n routers: {}\n services: {}\n";
2534
+ var TraefikProvider = class {
2535
+ name = "traefik";
2536
+ generate(services, opts) {
2537
+ const routers = [];
2538
+ const svcs = [];
2539
+ for (const [name, st] of services) {
2540
+ if (st.health !== "up") continue;
2541
+ const sub = opts.routes[name];
2542
+ if (sub === void 0) continue;
2543
+ const rule = sub ? `Host(\`${sub}.${opts.domain}\`)` : `Host(\`${opts.domain}\`)`;
2544
+ const safe = name.replace(/[^a-z0-9-]/g, "-");
2545
+ const port = st.realPort ?? st.port;
2546
+ let router = ` ${safe}:
2547
+ rule: "${rule}"
2548
+ service: ${safe}
2549
+ entryPoints:
2550
+ - ${opts.entrypoint}`;
2551
+ if (opts.tls) router += `
2552
+ tls:
2553
+ certResolver: le`;
2554
+ routers.push(router);
2555
+ svcs.push(` ${safe}:
2556
+ loadBalancer:
2557
+ servers:
2558
+ - url: "http://${opts.host}:${port}"`);
2559
+ }
2560
+ if (!routers.length) return EMPTY_CONFIG;
2561
+ return `http:
2562
+ routers:
2563
+ ${routers.join("\n")}
2564
+ services:
2565
+ ${svcs.join("\n")}
2566
+ `;
2567
+ }
2568
+ write(content, opts) {
2569
+ const dir = dirname4(opts.confPath);
2570
+ if (!existsSync12(dir)) mkdirSync4(dir, { recursive: true });
2571
+ writeFileSync3(opts.confPath, content);
2572
+ }
2573
+ clear(opts) {
2574
+ this.write(EMPTY_CONFIG, opts);
2575
+ }
2576
+ };
2577
+
2578
+ // src/proxy-config/nginx.ts
2579
+ import { existsSync as existsSync13, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "fs";
2580
+ import { dirname as dirname5 } from "path";
2581
+ var EMPTY_CONFIG2 = "# devup: no healthy services\n";
2582
+ var NginxProvider = class {
2583
+ name = "nginx";
2584
+ generate(services, opts) {
2585
+ const blocks = [];
2586
+ for (const [name, st] of services) {
2587
+ if (st.health !== "up") continue;
2588
+ const sub = opts.routes[name];
2589
+ if (sub === void 0) continue;
2590
+ const serverName = sub ? `${sub}.${opts.domain}` : opts.domain;
2591
+ const port = st.realPort ?? st.port;
2592
+ const listen = opts.tls ? "443 ssl" : "80";
2593
+ const tlsBlock = opts.tls ? ` ssl_certificate /etc/nginx/certs/${serverName}.crt;
2594
+ ssl_certificate_key /etc/nginx/certs/${serverName}.key;
2595
+ ` : "";
2596
+ blocks.push(
2597
+ `server {
2598
+ listen ${listen};
2599
+ server_name ${serverName};
2600
+ ` + tlsBlock + ` location / {
2601
+ proxy_pass http://${opts.host}:${port};
2602
+ proxy_set_header Host $host;
2603
+ proxy_set_header X-Real-IP $remote_addr;
2604
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
2605
+ proxy_set_header X-Forwarded-Proto $scheme;
2606
+ proxy_http_version 1.1;
2607
+ proxy_set_header Upgrade $http_upgrade;
2608
+ proxy_set_header Connection "upgrade";
2609
+ }
2610
+ }`
2611
+ );
2612
+ }
2613
+ if (!blocks.length) return EMPTY_CONFIG2;
2614
+ return blocks.join("\n\n") + "\n";
2615
+ }
2616
+ write(content, opts) {
2617
+ const dir = dirname5(opts.confPath);
2618
+ if (!existsSync13(dir)) mkdirSync5(dir, { recursive: true });
2619
+ writeFileSync4(opts.confPath, content);
2620
+ }
2621
+ clear(opts) {
2622
+ this.write(EMPTY_CONFIG2, opts);
2623
+ }
2624
+ };
2625
+
2626
+ // src/proxy-config/caddy.ts
2627
+ import { existsSync as existsSync14, mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
2628
+ import { dirname as dirname6 } from "path";
2629
+ var EMPTY_CONFIG3 = "# devup: no healthy services\n";
2630
+ var CaddyProvider = class {
2631
+ name = "caddy";
2632
+ generate(services, opts) {
2633
+ const blocks = [];
2634
+ for (const [name, st] of services) {
2635
+ if (st.health !== "up") continue;
2636
+ const sub = opts.routes[name];
2637
+ if (sub === void 0) continue;
2638
+ const host = sub ? `${sub}.${opts.domain}` : opts.domain;
2639
+ const port = st.realPort ?? st.port;
2640
+ const siteAddr = opts.tls ? host : `http://${host}`;
2641
+ blocks.push(
2642
+ `${siteAddr} {
2643
+ reverse_proxy ${opts.host}:${port}
2644
+ }`
2645
+ );
2646
+ }
2647
+ if (!blocks.length) return EMPTY_CONFIG3;
2648
+ return blocks.join("\n\n") + "\n";
2649
+ }
2650
+ write(content, opts) {
2651
+ const dir = dirname6(opts.confPath);
2652
+ if (!existsSync14(dir)) mkdirSync6(dir, { recursive: true });
2653
+ writeFileSync5(opts.confPath, content);
2654
+ }
2655
+ clear(opts) {
2656
+ this.write(EMPTY_CONFIG3, opts);
2657
+ }
2658
+ };
2659
+
2660
+ // src/proxy-config/detect.ts
2661
+ var providers = {
2662
+ traefik: () => new TraefikProvider(),
2663
+ nginx: () => new NginxProvider(),
2664
+ caddy: () => new CaddyProvider()
2665
+ };
2666
+ function detectProxyProvider(name) {
2667
+ const factory = providers[name];
2668
+ if (!factory) {
2669
+ const available = Object.keys(providers).join(", ");
2670
+ throw new Error(`Unknown proxy provider: "${name}". Available: ${available}`);
2671
+ }
2672
+ return factory();
2673
+ }
2674
+
2675
+ // src/tui/App.tsx
2676
+ import { useCallback as useCallback3, useRef as useRef5 } from "react";
2677
+ import { Box as Box6, Text as Text6 } from "ink";
2678
+
2679
+ // src/tui/hooks/useProcessManager.ts
2680
+ import { useState, useEffect, useRef, useCallback } from "react";
2681
+ function useProcessManager(platform, baseCwd, env, logSink = null) {
2682
+ const [states, setStates] = useState(/* @__PURE__ */ new Map());
2683
+ const [logs, setLogs] = useState([]);
2684
+ const [stats, setStats] = useState(/* @__PURE__ */ new Map());
2685
+ const mgrRef = useRef(null);
2686
+ const prevCpu = useRef(/* @__PURE__ */ new Map());
2687
+ const pausedRef = useRef(false);
2688
+ const pendingLogsRef = useRef([]);
2689
+ const sinkRef = useRef(logSink);
2690
+ sinkRef.current = logSink;
2691
+ const logBus = useRef(new Broadcaster());
2692
+ const stateBus = useRef(new Broadcaster());
2693
+ useEffect(() => {
2694
+ const mgr2 = new ProcessManager({
2695
+ baseCwd,
2696
+ env,
2697
+ platform,
2698
+ events: {
2699
+ onLog: (svcName, text, colorIdx) => {
2700
+ sinkRef.current?.write(svcName, text);
2701
+ logBus.current.emit({ svc: svcName, text });
2702
+ const entry = { svcName, text, colorIdx, ts: Date.now(), level: detectLogLevel(text) };
2703
+ if (pausedRef.current) {
2704
+ pendingLogsRef.current.push(entry);
2705
+ if (pendingLogsRef.current.length > 5e3) {
2706
+ pendingLogsRef.current = pendingLogsRef.current.slice(-5e3);
2707
+ }
2708
+ return;
2709
+ }
2710
+ setLogs((prev) => {
2711
+ const next = prev.concat(entry);
2712
+ return next.length > 5e3 ? next.slice(-5e3) : next;
2713
+ });
2714
+ },
2715
+ onStateChange: (name, state) => {
2716
+ stateBus.current.emit({ name, state });
2717
+ setStates(new Map(mgr2.state));
2718
+ }
2719
+ }
2720
+ });
2721
+ mgrRef.current = mgr2;
2722
+ return () => {
2723
+ mgr2.cleanup();
2724
+ };
2725
+ }, [baseCwd, env, platform]);
2726
+ useEffect(() => {
2727
+ const id = setInterval(async () => {
2728
+ const mgr2 = mgrRef.current;
2729
+ if (!mgr2) return;
2730
+ await mgr2.checkAllHealth();
2731
+ setStates(new Map(mgr2.state));
2732
+ const pids = [];
2733
+ const pidMap = /* @__PURE__ */ new Map();
2734
+ for (const [name, st] of mgr2.state) {
2735
+ if (st.pid) {
2736
+ pids.push(st.pid);
2737
+ pidMap.set(st.pid, name);
2738
+ }
2739
+ }
2740
+ if (pids.length) {
2741
+ const raw = await platform.getProcessStats(pids);
2742
+ const next = /* @__PURE__ */ new Map();
2743
+ for (const [pid, data] of raw) {
2744
+ const name = pidMap.get(pid);
2745
+ if (!name) continue;
2746
+ const prev = prevCpu.current.get(name) ?? { time: Date.now(), cpu: 0 };
2747
+ const cpuPct = calcCpuPercent(data.cpuSeconds, prev.cpu, prev.time);
2748
+ prevCpu.current.set(name, { time: Date.now(), cpu: data.cpuSeconds });
2749
+ next.set(name, { cpu: cpuPct.toFixed(1) + "%", mem: (data.rss / 1024).toFixed(1) + " MB" });
2750
+ }
2751
+ setStats(next);
2752
+ }
2753
+ }, 3e3);
2754
+ return () => clearInterval(id);
2755
+ }, [platform]);
2756
+ const mgr = mgrRef.current;
2757
+ const clearLogs = useCallback(() => {
2758
+ pendingLogsRef.current = [];
2759
+ setLogs([]);
2760
+ }, []);
2761
+ const pushLog = useCallback((svcName, text, colorIdx = 0) => {
2762
+ sinkRef.current?.write(svcName, text);
2763
+ logBus.current.emit({ svc: svcName, text });
2764
+ const entry = { svcName, text, colorIdx, ts: Date.now(), level: detectLogLevel(text) };
2765
+ if (pausedRef.current) {
2766
+ pendingLogsRef.current.push(entry);
2767
+ if (pendingLogsRef.current.length > 5e3) {
2768
+ pendingLogsRef.current = pendingLogsRef.current.slice(-5e3);
2769
+ }
2770
+ return;
2771
+ }
2772
+ setLogs((prev) => {
2773
+ const next = prev.concat(entry);
2774
+ return next.length > 5e3 ? next.slice(-5e3) : next;
2775
+ });
2776
+ }, []);
2777
+ const setPaused = useCallback((paused) => {
2778
+ pausedRef.current = paused;
2779
+ if (!paused && pendingLogsRef.current.length) {
2780
+ const flush = pendingLogsRef.current;
2781
+ pendingLogsRef.current = [];
2782
+ setLogs((prev) => {
2783
+ const next = prev.concat(flush);
2784
+ return next.length > 5e3 ? next.slice(-5e3) : next;
2785
+ });
2786
+ }
2787
+ }, []);
2788
+ return {
2789
+ states,
2790
+ logs,
2791
+ stats,
2792
+ start: useCallback((svc, colorIdx) => mgr?.start(svc, colorIdx), [mgr]),
2793
+ stop: useCallback((name) => mgr?.stop(name), [mgr]),
2794
+ restart: useCallback((name) => mgr?.restart(name), [mgr]),
2795
+ install: useCallback((svc, colorIdx) => mgr?.install(svc, colorIdx), [mgr]),
2796
+ cleanup: useCallback(() => mgr?.cleanup(), [mgr]),
2797
+ clearLogs,
2798
+ setPaused,
2799
+ pushLog,
2800
+ manager: mgr,
2801
+ logBus: logBus.current,
2802
+ stateBus: stateBus.current
2803
+ };
2804
+ }
2805
+
2806
+ // src/tui/hooks/useKeyBindings.ts
2807
+ import { useInput } from "ink";
2808
+ import { useState as useState2, useCallback as useCallback2 } from "react";
2809
+ var SORT_MODES = ["name", "mem", "errors"];
2810
+ function scrollBy(setState, delta) {
2811
+ setState((s) => {
2812
+ if (s.panel === "logs") {
2813
+ const next2 = s.logsScrollOffset - delta;
2814
+ return { ...s, logsScrollOffset: Math.max(0, next2) };
2815
+ }
2816
+ const next = s.statsScrollOffset + delta;
2817
+ return { ...s, statsScrollOffset: Math.max(0, next) };
2818
+ });
2819
+ }
2820
+ function scrollTo(setState, target) {
2821
+ setState((s) => {
2822
+ if (s.panel === "logs") {
2823
+ return { ...s, logsScrollOffset: target === "top" ? Number.MAX_SAFE_INTEGER : 0 };
2824
+ }
2825
+ return { ...s, statsScrollOffset: target === "top" ? 0 : Number.MAX_SAFE_INTEGER };
2826
+ });
2827
+ }
2828
+ function useKeyBindings(opts) {
2829
+ const [state, setState] = useState2({
2830
+ panel: "logs",
2831
+ modal: "none",
2832
+ logFilter: null,
2833
+ searchTerm: null,
2834
+ logsPaused: false,
2835
+ showTimestamps: false,
2836
+ sortIdx: 0,
2837
+ proxyEnabled: false,
2838
+ logsScrollOffset: 0,
2839
+ statsScrollOffset: 0,
2840
+ levelFilter: "all",
2841
+ verboseStats: false
2842
+ });
2843
+ const LEVEL_CYCLE = ["all", "error", "warn"];
2844
+ const setModal = useCallback2((modal) => setState((s) => ({ ...s, modal })), []);
2845
+ const setFilter = useCallback2((f) => setState((s) => ({ ...s, logFilter: f, modal: "none" })), []);
2846
+ const setSearch = useCallback2((t) => setState((s) => ({ ...s, searchTerm: t, modal: "none" })), []);
2847
+ const isActive = process.stdin.isTTY ?? false;
2848
+ useInput((input, key) => {
2849
+ if (state.modal !== "none") return;
2850
+ if (input === "q" || key.ctrl && input === "c") opts.onQuit();
2851
+ else if (key.ctrl && input === "a") scrollTo(setState, "top");
2852
+ else if (key.ctrl && input === "e") scrollTo(setState, "bottom");
2853
+ else if (key.ctrl && input === "b") scrollBy(setState, -10);
2854
+ else if (key.ctrl && input === "f") scrollBy(setState, 10);
2855
+ else if (key.upArrow) scrollBy(setState, -1);
2856
+ else if (key.downArrow) scrollBy(setState, 1);
2857
+ else if (input === "[") scrollBy(setState, -10);
2858
+ else if (input === "]") scrollBy(setState, 10);
2859
+ else if (input === "c") opts.onClearLogs();
2860
+ else if (key.tab) setState((s) => ({ ...s, panel: s.panel === "logs" ? "stats" : "logs" }));
2861
+ else if (input === "f") setModal("filter");
2862
+ else if (input === "r") setModal("restart");
2863
+ else if (input === "o") setModal("open");
2864
+ else if (input === "/") setModal("search");
2865
+ else if (input === "a") setState((s) => ({ ...s, logFilter: null, searchTerm: null, levelFilter: "all" }));
2866
+ else if (input === "p") setState((s) => ({ ...s, logsPaused: !s.logsPaused }));
2867
+ else if (input === "t") setState((s) => ({ ...s, showTimestamps: !s.showTimestamps }));
2868
+ else if (input === "s") setState((s) => ({ ...s, sortIdx: (s.sortIdx + 1) % SORT_MODES.length }));
2869
+ else if (input === "T") {
2870
+ opts.onToggleProxy();
2871
+ setState((s) => ({ ...s, proxyEnabled: !s.proxyEnabled }));
2872
+ } else if (input === "L") setState((s) => ({ ...s, levelFilter: LEVEL_CYCLE[(LEVEL_CYCLE.indexOf(s.levelFilter) + 1) % LEVEL_CYCLE.length] }));
2873
+ else if (input === "v") setState((s) => ({ ...s, verboseStats: !s.verboseStats }));
2874
+ }, { isActive });
2875
+ return {
2876
+ ...state,
2877
+ setModal,
2878
+ setFilter,
2879
+ setSearch,
2880
+ sortMode: SORT_MODES[state.sortIdx],
2881
+ // Funciones para resetear el scroll cuando cambia el contenido
2882
+ resetLogsScroll: useCallback2(() => setState((s) => ({ ...s, logsScrollOffset: 0 })), []),
2883
+ resetStatsScroll: useCallback2(() => setState((s) => ({ ...s, statsScrollOffset: 0 })), [])
2884
+ };
2885
+ }
2886
+
2887
+ // src/tui/hooks/useProxySync.ts
2888
+ import { useEffect as useEffect2, useRef as useRef2 } from "react";
2889
+ function useProxySync(provider, opts, states, enabled) {
2890
+ const statesRef = useRef2(states);
2891
+ const lastContentRef = useRef2(null);
2892
+ statesRef.current = states;
2893
+ useEffect2(() => {
2894
+ if (!provider || !opts || !enabled) return;
2895
+ const sync = () => {
2896
+ const svcStates = /* @__PURE__ */ new Map();
2897
+ for (const [name, st] of statesRef.current) {
2898
+ svcStates.set(name, { port: st.svc.port, health: st.health, realPort: st.svc.realPort });
2899
+ }
2900
+ const content = provider.generate(svcStates, opts);
2901
+ if (content === lastContentRef.current) return;
2902
+ lastContentRef.current = content;
2903
+ provider.write(content, opts);
2904
+ };
2905
+ sync();
2906
+ const id = setInterval(sync, 3e3);
2907
+ return () => {
2908
+ clearInterval(id);
2909
+ lastContentRef.current = null;
2910
+ };
2911
+ }, [provider, opts, enabled]);
1716
2912
  }
1717
- function ColHeader({ ml }) {
1718
- return /* @__PURE__ */ jsxs2(Text2, { bold: true, children: [
1719
- "H ",
1720
- "Service".padEnd(ml),
1721
- " ",
1722
- "Port".padStart(5),
1723
- " ",
1724
- "Status".padEnd(8),
1725
- " ",
1726
- "CPU".padStart(6),
1727
- " ",
1728
- "Mem".padStart(8),
1729
- " Err Rst ",
1730
- "Up".padStart(6)
1731
- ] });
2913
+
2914
+ // src/tui/hooks/useTerminalSize.ts
2915
+ import { useEffect as useEffect3, useState as useState3 } from "react";
2916
+ import { useStdout } from "ink";
2917
+ function useTerminalSize() {
2918
+ const { stdout } = useStdout();
2919
+ const [rows, setRows] = useState3(stdout?.rows ?? 40);
2920
+ useEffect3(() => {
2921
+ if (!stdout) return;
2922
+ const onResize = () => setRows(stdout.rows ?? 40);
2923
+ stdout.on("resize", onResize);
2924
+ return () => {
2925
+ stdout.off("resize", onResize);
2926
+ };
2927
+ }, [stdout]);
2928
+ return rows;
1732
2929
  }
1733
- function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scrollOffset, resetScroll, verbose = false }) {
1734
- const names = [...states.keys()];
1735
- const stObj = Object.fromEntries([...states].map(([k, v]) => [k, { errors: v.errors }]));
1736
- const statsObj = Object.fromEntries([...stats].map(([k, v]) => [k, v]));
1737
- const apis = sortServiceNames(names.filter((n) => states.get(n).svc.type === "api"), sortMode, statsObj, stObj);
1738
- const webs = sortServiceNames(names.filter((n) => states.get(n).svc.type === "web"), sortMode, statsObj, stObj);
1739
- const cpus = os.cpus().length;
1740
- const totalGB = (os.totalmem() / 1024 / 1024 / 1024).toFixed(1);
1741
- const usedGB = (parseFloat(totalGB) - os.freemem() / 1024 / 1024 / 1024).toFixed(1);
1742
- const load = os.loadavg()[0].toFixed(2);
1743
- let totalCpu = 0, totalMemMB = 0, totalErrors = 0, totalRestarts = 0;
1744
- for (const name of names) {
1745
- const s = stats.get(name);
1746
- if (s) {
1747
- const c = parseFloat(s.cpu);
1748
- if (!isNaN(c)) totalCpu += c;
1749
- const m = parseFloat(s.mem);
1750
- if (!isNaN(m)) totalMemMB += m;
1751
- }
1752
- totalErrors += states.get(name)?.errors ?? 0;
1753
- totalRestarts += states.get(name)?.restarts ?? 0;
1754
- }
1755
- const stackMem = totalMemMB >= 1024 ? (totalMemMB / 1024).toFixed(2) + " GB" : totalMemMB.toFixed(1) + " MB";
1756
- const ml = maxNameLen;
1757
- const contentHeight = Math.max(1, height - 2);
1758
- const rowsPerCol = Math.max(1, contentHeight - 2);
1759
- const maxRows = Math.max(0, Math.max(apis.length, webs.length) - rowsPerCol);
1760
- const effectiveOffset = scrollOffset === Number.MAX_SAFE_INTEGER ? maxRows : Math.min(scrollOffset, maxRows);
1761
- const apiStartIndex = Math.min(effectiveOffset, Math.max(0, apis.length - rowsPerCol));
1762
- const webStartIndex = Math.min(effectiveOffset, Math.max(0, webs.length - rowsPerCol));
1763
- const visibleApis = apis.slice(apiStartIndex, apiStartIndex + rowsPerCol);
1764
- const visibleWebs = webs.slice(webStartIndex, webStartIndex + rowsPerCol);
1765
- useEffect4(() => {
1766
- resetScroll();
1767
- }, [sortMode, resetScroll]);
1768
- const totalRowsLong = Math.max(apis.length, webs.length);
1769
- const positionInfo = focused && totalRowsLong > 0 ? `(${effectiveOffset + 1}-${Math.min(effectiveOffset + rowsPerCol, totalRowsLong)}/${totalRowsLong})` : "";
1770
- const scrolled = effectiveOffset > 0;
1771
- const loopedCount = [...states.values()].filter(isCrashLooped).length;
1772
- const ramPct = parseFloat(usedGB) / parseFloat(totalGB) * 100;
1773
- const [ramBanner, setRamBanner] = useState3(false);
2930
+
2931
+ // src/tui/hooks/useLogsPause.ts
2932
+ import { useEffect as useEffect4 } from "react";
2933
+ function useLogsPause(setPaused, logsPaused, logsScrollOffset) {
1774
2934
  useEffect4(() => {
1775
- setRamBanner((prev) => nextRamBannerVisibility(ramPct, prev));
1776
- }, [ramPct]);
1777
- 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: [
1781
- " Stats ",
1782
- positionInfo
1783
- ] }),
1784
- scrolled && /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: " [SCROLL]" }),
1785
- loopedCount > 0 && /* @__PURE__ */ jsxs2(Text2, { color: "red", bold: true, children: [
1786
- " \u26A0 ",
1787
- loopedCount,
1788
- " need attention"
1789
- ] }),
1790
- /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1791
- " System: ",
1792
- cpus,
1793
- "c Load ",
1794
- load,
1795
- " RAM ",
1796
- usedGB,
1797
- "/",
1798
- totalGB,
1799
- "GB"
1800
- ] }),
1801
- /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: " \u2502 " }),
1802
- /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1803
- "Stack: CPU ",
1804
- totalCpu.toFixed(1),
1805
- "% RAM ",
1806
- stackMem,
1807
- " Err ",
1808
- totalErrors,
1809
- " Rst ",
1810
- totalRestarts,
1811
- " Svcs ",
1812
- names.length
1813
- ] }),
1814
- sortMode !== "name" && /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1815
- " \u2502 Sort: ",
1816
- sortMode
1817
- ] })
1818
- ] }),
1819
- ramBanner && /* @__PURE__ */ jsxs2(Box2, { children: [
1820
- /* @__PURE__ */ jsxs2(Text2, { color: "yellow", bold: true, children: [
1821
- " \u26A0 RAM ",
1822
- ramPct.toFixed(0),
1823
- "% \u2014 top: "
1824
- ] }),
1825
- /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: topConsumers.map((c) => `${c.name} ${c.mb.toFixed(0)}MB`).join(", ") })
1826
- ] }),
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: [
1830
- " APIs (",
1831
- apis.length,
1832
- ")"
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
- ] });
2935
+ setPaused(logsPaused || logsScrollOffset > 0);
2936
+ }, [logsPaused, logsScrollOffset, setPaused]);
1849
2937
  }
1850
2938
 
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
- ] }) });
2939
+ // src/tui/hooks/useControlPlane.ts
2940
+ import { useEffect as useEffect5, useRef as useRef3 } from "react";
2941
+ import { createInterface as createInterface5 } from "readline";
2942
+ import { createReadStream as createReadStream3, existsSync as existsSync15 } from "fs";
2943
+ function useControlPlane(manager, projectName, logSink, pushLog, logBus, stateBus) {
2944
+ const handleRef = useRef3(null);
2945
+ useEffect5(() => {
2946
+ if (!manager) return;
2947
+ let handle = null;
2948
+ (async () => {
2949
+ try {
2950
+ handle = await startSocketServer(projectName, {
2951
+ states: () => manager.state,
2952
+ restart: (name) => manager.restart(name),
2953
+ stop: (name) => manager.stop(name),
2954
+ tailLogs: async (svcName, lines) => {
2955
+ if (!logSink) return [];
2956
+ const file = logSink.pathFor(svcName);
2957
+ if (!existsSync15(file)) return [];
2958
+ return new Promise((resolve4, reject) => {
2959
+ const buf = [];
2960
+ const rl = createInterface5({ input: createReadStream3(file, { encoding: "utf8" }) });
2961
+ rl.on("line", (l) => {
2962
+ buf.push(l);
2963
+ if (buf.length > lines) buf.shift();
2964
+ });
2965
+ rl.on("close", () => resolve4(buf));
2966
+ rl.on("error", reject);
2967
+ });
2968
+ },
2969
+ watchLogs: (svcName, onLine) => {
2970
+ return logBus.subscribe(({ svc, text }) => {
2971
+ if (svcName === null || svc === svcName) onLine(svc, text);
2972
+ });
2973
+ },
2974
+ watchStatus: (onUpdate) => {
2975
+ return stateBus.subscribe(({ name, state }) => onUpdate(name, state));
2976
+ }
2977
+ }, { onLog: (msg) => pushLog("devup", msg, 12) });
2978
+ handleRef.current = handle;
2979
+ } catch (e) {
2980
+ pushLog("devup", `\u26A0 control plane disabled: ${e.message}`, 5);
2981
+ }
2982
+ })();
2983
+ return () => {
2984
+ void handle?.close();
2985
+ handleRef.current = null;
2986
+ };
2987
+ }, [manager, projectName, logSink, pushLog, logBus, stateBus]);
2988
+ return handleRef;
1891
2989
  }
1892
2990
 
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);
2991
+ // src/tui/hooks/useHotReload.ts
2992
+ import { useEffect as useEffect6 } from "react";
2993
+ function useHotReload(manager, cliArgs, baseCwd, pushLog) {
2994
+ useEffect6(() => {
2995
+ if (!cliArgs.watchConfig || !manager) return;
2996
+ let configPath;
2997
+ try {
2998
+ configPath = findConfigFile(baseCwd, cliArgs.configPath);
2999
+ } catch (e) {
3000
+ pushLog("devup", `\u26A0 watch-config disabled: ${e.message}`, 5);
1931
3001
  return;
1932
3002
  }
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: [
3003
+ pushLog("devup", `\u{1F440} watching ${configPath}`, 12);
3004
+ return watchConfig({
3005
+ configPath,
3006
+ baseCwd,
3007
+ manager,
3008
+ log: (msg) => pushLog("devup", msg, msg.startsWith("\u26A0") ? 5 : 12)
3009
+ });
3010
+ }, [cliArgs.watchConfig, cliArgs.configPath, baseCwd, manager, pushLog]);
3011
+ }
3012
+
3013
+ // src/tui/hooks/useContextualTips.ts
3014
+ import { useEffect as useEffect8, useRef as useRef4, useState as useState5 } from "react";
3015
+
3016
+ // src/tui/tips.ts
3017
+ function pickTip(state) {
3018
+ if (state.crashLoopedCount > 0 && !state.shown.has("crashed")) {
3019
+ return { id: "crashed", message: "tip: press r to restart, or check the log of the failing service" };
3020
+ }
3021
+ if (state.totalLogs > 1e3 && !state.hasSearch && !state.shown.has("search")) {
3022
+ return { id: "search", message: "tip: press / to search in logs" };
3023
+ }
3024
+ if (state.totalLogs > 500 && !state.hasFilter && !state.shown.has("filter")) {
3025
+ return { id: "filter", message: "tip: press f to filter logs by service" };
3026
+ }
3027
+ return null;
3028
+ }
3029
+
3030
+ // src/tui/StatsPanel.tsx
3031
+ import { useEffect as useEffect7, useState as useState4 } from "react";
3032
+ import { Box, Text } from "ink";
3033
+ import os from "os";
3034
+ import { jsx, jsxs } from "react/jsx-runtime";
3035
+ var H = {
3036
+ up: { c: "\u25CF", color: "green" },
3037
+ wait: { c: "\u25CF", color: "yellow" },
3038
+ down: { c: "\u25CF", color: "red" },
3039
+ idle: { c: "\u25CB", color: "blue" }
3040
+ };
3041
+ var MAX_RESTARTS2 = 3;
3042
+ function isCrashLooped(st) {
3043
+ return st.status === "crashed" && st.restarts >= MAX_RESTARTS2;
3044
+ }
3045
+ function Row({ name, st, stat, ml, verbose }) {
3046
+ const looped = isCrashLooped(st);
3047
+ 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 });
3048
+ const color = tagColors[st.colorIdx % tagColors.length];
3049
+ const sc = looped ? "red" : st.status === "running" ? "green" : st.status === "starting" ? "yellow" : st.status === "idle" ? "blue" : "red";
3050
+ const statusLabel = looped ? "looping" : st.status;
3051
+ const up = st.startedAt ? fmtUptime(Date.now() - st.startedAt) : "-";
3052
+ if (!verbose) {
3053
+ return /* @__PURE__ */ jsxs(Text, { children: [
3054
+ indicator,
1940
3055
  " ",
1941
- title,
3056
+ /* @__PURE__ */ jsx(Text, { color, children: name.padEnd(ml) }),
1942
3057
  " ",
1943
- query && /* @__PURE__ */ jsxs4(Text4, { color: "yellow", children: [
1944
- "[",
1945
- query,
1946
- "]"
1947
- ] })
3058
+ String(st.svc.port).padStart(5),
3059
+ " ",
3060
+ /* @__PURE__ */ jsx(Text, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
3061
+ " ",
3062
+ (stat?.cpu ?? "-").padStart(6),
3063
+ " ",
3064
+ (stat?.mem ?? "-").padStart(8),
3065
+ " ",
3066
+ String(st.errors).padStart(3),
3067
+ " ",
3068
+ String(st.restarts).padStart(3),
3069
+ " ",
3070
+ up.padStart(6)
3071
+ ] });
3072
+ }
3073
+ const resolvedArgs = buildProcessArgs(st.svc).join(" ");
3074
+ const env = redactSecrets(st.svc.extraEnv);
3075
+ const envStr = Object.entries(env).map(([k, v]) => `${k}=${v}`).join(" ");
3076
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
3077
+ /* @__PURE__ */ jsxs(Text, { children: [
3078
+ indicator,
3079
+ " ",
3080
+ /* @__PURE__ */ jsx(Text, { color, children: name.padEnd(ml) }),
3081
+ " ",
3082
+ String(st.svc.port).padStart(5),
3083
+ " ",
3084
+ /* @__PURE__ */ jsx(Text, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
3085
+ " ",
3086
+ (stat?.cpu ?? "-").padStart(6),
3087
+ " ",
3088
+ (stat?.mem ?? "-").padStart(8),
3089
+ " ",
3090
+ String(st.errors).padStart(3),
3091
+ " ",
3092
+ String(st.restarts).padStart(3),
3093
+ " ",
3094
+ up.padStart(6)
1948
3095
  ] }),
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: [
3096
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
3097
+ " cmd: ",
3098
+ st.svc.cmd,
1950
3099
  " ",
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" })
3100
+ resolvedArgs
3101
+ ] }),
3102
+ envStr && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
3103
+ " env: ",
3104
+ envStr
3105
+ ] })
1957
3106
  ] });
1958
3107
  }
1959
-
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" })
3108
+ function ColHeader({ ml }) {
3109
+ return /* @__PURE__ */ jsxs(Text, { bold: true, children: [
3110
+ "H ",
3111
+ "Service".padEnd(ml),
3112
+ " ",
3113
+ "Port".padStart(5),
3114
+ " ",
3115
+ "Status".padEnd(8),
3116
+ " ",
3117
+ "CPU".padStart(6),
3118
+ " ",
3119
+ "Mem".padStart(8),
3120
+ " Err Rst ",
3121
+ "Up".padStart(6)
1976
3122
  ] });
1977
3123
  }
1978
-
1979
- // src/lazy/proxy.ts
1980
- import net2 from "net";
1981
- function createLazyProxy(opts) {
1982
- const { listenPort, targetPort, timeoutMin, onDemandStart, onIdleStop, isAlive, onLog } = opts;
1983
- let idleTimer = null;
1984
- let lastActivity = Date.now();
1985
- let starting = false;
1986
- let serviceReady = false;
1987
- let pendingConns = [];
1988
- const activeConns = /* @__PURE__ */ new Set();
1989
- function bumpActivity() {
1990
- lastActivity = Date.now();
1991
- }
1992
- function scheduleIdleCheck() {
1993
- if (idleTimer) clearTimeout(idleTimer);
1994
- if (timeoutMin <= 0) return;
1995
- const periodMs = timeoutMin * 6e4;
1996
- idleTimer = setTimeout(() => {
1997
- const elapsed = Date.now() - lastActivity;
1998
- if (activeConns.size > 0 || elapsed < periodMs) {
1999
- scheduleIdleCheck();
2000
- return;
2001
- }
2002
- serviceReady = false;
2003
- onLog?.(`\u{1F4A4} idle ${timeoutMin}min \u2014 stopping`);
2004
- onIdleStop();
2005
- }, periodMs);
2006
- }
2007
- function pipeToTarget(client) {
2008
- const target = net2.createConnection({ port: targetPort, host: "127.0.0.1", allowHalfOpen: true });
2009
- activeConns.add(client);
2010
- const cleanup = () => {
2011
- activeConns.delete(client);
2012
- bumpActivity();
2013
- };
2014
- target.on("error", () => {
2015
- client.destroy();
2016
- cleanup();
2017
- });
2018
- client.on("error", () => {
2019
- target.destroy();
2020
- cleanup();
2021
- });
2022
- client.on("close", cleanup);
2023
- target.on("close", cleanup);
2024
- target.on("connect", () => {
2025
- target.on("data", (chunk) => {
2026
- bumpActivity();
2027
- if (!client.destroyed) client.write(chunk);
2028
- });
2029
- client.on("data", (chunk) => {
2030
- bumpActivity();
2031
- if (!target.destroyed) target.write(chunk);
2032
- });
2033
- target.on("end", () => {
2034
- if (!client.destroyed) client.end();
2035
- });
2036
- client.on("end", () => {
2037
- if (!target.destroyed) target.end();
2038
- });
2039
- });
2040
- }
2041
- async function handleConnection(client) {
2042
- bumpActivity();
2043
- client.on("error", () => {
2044
- });
2045
- if (serviceReady && isAlive()) {
2046
- pipeToTarget(client);
2047
- return;
2048
- }
2049
- pendingConns.push(client);
2050
- client.on("close", () => {
2051
- pendingConns = pendingConns.filter((s) => s !== client);
2052
- });
2053
- if (starting) return;
2054
- starting = true;
2055
- onLog?.("\u26A1 on-demand start");
2056
- let ok = false;
2057
- try {
2058
- await onDemandStart();
2059
- ok = await waitForPort(targetPort, { timeout: 45e3, interval: 500 });
2060
- if (ok) serviceReady = true;
2061
- else onLog?.("\u26A0 timeout waiting for service");
2062
- } catch (e) {
2063
- onLog?.(`\u274C start failed: ${e.message}`);
2064
- }
2065
- starting = false;
2066
- const conns = pendingConns.splice(0);
2067
- if (!ok) {
2068
- 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);
3124
+ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scrollOffset, resetScroll, verbose = false }) {
3125
+ const names = [...states.keys()];
3126
+ const stObj = Object.fromEntries([...states].map(([k, v]) => [k, { errors: v.errors }]));
3127
+ const statsObj = Object.fromEntries([...stats].map(([k, v]) => [k, v]));
3128
+ const apis = sortServiceNames(names.filter((n) => states.get(n).svc.type === "api"), sortMode, statsObj, stObj);
3129
+ const webs = sortServiceNames(names.filter((n) => states.get(n).svc.type === "web"), sortMode, statsObj, stObj);
3130
+ const cpus = os.cpus().length;
3131
+ const totalGB = (os.totalmem() / 1024 / 1024 / 1024).toFixed(1);
3132
+ const usedGB = (parseFloat(totalGB) - os.freemem() / 1024 / 1024 / 1024).toFixed(1);
3133
+ const load = os.loadavg()[0].toFixed(2);
3134
+ let totalCpu = 0, totalMemMB = 0, totalErrors = 0, totalRestarts = 0;
3135
+ for (const name of names) {
3136
+ const s = stats.get(name);
3137
+ if (s) {
3138
+ const c = parseFloat(s.cpu);
3139
+ if (!isNaN(c)) totalCpu += c;
3140
+ const m = parseFloat(s.mem);
3141
+ if (!isNaN(m)) totalMemMB += m;
2075
3142
  }
3143
+ totalErrors += states.get(name)?.errors ?? 0;
3144
+ totalRestarts += states.get(name)?.restarts ?? 0;
2076
3145
  }
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
- };
3146
+ const stackMem = totalMemMB >= 1024 ? (totalMemMB / 1024).toFixed(2) + " GB" : totalMemMB.toFixed(1) + " MB";
3147
+ const ml = maxNameLen;
3148
+ const contentHeight = Math.max(1, height - 2);
3149
+ const rowsPerCol = Math.max(1, contentHeight - 2);
3150
+ const maxRows = Math.max(0, Math.max(apis.length, webs.length) - rowsPerCol);
3151
+ const effectiveOffset = scrollOffset === Number.MAX_SAFE_INTEGER ? maxRows : Math.min(scrollOffset, maxRows);
3152
+ const apiStartIndex = Math.min(effectiveOffset, Math.max(0, apis.length - rowsPerCol));
3153
+ const webStartIndex = Math.min(effectiveOffset, Math.max(0, webs.length - rowsPerCol));
3154
+ const visibleApis = apis.slice(apiStartIndex, apiStartIndex + rowsPerCol);
3155
+ const visibleWebs = webs.slice(webStartIndex, webStartIndex + rowsPerCol);
3156
+ useEffect7(() => {
3157
+ resetScroll();
3158
+ }, [sortMode, resetScroll]);
3159
+ const totalRowsLong = Math.max(apis.length, webs.length);
3160
+ const positionInfo = focused && totalRowsLong > 0 ? `(${effectiveOffset + 1}-${Math.min(effectiveOffset + rowsPerCol, totalRowsLong)}/${totalRowsLong})` : "";
3161
+ const scrolled = effectiveOffset > 0;
3162
+ const loopedCount = [...states.values()].filter(isCrashLooped).length;
3163
+ const ramPct = parseFloat(usedGB) / parseFloat(totalGB) * 100;
3164
+ const [ramBanner, setRamBanner] = useState4(false);
3165
+ useEffect7(() => {
3166
+ setRamBanner((prev) => nextRamBannerVisibility(ramPct, prev));
3167
+ }, [ramPct]);
3168
+ 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) : [];
3169
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "green" : "gray", height, children: [
3170
+ /* @__PURE__ */ jsxs(Box, { children: [
3171
+ /* @__PURE__ */ jsxs(Text, { bold: true, color: "green", children: [
3172
+ " Stats ",
3173
+ positionInfo
3174
+ ] }),
3175
+ scrolled && /* @__PURE__ */ jsx(Text, { color: "yellow", children: " [SCROLL]" }),
3176
+ loopedCount > 0 && /* @__PURE__ */ jsxs(Text, { color: "red", bold: true, children: [
3177
+ " \u26A0 ",
3178
+ loopedCount,
3179
+ " need attention"
3180
+ ] }),
3181
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
3182
+ " System: ",
3183
+ cpus,
3184
+ "c Load ",
3185
+ load,
3186
+ " RAM ",
3187
+ usedGB,
3188
+ "/",
3189
+ totalGB,
3190
+ "GB"
3191
+ ] }),
3192
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " \u2502 " }),
3193
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
3194
+ "Stack: CPU ",
3195
+ totalCpu.toFixed(1),
3196
+ "% RAM ",
3197
+ stackMem,
3198
+ " Err ",
3199
+ totalErrors,
3200
+ " Rst ",
3201
+ totalRestarts,
3202
+ " Svcs ",
3203
+ names.length
3204
+ ] }),
3205
+ sortMode !== "name" && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
3206
+ " \u2502 Sort: ",
3207
+ sortMode
3208
+ ] })
3209
+ ] }),
3210
+ ramBanner && /* @__PURE__ */ jsxs(Box, { children: [
3211
+ /* @__PURE__ */ jsxs(Text, { color: "yellow", bold: true, children: [
3212
+ " \u26A0 RAM ",
3213
+ ramPct.toFixed(0),
3214
+ "% \u2014 top: "
3215
+ ] }),
3216
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: topConsumers.map((c) => `${c.name} ${c.mb.toFixed(0)}MB`).join(", ") })
3217
+ ] }),
3218
+ /* @__PURE__ */ jsxs(Box, { flexGrow: 1, children: [
3219
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", flexGrow: 1, flexBasis: 0, children: [
3220
+ /* @__PURE__ */ jsxs(Text, { bold: true, color: "cyan", children: [
3221
+ " APIs (",
3222
+ apis.length,
3223
+ ")"
3224
+ ] }),
3225
+ /* @__PURE__ */ jsx(ColHeader, { ml }),
3226
+ visibleApis.map((n) => /* @__PURE__ */ jsx(Row, { name: n, st: states.get(n), stat: stats.get(n), ml, verbose }, n))
3227
+ ] }),
3228
+ /* @__PURE__ */ jsx(Box, { flexDirection: "column", width: 1, children: Array.from({ length: contentHeight }, (_, i) => /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }, i)) }),
3229
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", flexGrow: 1, flexBasis: 0, children: [
3230
+ /* @__PURE__ */ jsxs(Text, { bold: true, color: "magenta", children: [
3231
+ " Webs (",
3232
+ webs.length,
3233
+ ")"
3234
+ ] }),
3235
+ /* @__PURE__ */ jsx(ColHeader, { ml }),
3236
+ visibleWebs.map((n) => /* @__PURE__ */ jsx(Row, { name: n, st: states.get(n), stat: stats.get(n), ml, verbose }, n))
3237
+ ] })
3238
+ ] })
3239
+ ] });
2090
3240
  }
2091
3241
 
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);
3242
+ // src/tui/hooks/useContextualTips.ts
3243
+ function useContextualTips(totalLogs, hasSearch, hasFilter, states) {
3244
+ const shownTips = useRef4(/* @__PURE__ */ new Set());
3245
+ const [activeTip, setActiveTip] = useState5(null);
3246
+ useEffect8(() => {
3247
+ const tip = pickTip({
3248
+ totalLogs,
3249
+ hasSearch,
3250
+ hasFilter,
3251
+ crashLoopedCount: [...states.values()].filter(isCrashLooped).length,
3252
+ shown: shownTips.current
3253
+ });
3254
+ if (tip && tip.message !== activeTip) {
3255
+ shownTips.current.add(tip.id);
3256
+ setActiveTip(tip.message);
3257
+ const timer = setTimeout(() => setActiveTip(null), 12e3);
3258
+ return () => clearTimeout(timer);
2117
3259
  }
2118
- }
2119
- return { procs, allHealthy: failed.length === 0, failed };
3260
+ }, [totalLogs, states, hasSearch, hasFilter, activeTip]);
3261
+ return activeTip;
2120
3262
  }
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);
3263
+
3264
+ // src/tui/hooks/useBootSequence.ts
3265
+ import { useEffect as useEffect9, useState as useState6 } from "react";
3266
+ function useBootSequence(manager, config, services, cliArgs, platform, env, baseCwd, refs, pushLog) {
3267
+ const [booted, setBooted] = useState6(false);
3268
+ useEffect9(() => {
3269
+ if (booted || !manager) return;
3270
+ setBooted(true);
3271
+ const mgr = manager;
3272
+ (async () => {
3273
+ const lazyMode = cliArgs.lazy;
3274
+ const lazyTimeout = cliArgs.lazyTimeout;
3275
+ if (config.external?.length) {
3276
+ const result = await startExternals(config.external, {
3277
+ baseCwd,
3278
+ env,
3279
+ platform,
3280
+ onLog: (svc, msg) => pushLog(`ext:${svc}`, msg, 12)
2137
3281
  });
3282
+ refs.externals.current = result.procs;
3283
+ if (!result.allHealthy) {
3284
+ pushLog("devup", `\u274C external(s) failed: ${result.failed.join(", ")}. Aborting boot.`, 5);
3285
+ return;
3286
+ }
2138
3287
  }
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;
3288
+ if (lazyMode && config.lazy) {
3289
+ const { alwaysOn, lazy } = classifyServices(services, config.lazy);
3290
+ const aoPhases = groupByPhase(alwaysOn);
3291
+ let colorIdx = 0;
3292
+ for (const num of Object.keys(aoPhases).map(Number).sort((a, b) => a - b)) {
3293
+ const svcs = aoPhases[num];
3294
+ for (const svc of svcs) {
3295
+ const ci = colorIdx++;
3296
+ await mgr.install(svc, ci);
3297
+ await mgr.start(svc, ci);
3298
+ }
3299
+ const apis = svcs.filter((s) => s.type === "api");
3300
+ if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
3301
+ svcs.filter((s) => s.type === "web").forEach((s) => {
3302
+ const st = mgr.state.get(s.name);
3303
+ if (st) st.status = "running";
3304
+ });
3305
+ }
3306
+ for (const svc of lazy) {
3307
+ const ci = colorIdx++;
3308
+ const rewritten = rewriteServicePort(svc);
3309
+ const idleState = {
3310
+ svc: rewritten,
3311
+ proc: null,
3312
+ pid: null,
3313
+ status: "idle",
3314
+ health: "idle",
3315
+ errors: 0,
3316
+ restarts: 0,
3317
+ startedAt: null,
3318
+ intentionalStop: false,
3319
+ colorIdx: ci
3320
+ };
3321
+ mgr.state.set(svc.name, idleState);
3322
+ const proxy = createLazyProxy({
3323
+ listenPort: svc.port,
3324
+ targetPort: rewritten.realPort,
3325
+ timeoutMin: lazyTimeout,
3326
+ onDemandStart: async () => {
3327
+ await mgr.install(rewritten, ci);
3328
+ await mgr.start(rewritten, ci);
3329
+ const ok = await waitForPort(rewritten.realPort, { timeout: 45e3 });
3330
+ const st = mgr.state.get(svc.name);
3331
+ if (st) {
3332
+ st.status = ok ? "running" : "timeout";
3333
+ if (ok) st.health = "up";
3334
+ }
3335
+ },
3336
+ onIdleStop: () => {
3337
+ mgr.stop(svc.name);
3338
+ const st = mgr.state.get(svc.name);
3339
+ if (st) {
3340
+ st.status = "idle";
3341
+ st.health = "idle";
3342
+ st.pid = null;
3343
+ st.proc = null;
3344
+ st.startedAt = null;
3345
+ }
3346
+ },
3347
+ isAlive: () => {
3348
+ const st = mgr.state.get(svc.name);
3349
+ return !!st && !!st.proc && !st.proc.killed && st.health === "up";
3350
+ }
3351
+ });
3352
+ refs.lazyProxies.current.set(svc.name, proxy);
3353
+ }
3354
+ } else {
3355
+ const phases = groupByPhase(services);
3356
+ let colorIdx = 0;
3357
+ for (const num of Object.keys(phases).map(Number).sort((a, b) => a - b)) {
3358
+ const svcs = phases[num];
3359
+ for (const svc of svcs) {
3360
+ const ci = colorIdx++;
3361
+ await mgr.install(svc, ci);
3362
+ await mgr.start(svc, ci);
3363
+ }
3364
+ const apis = svcs.filter((s) => s.type === "api");
3365
+ if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
3366
+ svcs.filter((s) => s.type === "web").forEach((s) => {
3367
+ const st = mgr.state.get(s.name);
3368
+ if (st) st.status = "running";
3369
+ });
3370
+ }
3371
+ }
3372
+ })();
3373
+ }, [booted, manager, services, cliArgs, config.lazy, config.external, baseCwd, env, platform, refs, pushLog]);
2161
3374
  }
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));
3375
+
3376
+ // src/tui/LogsPanel.tsx
3377
+ import { useEffect as useEffect10, useMemo } from "react";
3378
+ import { Box as Box2, Text as Text2 } from "ink";
3379
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
3380
+ function resolveBorder(focused, filter, filteredColorIdx) {
3381
+ if (focused) return "cyan";
3382
+ if (filter && filteredColorIdx !== null && filteredColorIdx >= 0) {
3383
+ return tagColors[filteredColorIdx % tagColors.length];
2168
3384
  }
2169
- return false;
3385
+ return "gray";
3386
+ }
3387
+ function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLen, height, focused, scrollOffset, resetScroll, levelFilter = "all", filteredColorIdx = null }) {
3388
+ const byService = filter ? logs.filter((l) => l.svcName === filter) : logs;
3389
+ const filtered = levelFilter === "all" ? byService : levelFilter === "error" ? byService.filter((l) => l.level === "error") : byService.filter((l) => l.level === "error" || l.level === "warn");
3390
+ const contentHeight = Math.max(1, height - 2);
3391
+ const totalLines = filtered.length;
3392
+ const maxOffset = Math.max(0, totalLines - contentHeight);
3393
+ const effectiveOffset = scrollOffset === Number.MAX_SAFE_INTEGER ? maxOffset : Math.min(scrollOffset, maxOffset);
3394
+ const startIndex = Math.max(0, totalLines - contentHeight - effectiveOffset);
3395
+ const endIndex = Math.min(startIndex + contentHeight, totalLines);
3396
+ const visible = filtered.slice(startIndex, endIndex);
3397
+ useEffect10(() => {
3398
+ resetScroll();
3399
+ }, [filter, searchTerm, resetScroll]);
3400
+ const matcher = useMemo(() => compileSearchPattern(searchTerm), [searchTerm]);
3401
+ const scrolled = effectiveOffset > 0;
3402
+ const label = [
3403
+ "Logs",
3404
+ filter ? `[${filter}]` : "",
3405
+ searchTerm ? `/${searchTerm}` : "",
3406
+ matcher?.invalid ? "(invalid regex)" : "",
3407
+ levelFilter !== "all" ? `[level: ${levelFilter}${levelFilter === "warn" ? "+error" : ""}]` : "",
3408
+ paused ? "[PAUSED]" : "",
3409
+ scrolled ? "[SCROLL]" : "",
3410
+ `${filtered.length} lines`,
3411
+ focused && totalLines > 0 ? `(${startIndex + 1}-${endIndex}/${totalLines})` : ""
3412
+ ].filter(Boolean).join(" ");
3413
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", borderColor: resolveBorder(focused, filter, filteredColorIdx), height, children: [
3414
+ /* @__PURE__ */ jsx2(Box2, { children: /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "cyan", children: [
3415
+ " ",
3416
+ label,
3417
+ " "
3418
+ ] }) }),
3419
+ visible.map((entry, i) => {
3420
+ const color = tagColors[entry.colorIdx % tagColors.length];
3421
+ const ts = showTimestamps ? new Date(entry.ts).toLocaleTimeString("en-GB") + " " : "";
3422
+ const line = entry.text;
3423
+ const isMatch = matcher ? matcher.test(line) : false;
3424
+ return /* @__PURE__ */ jsxs2(Box2, { children: [
3425
+ showTimestamps && /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: ts }),
3426
+ /* @__PURE__ */ jsxs2(Text2, { color, children: [
3427
+ "[",
3428
+ entry.svcName.padEnd(maxNameLen),
3429
+ "]"
3430
+ ] }),
3431
+ /* @__PURE__ */ jsx2(Text2, { children: " " }),
3432
+ isMatch ? /* @__PURE__ */ jsx2(Text2, { backgroundColor: "yellow", color: "black", children: line }) : /* @__PURE__ */ jsx2(Text2, { children: line })
3433
+ ] }, i);
3434
+ })
3435
+ ] });
2170
3436
  }
2171
3437
 
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;
3438
+ // src/tui/StatusBar.tsx
3439
+ import { Box as Box3, Text as Text3 } from "ink";
3440
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
3441
+ function StatusBar() {
3442
+ return /* @__PURE__ */ jsx3(Box3, { children: /* @__PURE__ */ jsxs3(Text3, { children: [
3443
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "q" }),
3444
+ " Quit ",
3445
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Tab" }),
3446
+ " Switch ",
3447
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "\u2191\u2193" }),
3448
+ " Scroll ",
3449
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "PgUp/PgDn" }),
3450
+ " Page ",
3451
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Ctrl+A/E" }),
3452
+ " Home/End ",
3453
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "c" }),
3454
+ " Clear ",
3455
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "f" }),
3456
+ " Filter ",
3457
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "L" }),
3458
+ " Level ",
3459
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "a" }),
3460
+ " All ",
3461
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "r" }),
3462
+ " Restart ",
3463
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "/" }),
3464
+ " Search ",
3465
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "s" }),
3466
+ " Sort ",
3467
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "o" }),
3468
+ " Open ",
3469
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "p" }),
3470
+ " Pause ",
3471
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "t" }),
3472
+ " Time ",
3473
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "v" }),
3474
+ " Verbose ",
3475
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "T" }),
3476
+ " Proxy"
3477
+ ] }) });
2184
3478
  }
2185
3479
 
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}` } });
3480
+ // src/tui/ServiceList.tsx
3481
+ import { useState as useState7, useMemo as useMemo2 } from "react";
3482
+ import { Box as Box4, Text as Text4, useInput as useInput2 } from "ink";
3483
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
3484
+ function ServiceList({ title, services, onSelect, onClose, filterType }) {
3485
+ const allNames = useMemo2(
3486
+ () => [...services.keys()].filter((n) => !filterType || services.get(n).svc.type === filterType),
3487
+ [services, filterType]
3488
+ );
3489
+ const [idx, setIdx] = useState7(0);
3490
+ const [query, setQuery] = useState7("");
3491
+ const names = useMemo2(() => {
3492
+ if (!query) return allNames;
3493
+ const q = query.toLowerCase();
3494
+ return allNames.filter((n) => n.toLowerCase().includes(q));
3495
+ }, [allNames, query]);
3496
+ const clamped = Math.min(idx, Math.max(0, names.length - 1));
3497
+ useInput2((input, key) => {
3498
+ if (key.escape) {
3499
+ if (query) setQuery("");
3500
+ else onClose();
2243
3501
  return;
2244
3502
  }
2245
- if (typeof req.method !== "string") {
2246
- respond(socket, { id: req.id, error: { code: -32600, message: "method required" } });
3503
+ if (key.return) {
3504
+ if (names[clamped]) onSelect(names[clamped]);
2247
3505
  return;
2248
3506
  }
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 };
3507
+ if (key.upArrow) {
3508
+ setIdx((i) => Math.max(0, i - 1));
3509
+ return;
2285
3510
  }
2286
- case "stop": {
2287
- const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
2288
- ctx.stop(svc);
2289
- return { ok: true };
3511
+ if (key.downArrow) {
3512
+ setIdx((i) => Math.min(names.length - 1, i + 1));
3513
+ return;
2290
3514
  }
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) };
3515
+ if (key.backspace || key.delete) {
3516
+ setQuery((q) => q.slice(0, -1));
3517
+ setIdx(0);
3518
+ return;
2295
3519
  }
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
- }
2306
- return v;
2307
- }
2308
-
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);
2346
- continue;
3520
+ if (input && !key.ctrl && !key.meta && input.length === 1) {
3521
+ setQuery((q) => q + input);
3522
+ setIdx(0);
2347
3523
  }
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 };
3524
+ }, { isActive: process.stdin.isTTY ?? false });
3525
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
3526
+ /* @__PURE__ */ jsxs4(Text4, { bold: true, color: "cyan", children: [
3527
+ " ",
3528
+ title,
3529
+ " ",
3530
+ query && /* @__PURE__ */ jsxs4(Text4, { color: "yellow", children: [
3531
+ "[",
3532
+ query,
3533
+ "]"
3534
+ ] })
3535
+ ] }),
3536
+ 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: [
3537
+ " ",
3538
+ name,
3539
+ " :",
3540
+ services.get(name).svc.port,
3541
+ " "
3542
+ ] }) }, name)),
3543
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "type to filter \u2191\u2193 navigate Enter select Esc clear/close" })
3544
+ ] });
2356
3545
  }
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(", ");
3546
+
3547
+ // src/tui/SearchInput.tsx
3548
+ import { useState as useState8 } from "react";
3549
+ import { Box as Box5, Text as Text5, useInput as useInput3 } from "ink";
3550
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
3551
+ function SearchInput({ onSubmit, onClose }) {
3552
+ const [value, setValue] = useState8("");
3553
+ useInput3((input, key) => {
3554
+ if (key.escape) onClose();
3555
+ else if (key.return) onSubmit(value.trim() || null);
3556
+ else if (key.backspace || key.delete) setValue((v) => v.slice(0, -1));
3557
+ else if (input && !key.ctrl && !key.meta) setValue((v) => v + input);
3558
+ }, { isActive: process.stdin.isTTY ?? false });
3559
+ return /* @__PURE__ */ jsxs5(Box5, { borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
3560
+ /* @__PURE__ */ jsx5(Text5, { bold: true, color: "yellow", children: "Search: " }),
3561
+ /* @__PURE__ */ jsx5(Text5, { children: value }),
3562
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u2588" })
3563
+ ] });
2364
3564
  }
2365
3565
 
2366
3566
  // src/tui/App.tsx
@@ -2377,26 +3577,13 @@ function buildServiceUrl(name, port, proxyActive, proxyOpts) {
2377
3577
  return `http://localhost:${port}`;
2378
3578
  }
2379
3579
  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]);
3580
+ const rows = useTerminalSize();
2390
3581
  const logsHeight = Math.floor(rows * 0.65);
2391
3582
  const statsHeight = rows - logsHeight - 2;
2392
3583
  const maxNameLen = Math.max(...services.map((s) => s.name.length), 10);
2393
3584
  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);
3585
+ const lazyProxies = useRef5(/* @__PURE__ */ new Map());
3586
+ const externals = useRef5([]);
2400
3587
  const kb = useKeyBindings({
2401
3588
  onQuit: () => {
2402
3589
  void shutdown();
@@ -2405,10 +3592,10 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
2405
3592
  onToggleProxy: () => {
2406
3593
  }
2407
3594
  });
3595
+ const socketServer = useControlPlane(pm.manager, config.name, logSink, pm.pushLog, pm.logBus, pm.stateBus);
2408
3596
  const shutdown = useCallback3(async () => {
2409
3597
  lazyProxies.current.forEach((p) => p.destroy());
2410
3598
  await socketServer.current?.close();
2411
- socketServer.current = null;
2412
3599
  await pm.cleanup();
2413
3600
  if (externals.current.length) {
2414
3601
  await stopExternals(externals.current, platform, {
@@ -2420,236 +3607,22 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
2420
3607
  }
2421
3608
  await logSink?.close();
2422
3609
  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;
2462
- 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
- }
2516
- }
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);
2544
- }
2545
- }, [pm.logs.length, pm.states, kb.searchTerm, kb.logFilter, activeTip]);
3610
+ }, [pm, logSink, platform, baseCwd, env, socketServer]);
3611
+ useHotReload(pm.manager, cliArgs, baseCwd, pm.pushLog);
3612
+ useLogsPause(pm.setPaused, kb.logsPaused, kb.logsScrollOffset);
3613
+ const activeTip = useContextualTips(pm.logs.length, !!kb.searchTerm, !!kb.logFilter, pm.states);
2546
3614
  useProxySync(proxyProvider, proxyOpts, pm.states, kb.proxyEnabled);
2547
- useEffect5(() => {
2548
- if (booted || !pm.manager) return;
2549
- setBooted(true);
2550
- const mgr = pm.manager;
2551
- (async () => {
2552
- const lazyMode = cliArgs.lazy;
2553
- const lazyTimeout = cliArgs.lazyTimeout;
2554
- if (config.external?.length) {
2555
- const result = await startExternals(config.external, {
2556
- baseCwd,
2557
- env,
2558
- platform,
2559
- onLog: (svc, msg) => pm.pushLog(`ext:${svc}`, msg, 12)
2560
- });
2561
- externals.current = result.procs;
2562
- if (!result.allHealthy) {
2563
- pm.pushLog("devup", `\u274C external(s) failed: ${result.failed.join(", ")}. Aborting boot.`, 5);
2564
- return;
2565
- }
2566
- }
2567
- if (lazyMode && config.lazy) {
2568
- const { alwaysOn, lazy } = classifyServices(services, config.lazy);
2569
- const aoPhases = groupByPhase(alwaysOn);
2570
- let colorIdx = 0;
2571
- for (const num of Object.keys(aoPhases).map(Number).sort((a, b) => a - b)) {
2572
- const svcs = aoPhases[num];
2573
- for (const svc of svcs) {
2574
- const ci = colorIdx++;
2575
- await mgr.install(svc, ci);
2576
- await mgr.start(svc, ci);
2577
- }
2578
- const apis = svcs.filter((s) => s.type === "api");
2579
- if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
2580
- svcs.filter((s) => s.type === "web").forEach((s) => {
2581
- const st = mgr.state.get(s.name);
2582
- if (st) st.status = "running";
2583
- });
2584
- }
2585
- for (const svc of lazy) {
2586
- const ci = colorIdx++;
2587
- const rewritten = rewriteServicePort(svc);
2588
- const idleState = {
2589
- svc: rewritten,
2590
- proc: null,
2591
- pid: null,
2592
- status: "idle",
2593
- health: "idle",
2594
- errors: 0,
2595
- restarts: 0,
2596
- startedAt: null,
2597
- intentionalStop: false,
2598
- colorIdx: ci
2599
- };
2600
- mgr.state.set(svc.name, idleState);
2601
- const proxy = createLazyProxy({
2602
- listenPort: svc.port,
2603
- targetPort: rewritten.realPort,
2604
- timeoutMin: lazyTimeout,
2605
- onDemandStart: async () => {
2606
- await mgr.install(rewritten, ci);
2607
- await mgr.start(rewritten, ci);
2608
- const ok = await waitForPort(rewritten.realPort, { timeout: 45e3 });
2609
- const st = mgr.state.get(svc.name);
2610
- if (st) {
2611
- st.status = ok ? "running" : "timeout";
2612
- if (ok) st.health = "up";
2613
- }
2614
- },
2615
- onIdleStop: () => {
2616
- mgr.stop(svc.name);
2617
- const st = mgr.state.get(svc.name);
2618
- if (st) {
2619
- st.status = "idle";
2620
- st.health = "idle";
2621
- st.pid = null;
2622
- st.proc = null;
2623
- st.startedAt = null;
2624
- }
2625
- },
2626
- isAlive: () => {
2627
- const st = mgr.state.get(svc.name);
2628
- return !!st && !!st.proc && !st.proc.killed && st.health === "up";
2629
- }
2630
- });
2631
- lazyProxies.current.set(svc.name, proxy);
2632
- }
2633
- } else {
2634
- const phases = groupByPhase(services);
2635
- let colorIdx = 0;
2636
- for (const num of Object.keys(phases).map(Number).sort((a, b) => a - b)) {
2637
- const svcs = phases[num];
2638
- for (const svc of svcs) {
2639
- const ci = colorIdx++;
2640
- await mgr.install(svc, ci);
2641
- await mgr.start(svc, ci);
2642
- }
2643
- const apis = svcs.filter((s) => s.type === "api");
2644
- if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
2645
- svcs.filter((s) => s.type === "web").forEach((s) => {
2646
- const st = mgr.state.get(s.name);
2647
- if (st) st.status = "running";
2648
- });
2649
- }
2650
- }
2651
- })();
2652
- }, [booted, pm.manager, services, cliArgs, config.lazy]);
3615
+ useBootSequence(
3616
+ pm.manager,
3617
+ config,
3618
+ services,
3619
+ cliArgs,
3620
+ platform,
3621
+ env,
3622
+ baseCwd,
3623
+ { lazyProxies, externals },
3624
+ pm.pushLog
3625
+ );
2653
3626
  const handleFilterSelect = useCallback3((name) => kb.setFilter(name), [kb]);
2654
3627
  const handleRestartSelect = useCallback3((name) => {
2655
3628
  pm.restart(name);
@@ -2722,61 +3695,6 @@ ${formatValidationErrors(errs)}`, 5);
2722
3695
  ] });
2723
3696
  }
2724
3697
 
2725
- // 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";
2728
- import { homedir as homedir3 } from "os";
2729
- var LogSink = class {
2730
- dir;
2731
- rotateOnStart;
2732
- streams = /* @__PURE__ */ new Map();
2733
- seen = /* @__PURE__ */ new Set();
2734
- constructor(opts) {
2735
- const root = opts.rootDir ?? join7(homedir3(), ".devup", "logs");
2736
- this.dir = join7(root, sanitize2(opts.projectName));
2737
- this.rotateOnStart = opts.rotateOnStart ?? true;
2738
- mkdirSync5(this.dir, { recursive: true });
2739
- }
2740
- /** Returns the file path for a service log (useful for tests / UI). */
2741
- pathFor(svcName) {
2742
- return join7(this.dir, `${sanitize2(svcName)}.log`);
2743
- }
2744
- write(svcName, line) {
2745
- const stream = this.streamFor(svcName);
2746
- stream.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
2747
- `);
2748
- }
2749
- async close() {
2750
- const closes = [...this.streams.values()].map(
2751
- (s) => new Promise((r) => s.end(() => r()))
2752
- );
2753
- this.streams.clear();
2754
- this.seen.clear();
2755
- await Promise.all(closes);
2756
- }
2757
- streamFor(svcName) {
2758
- let s = this.streams.get(svcName);
2759
- if (s) return s;
2760
- const file = this.pathFor(svcName);
2761
- if (this.rotateOnStart && !this.seen.has(svcName) && existsSync12(file)) {
2762
- try {
2763
- mkdirSync5(dirname6(file), { recursive: true });
2764
- renameSync(file, file + ".prev");
2765
- } catch {
2766
- }
2767
- }
2768
- this.seen.add(svcName);
2769
- s = createWriteStream(file, { flags: "a" });
2770
- s.on("error", () => {
2771
- });
2772
- this.streams.set(svcName, s);
2773
- return s;
2774
- }
2775
- };
2776
- function sanitize2(name) {
2777
- return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
2778
- }
2779
-
2780
3698
  // src/orchestrator/dry-run.ts
2781
3699
  function renderDryRun(opts) {
2782
3700
  const { config, services, cliArgs, env, proxyProvider, proxyOpts } = opts;
@@ -2947,8 +3865,8 @@ function defineConfig(config) {
2947
3865
  function readVersion() {
2948
3866
  try {
2949
3867
  const here = dirname7(fileURLToPath2(import.meta.url));
2950
- const pkgPath = join8(here, "..", "package.json");
2951
- return JSON.parse(readFileSync2(pkgPath, "utf8")).version ?? "unknown";
3868
+ const pkgPath = join10(here, "..", "package.json");
3869
+ return JSON.parse(readFileSync4(pkgPath, "utf8")).version ?? "unknown";
2952
3870
  } catch {
2953
3871
  return "unknown";
2954
3872
  }
@@ -2983,6 +3901,8 @@ async function main() {
2983
3901
  if (subcmd === "logs") process.exit(await runLogs(subArgs, subOpts));
2984
3902
  if (subcmd === "install") process.exit(await runInstall(subOpts));
2985
3903
  if (subcmd === "status") process.exit(await runStatus(subOpts));
3904
+ if (subcmd === "ctl") process.exit(await runCtl(subArgs, subOpts));
3905
+ if (subcmd === "down") process.exit(await runDown(subOpts));
2986
3906
  }
2987
3907
  let configPath;
2988
3908
  try {
@@ -3015,7 +3935,7 @@ ${formatValidationWarnings(warnings)}`);
3015
3935
  process.exit(1);
3016
3936
  }
3017
3937
  const platform = await detectPlatform();
3018
- const envFile = config.envFile ? join8(cwd, config.envFile) : join8(cwd, ".env");
3938
+ const envFile = config.envFile ? join10(cwd, config.envFile) : join10(cwd, ".env");
3019
3939
  const env = parseEnvFile(envFile, process.env);
3020
3940
  if (config.env) {
3021
3941
  for (const [k, v] of Object.entries(config.env)) {
@@ -3032,7 +3952,7 @@ ${formatValidationWarnings(warnings)}`);
3032
3952
  routes: config.proxy.routes,
3033
3953
  tls: cliArgs.proxyTls ?? config.proxy.tls ?? true,
3034
3954
  entrypoint: cliArgs.proxyEntrypoint ?? config.proxy.entrypoint ?? "websecure",
3035
- confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join8(homedir4(), ".traefik", "traefik_conf.yaml")
3955
+ confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join10(homedir5(), ".traefik", "traefik_conf.yaml")
3036
3956
  };
3037
3957
  }
3038
3958
  if (cliArgs.dryRun) {
@@ -3056,6 +3976,26 @@ ${formatValidationWarnings(warnings)}`);
3056
3976
  await logSink?.close();
3057
3977
  process.exit(code);
3058
3978
  }
3979
+ if (process.env.DEVUP_DAEMON_CHILD === "1") {
3980
+ await daemonBody({ config, services, cliArgs, platform, env, baseCwd: cwd, proxyProvider, proxyOpts });
3981
+ return;
3982
+ }
3983
+ if (subcmd === "up") {
3984
+ if (!raw.includes("-d") && !raw.includes("--detach")) {
3985
+ console.error("usage: devup up -d (use plain `devup` for the TUI)");
3986
+ process.exit(1);
3987
+ }
3988
+ process.exit(await runDetached({
3989
+ config,
3990
+ services,
3991
+ cliArgs,
3992
+ platform,
3993
+ env,
3994
+ baseCwd: cwd,
3995
+ proxyProvider,
3996
+ proxyOpts
3997
+ }));
3998
+ }
3059
3999
  const isInteractive = process.stdin.isTTY ?? false;
3060
4000
  const { waitUntilExit } = render(
3061
4001
  React7.createElement(App, {