@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.
- package/CHANGELOG.md +40 -0
- package/README.md +4 -2
- package/dist/control-plane/client.d.ts +17 -0
- package/dist/control-plane/client.d.ts.map +1 -0
- package/dist/control-plane/socket-server.d.ts +6 -0
- package/dist/control-plane/socket-server.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2653 -1713
- package/dist/index.js.map +1 -1
- package/dist/orchestrator/config-watcher.d.ts +22 -0
- package/dist/orchestrator/config-watcher.d.ts.map +1 -0
- package/dist/orchestrator/daemon.d.ts +38 -0
- package/dist/orchestrator/daemon.d.ts.map +1 -0
- package/dist/orchestrator/subcommands.d.ts +7 -0
- package/dist/orchestrator/subcommands.d.ts.map +1 -1
- package/dist/process/health-poller.d.ts +15 -0
- package/dist/process/health-poller.d.ts.map +1 -0
- package/dist/process/internals.d.ts +14 -0
- package/dist/process/internals.d.ts.map +1 -0
- package/dist/process/lifecycle.d.ts +31 -0
- package/dist/process/lifecycle.d.ts.map +1 -0
- package/dist/process/manager.d.ts +11 -13
- package/dist/process/manager.d.ts.map +1 -1
- package/dist/process/restarter.d.ts +26 -0
- package/dist/process/restarter.d.ts.map +1 -0
- package/dist/process/spawner.d.ts +38 -0
- package/dist/process/spawner.d.ts.map +1 -0
- package/dist/tui/App.d.ts.map +1 -1
- package/dist/tui/hooks/useBootSequence.d.ts +20 -0
- package/dist/tui/hooks/useBootSequence.d.ts.map +1 -0
- package/dist/tui/hooks/useContextualTips.d.ts +6 -0
- package/dist/tui/hooks/useContextualTips.d.ts.map +1 -0
- package/dist/tui/hooks/useControlPlane.d.ts +18 -0
- package/dist/tui/hooks/useControlPlane.d.ts.map +1 -0
- package/dist/tui/hooks/useHotReload.d.ts +6 -0
- package/dist/tui/hooks/useHotReload.d.ts.map +1 -0
- package/dist/tui/hooks/useLogsPause.d.ts +4 -0
- package/dist/tui/hooks/useLogsPause.d.ts.map +1 -0
- package/dist/tui/hooks/useProcessManager.d.ts +9 -0
- package/dist/tui/hooks/useProcessManager.d.ts.map +1 -1
- package/dist/tui/hooks/useTerminalSize.d.ts +4 -0
- package/dist/tui/hooks/useTerminalSize.d.ts.map +1 -0
- package/dist/utils/broadcaster.d.ts +6 -0
- package/dist/utils/broadcaster.d.ts.map +1 -0
- package/dist/utils/colors.d.ts +4 -0
- package/dist/utils/colors.d.ts.map +1 -0
- package/dist/utils/env.d.ts +5 -0
- package/dist/utils/env.d.ts.map +1 -0
- package/dist/utils/format.d.ts +3 -0
- package/dist/utils/format.d.ts.map +1 -0
- package/dist/utils/install-stamp.d.ts +6 -0
- package/dist/utils/install-stamp.d.ts.map +1 -0
- package/dist/utils/phases.d.ts +4 -0
- package/dist/utils/phases.d.ts.map +1 -0
- package/dist/utils/process-args.d.ts +8 -0
- package/dist/utils/process-args.d.ts.map +1 -0
- package/dist/utils/redact.d.ts +4 -0
- package/dist/utils/redact.d.ts.map +1 -0
- package/dist/utils/search.d.ts +17 -0
- package/dist/utils/search.d.ts.map +1 -0
- package/dist/utils/stats.d.ts +19 -0
- package/dist/utils/stats.d.ts.map +1 -0
- package/dist/utils.d.ts +10 -41
- package/dist/utils.d.ts.map +1 -1
- 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
|
|
7
|
-
import { dirname as dirname7, join as
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
return
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
return
|
|
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
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
if (s
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
return
|
|
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 (!
|
|
588
|
+
if (!existsSync4(nm)) return true;
|
|
585
589
|
try {
|
|
586
|
-
const pkgHash = createHash("md5").update(
|
|
590
|
+
const pkgHash = createHash("md5").update(readFileSync2(join2(fullCwd, "package.json"))).digest("hex");
|
|
587
591
|
const stampFile = join2(nm, ".install-stamp");
|
|
588
|
-
if (
|
|
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(
|
|
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
|
-
|
|
601
|
-
|
|
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/
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
|
|
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
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
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
|
-
|
|
726
|
-
const
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
|
790
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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 "
|
|
827
|
-
const
|
|
828
|
-
|
|
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 "
|
|
831
|
-
const
|
|
832
|
-
|
|
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(`
|
|
837
|
+
throw new Error(`unknown method: ${method}`);
|
|
836
838
|
}
|
|
837
839
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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/
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
-
|
|
957
|
-
|
|
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/
|
|
985
|
-
import {
|
|
986
|
-
import {
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
import {
|
|
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 {
|
|
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
|
|
998
|
-
import { existsSync as
|
|
913
|
+
import { spawn } from "child_process";
|
|
914
|
+
import { existsSync as existsSync7 } from "fs";
|
|
999
915
|
function installService(cwd, env, onLog) {
|
|
1000
|
-
if (!
|
|
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 =
|
|
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/
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
-
|
|
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.
|
|
1020
|
+
this.state = opts.state;
|
|
1021
|
+
this.procs = opts.procs;
|
|
1097
1022
|
this.events = opts.events;
|
|
1098
|
-
|
|
1099
|
-
|
|
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) => !
|
|
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 =
|
|
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
|
-
|
|
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((
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1142
|
+
res(true);
|
|
1222
1143
|
} else {
|
|
1223
1144
|
this.log(svc.name, `[build] \u274C exited with code ${code}`, colorIdx);
|
|
1224
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1265
|
-
|
|
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
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
1348
|
-
|
|
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/
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
|
|
1388
|
-
return
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
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
|
-
|
|
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/
|
|
1470
|
-
import {
|
|
1471
|
-
import {
|
|
1472
|
-
var
|
|
1473
|
-
function
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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
|
-
|
|
1480
|
-
|
|
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
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
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
|
-
|
|
1489
|
-
}
|
|
1512
|
+
void proc;
|
|
1513
|
+
}
|
|
1490
1514
|
}
|
|
1491
|
-
function
|
|
1492
|
-
const
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
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
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
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/
|
|
1578
|
-
import
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
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
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
}
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
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/
|
|
1640
|
-
import {
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
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
|
|
1655
|
-
const
|
|
1656
|
-
const
|
|
1657
|
-
const
|
|
1658
|
-
const
|
|
1659
|
-
const
|
|
1660
|
-
const
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
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
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
return
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
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
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
"
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
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
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
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
|
-
|
|
1776
|
-
}, [
|
|
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/
|
|
1852
|
-
import {
|
|
1853
|
-
import {
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
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/
|
|
1894
|
-
import {
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
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
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
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
|
-
|
|
3056
|
+
/* @__PURE__ */ jsx(Text, { color, children: name.padEnd(ml) }),
|
|
1942
3057
|
" ",
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
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
|
-
|
|
3096
|
+
/* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
3097
|
+
" cmd: ",
|
|
3098
|
+
st.svc.cmd,
|
|
1950
3099
|
" ",
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
" "
|
|
1955
|
-
|
|
1956
|
-
|
|
3100
|
+
resolvedArgs
|
|
3101
|
+
] }),
|
|
3102
|
+
envStr && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
3103
|
+
" env: ",
|
|
3104
|
+
envStr
|
|
3105
|
+
] })
|
|
1957
3106
|
] });
|
|
1958
3107
|
}
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
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
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
const
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
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
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
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/
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
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
|
|
3260
|
+
}, [totalLogs, states, hasSearch, hasFilter, activeTip]);
|
|
3261
|
+
return activeTip;
|
|
2120
3262
|
}
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
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
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
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
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
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
|
|
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/
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
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/
|
|
2187
|
-
import {
|
|
2188
|
-
import {
|
|
2189
|
-
import {
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
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 (
|
|
2246
|
-
|
|
3503
|
+
if (key.return) {
|
|
3504
|
+
if (names[clamped]) onSelect(names[clamped]);
|
|
2247
3505
|
return;
|
|
2248
3506
|
}
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
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
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
return { ok: true };
|
|
3511
|
+
if (key.downArrow) {
|
|
3512
|
+
setIdx((i) => Math.min(names.length - 1, i + 1));
|
|
3513
|
+
return;
|
|
2290
3514
|
}
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
return
|
|
3515
|
+
if (key.backspace || key.delete) {
|
|
3516
|
+
setQuery((q) => q.slice(0, -1));
|
|
3517
|
+
setIdx(0);
|
|
3518
|
+
return;
|
|
2295
3519
|
}
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
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
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
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
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
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
|
|
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
|
|
2395
|
-
const
|
|
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
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
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
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
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 =
|
|
2951
|
-
return JSON.parse(
|
|
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 ?
|
|
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 ??
|
|
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, {
|