@gachlab/devup 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -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 +1661 -887
- 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.d.ts +17 -0
- package/dist/process/health.d.ts.map +1 -1
- package/dist/process/spawner.d.ts.map +1 -1
- package/dist/tui/hooks/useControlPlane.d.ts +9 -1
- package/dist/tui/hooks/useControlPlane.d.ts.map +1 -1
- package/dist/tui/hooks/useHotReload.d.ts +2 -3
- package/dist/tui/hooks/useHotReload.d.ts.map +1 -1
- package/dist/tui/hooks/useProcessManager.d.ts +9 -0
- package/dist/tui/hooks/useProcessManager.d.ts.map +1 -1
- package/dist/utils/broadcaster.d.ts +6 -0
- package/dist/utils/broadcaster.d.ts.map +1 -0
- 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";
|
|
@@ -459,6 +459,23 @@ function checkPort(port, host = "127.0.0.1", timeoutMs = 2e3) {
|
|
|
459
459
|
socket.connect(port, host);
|
|
460
460
|
});
|
|
461
461
|
}
|
|
462
|
+
async function isPortBindable(port) {
|
|
463
|
+
for (const host of ["0.0.0.0", "::"]) {
|
|
464
|
+
if (!await tryBind(port, host)) return false;
|
|
465
|
+
}
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
function tryBind(port, host) {
|
|
469
|
+
return new Promise((resolve4) => {
|
|
470
|
+
const server = net.createServer();
|
|
471
|
+
server.once("error", (err) => {
|
|
472
|
+
if (err.code === "EADDRINUSE" || err.code === "EACCES") resolve4(false);
|
|
473
|
+
else resolve4(true);
|
|
474
|
+
});
|
|
475
|
+
server.once("listening", () => server.close(() => resolve4(true)));
|
|
476
|
+
server.listen(port, host);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
462
479
|
function checkHttp(port, opts = {}) {
|
|
463
480
|
const path = opts.path ?? "/";
|
|
464
481
|
const host = opts.host ?? "127.0.0.1";
|
|
@@ -668,386 +685,287 @@ var tagColors = [
|
|
|
668
685
|
"white"
|
|
669
686
|
];
|
|
670
687
|
|
|
671
|
-
// src/
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
688
|
+
// src/control-plane/client.ts
|
|
689
|
+
import { createConnection } from "net";
|
|
690
|
+
import { createInterface as createInterface2 } from "readline";
|
|
691
|
+
import { existsSync as existsSync6 } from "fs";
|
|
692
|
+
|
|
693
|
+
// src/control-plane/socket-server.ts
|
|
694
|
+
import { createServer } from "net";
|
|
695
|
+
import { createInterface } from "readline";
|
|
696
|
+
import { existsSync as existsSync5, unlinkSync, chmodSync, mkdirSync, statSync } from "fs";
|
|
697
|
+
import { dirname } from "path";
|
|
698
|
+
import { join as join3 } from "path";
|
|
699
|
+
import { homedir } from "os";
|
|
700
|
+
function defaultSocketPath(projectName) {
|
|
701
|
+
const safe = projectName.replace(/[^a-zA-Z0-9._-]+/g, "_") || "devup";
|
|
702
|
+
return join3(homedir(), ".devup", `sock-${safe}.sock`);
|
|
676
703
|
}
|
|
677
|
-
function
|
|
678
|
-
const
|
|
679
|
-
|
|
704
|
+
async function startSocketServer(projectName, ctx, opts = {}) {
|
|
705
|
+
const path = opts.path ?? defaultSocketPath(projectName);
|
|
706
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
707
|
+
if (existsSync5(path)) {
|
|
708
|
+
try {
|
|
709
|
+
const st = statSync(path);
|
|
710
|
+
if (st.isSocket()) unlinkSync(path);
|
|
711
|
+
} catch {
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
const server = createServer((socket) => handleClient(socket, ctx));
|
|
715
|
+
await new Promise((resolve4, reject) => {
|
|
716
|
+
server.once("error", reject);
|
|
717
|
+
server.listen(path, () => {
|
|
718
|
+
server.off("error", reject);
|
|
719
|
+
try {
|
|
720
|
+
chmodSync(path, 384);
|
|
721
|
+
} catch {
|
|
722
|
+
}
|
|
723
|
+
opts.onLog?.(`\u{1F50C} control plane at ${path}`);
|
|
724
|
+
resolve4();
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
return {
|
|
728
|
+
server,
|
|
729
|
+
path,
|
|
730
|
+
async close() {
|
|
731
|
+
await new Promise((resolve4) => server.close(() => resolve4()));
|
|
732
|
+
if (existsSync5(path)) {
|
|
733
|
+
try {
|
|
734
|
+
unlinkSync(path);
|
|
735
|
+
} catch {
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
};
|
|
680
740
|
}
|
|
681
|
-
function
|
|
682
|
-
|
|
741
|
+
function handleClient(socket, ctx) {
|
|
742
|
+
const rl = createInterface({ input: socket });
|
|
743
|
+
const unsubs = /* @__PURE__ */ new Set();
|
|
744
|
+
socket.on("close", () => {
|
|
745
|
+
for (const unsub of unsubs) unsub();
|
|
746
|
+
unsubs.clear();
|
|
747
|
+
});
|
|
748
|
+
rl.on("line", async (line) => {
|
|
749
|
+
if (!line.trim()) return;
|
|
750
|
+
let req;
|
|
751
|
+
try {
|
|
752
|
+
req = JSON.parse(line);
|
|
753
|
+
} catch (e) {
|
|
754
|
+
respond(socket, { error: { code: -32700, message: `parse error: ${e.message}` } });
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (typeof req.method !== "string") {
|
|
758
|
+
respond(socket, { id: req.id, error: { code: -32600, message: "method required" } });
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const params = req.params ?? {};
|
|
762
|
+
if (req.method === "logs.follow" || req.method === "status.follow") {
|
|
763
|
+
try {
|
|
764
|
+
await handleFollow(socket, req, params, ctx, unsubs);
|
|
765
|
+
} catch (e) {
|
|
766
|
+
respond(socket, { id: req.id, error: { code: -32603, message: e.message ?? String(e) } });
|
|
767
|
+
}
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
try {
|
|
771
|
+
const result = await dispatch(req.method, params, ctx);
|
|
772
|
+
respond(socket, { id: req.id, result });
|
|
773
|
+
} catch (e) {
|
|
774
|
+
respond(socket, { id: req.id, error: { code: -32603, message: e.message ?? String(e) } });
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
socket.on("error", () => {
|
|
778
|
+
});
|
|
683
779
|
}
|
|
684
|
-
async function
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
780
|
+
async function handleFollow(socket, req, params, ctx, unsubs) {
|
|
781
|
+
if (req.method === "logs.follow") {
|
|
782
|
+
const rawSvc = params["svc"] ?? params["service"];
|
|
783
|
+
const svcName = rawSvc != null ? stringOrThrow(rawSvc, "svc") : null;
|
|
784
|
+
const tail = Math.max(0, Math.min(1e3, Number(params["tail"] ?? 50)));
|
|
785
|
+
respond(socket, { id: req.id, result: { ok: true } });
|
|
786
|
+
if (svcName) {
|
|
787
|
+
const lines = await ctx.tailLogs(svcName, tail);
|
|
788
|
+
for (const l of lines) {
|
|
789
|
+
respond(socket, { id: req.id, event: "log", data: l });
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
const unsub = ctx.watchLogs(svcName, (svc, line) => {
|
|
793
|
+
respond(socket, { id: req.id, event: "log", data: line, svc });
|
|
794
|
+
});
|
|
795
|
+
unsubs.add(unsub);
|
|
796
|
+
} else {
|
|
797
|
+
respond(socket, { id: req.id, result: { ok: true } });
|
|
798
|
+
const snapshot = [];
|
|
799
|
+
for (const [name, st] of ctx.states()) {
|
|
800
|
+
snapshot.push(serializeState(name, st));
|
|
801
|
+
}
|
|
802
|
+
if (snapshot.length) {
|
|
803
|
+
respond(socket, { id: req.id, event: "status", data: snapshot });
|
|
804
|
+
}
|
|
805
|
+
const unsub = ctx.watchStatus((name, state) => {
|
|
806
|
+
respond(socket, { id: req.id, event: "status", data: [serializeState(name, state)] });
|
|
807
|
+
});
|
|
808
|
+
unsubs.add(unsub);
|
|
691
809
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
810
|
+
}
|
|
811
|
+
function serializeState(name, st) {
|
|
812
|
+
return {
|
|
813
|
+
name,
|
|
814
|
+
status: st.status,
|
|
815
|
+
health: st.health,
|
|
816
|
+
port: st.svc.port,
|
|
817
|
+
type: st.svc.type,
|
|
818
|
+
errors: st.errors,
|
|
819
|
+
restarts: st.restarts,
|
|
820
|
+
pid: st.pid,
|
|
821
|
+
startedAt: st.startedAt
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
function respond(socket, payload) {
|
|
825
|
+
if (socket.writable) socket.write(JSON.stringify(payload) + "\n");
|
|
826
|
+
}
|
|
827
|
+
async function dispatch(method, params, ctx) {
|
|
828
|
+
switch (method) {
|
|
829
|
+
case "status": {
|
|
830
|
+
const out = [];
|
|
831
|
+
for (const [name, st] of ctx.states()) {
|
|
832
|
+
out.push(serializeState(name, st));
|
|
833
|
+
}
|
|
834
|
+
return { services: out };
|
|
835
|
+
}
|
|
836
|
+
case "restart": {
|
|
837
|
+
const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
|
|
838
|
+
await ctx.restart(svc);
|
|
839
|
+
return { ok: true };
|
|
840
|
+
}
|
|
841
|
+
case "stop": {
|
|
842
|
+
const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
|
|
843
|
+
ctx.stop(svc);
|
|
844
|
+
return { ok: true };
|
|
845
|
+
}
|
|
846
|
+
case "logs.tail": {
|
|
847
|
+
const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
|
|
848
|
+
const lines = Math.max(1, Math.min(1e4, Number(params["lines"] ?? 100)));
|
|
849
|
+
return { lines: await ctx.tailLogs(svc, lines) };
|
|
850
|
+
}
|
|
851
|
+
case "ping":
|
|
852
|
+
return { ok: true, ts: Date.now() };
|
|
853
|
+
default:
|
|
854
|
+
throw new Error(`unknown method: ${method}`);
|
|
696
855
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
856
|
+
}
|
|
857
|
+
function stringOrThrow(v, paramName) {
|
|
858
|
+
if (typeof v !== "string" || !v.trim()) {
|
|
859
|
+
throw new Error(`param "${paramName}" must be a non-empty string`);
|
|
701
860
|
}
|
|
702
|
-
|
|
703
|
-
if (!follow) return 0;
|
|
704
|
-
return await followFile(file, out, statSync(file).size);
|
|
861
|
+
return v;
|
|
705
862
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
rl.on("close", () => resolve4());
|
|
711
|
-
rl.on("error", reject);
|
|
712
|
-
});
|
|
863
|
+
|
|
864
|
+
// src/control-plane/client.ts
|
|
865
|
+
function resolveSocket(projectName, overridePath) {
|
|
866
|
+
return overridePath ?? defaultSocketPath(projectName);
|
|
713
867
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
868
|
+
function assertSocketExists(socketPath, projectName) {
|
|
869
|
+
if (!existsSync6(socketPath)) {
|
|
870
|
+
throw new Error(
|
|
871
|
+
`devup is not running for project "${projectName}".
|
|
872
|
+
Start it with \`devup\` first.`
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
function sendRpc(socketPath, method, params = {}) {
|
|
877
|
+
return new Promise((resolve4, reject) => {
|
|
878
|
+
const c = createConnection(socketPath);
|
|
879
|
+
c.on("error", reject);
|
|
880
|
+
const rl = createInterface2({ input: c });
|
|
881
|
+
rl.once("line", (l) => {
|
|
882
|
+
c.end();
|
|
883
|
+
try {
|
|
884
|
+
const msg = JSON.parse(l);
|
|
885
|
+
if (msg.error) reject(new Error(msg.error.message ?? String(msg.error)));
|
|
886
|
+
else resolve4(msg.result);
|
|
887
|
+
} catch (e) {
|
|
888
|
+
reject(e);
|
|
731
889
|
}
|
|
732
|
-
};
|
|
733
|
-
watchFile(file, { interval: 500 }, () => {
|
|
734
|
-
void tick();
|
|
735
|
-
});
|
|
736
|
-
process.once("SIGINT", () => {
|
|
737
|
-
unwatchFile(file);
|
|
738
|
-
resolve4(0);
|
|
739
890
|
});
|
|
891
|
+
c.write(JSON.stringify({ id: 1, method, params }) + "\n");
|
|
740
892
|
});
|
|
741
893
|
}
|
|
742
|
-
|
|
743
|
-
const
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
failed.push(item.name);
|
|
759
|
-
out(`\u2717 ${item.name}`);
|
|
760
|
-
}
|
|
761
|
-
if (queue.length === 0 && inFlight === 0) resolve4();
|
|
762
|
-
else pump();
|
|
763
|
-
});
|
|
894
|
+
function openStream(socketPath, method, params, onFrame, onError) {
|
|
895
|
+
const c = createConnection(socketPath);
|
|
896
|
+
const rl = createInterface2({ input: c });
|
|
897
|
+
let ackDone = false;
|
|
898
|
+
c.on("error", (err) => onError?.(err));
|
|
899
|
+
c.write(JSON.stringify({ id: 1, method, params }) + "\n");
|
|
900
|
+
rl.on("line", (l) => {
|
|
901
|
+
try {
|
|
902
|
+
const msg = JSON.parse(l);
|
|
903
|
+
if (!ackDone) {
|
|
904
|
+
ackDone = true;
|
|
905
|
+
if (msg.error) {
|
|
906
|
+
onError?.(new Error(msg.error.message ?? String(msg.error)));
|
|
907
|
+
c.destroy();
|
|
908
|
+
}
|
|
909
|
+
return;
|
|
764
910
|
}
|
|
765
|
-
|
|
766
|
-
|
|
911
|
+
if (msg.event) onFrame(msg);
|
|
912
|
+
} catch {
|
|
913
|
+
}
|
|
767
914
|
});
|
|
768
|
-
|
|
769
|
-
out(`
|
|
770
|
-
failed: ${failed.join(", ")}`);
|
|
771
|
-
return 1;
|
|
772
|
-
}
|
|
773
|
-
out(`
|
|
774
|
-
${items.length} services up to date`);
|
|
775
|
-
return 0;
|
|
915
|
+
return () => c.destroy();
|
|
776
916
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
917
|
+
|
|
918
|
+
// src/orchestrator/daemon.ts
|
|
919
|
+
import { spawn as spawn4 } from "child_process";
|
|
920
|
+
import { writeFileSync as writeFileSync2, readFileSync as readFileSync3, existsSync as existsSync10, unlinkSync as unlinkSync2, mkdirSync as mkdirSync3, createReadStream } from "fs";
|
|
921
|
+
import { join as join8 } from "path";
|
|
922
|
+
import { homedir as homedir3 } from "os";
|
|
923
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
924
|
+
import { createInterface as createInterface3 } from "readline";
|
|
925
|
+
|
|
926
|
+
// src/process/manager.ts
|
|
927
|
+
import { join as join5 } from "path";
|
|
928
|
+
|
|
929
|
+
// src/process/installer.ts
|
|
930
|
+
import { spawn } from "child_process";
|
|
931
|
+
import { existsSync as existsSync7 } from "fs";
|
|
932
|
+
function installService(cwd, env, onLog) {
|
|
933
|
+
if (!existsSync7(cwd)) {
|
|
934
|
+
onLog?.(`\u26A0 directory not found: ${cwd}`);
|
|
935
|
+
return Promise.resolve(false);
|
|
936
|
+
}
|
|
937
|
+
if (!needsInstall(cwd)) {
|
|
938
|
+
onLog?.("\u2705 dependencies up to date");
|
|
939
|
+
return Promise.resolve(true);
|
|
940
|
+
}
|
|
941
|
+
onLog?.("\u{1F4E6} npm install...");
|
|
780
942
|
return new Promise((resolve4) => {
|
|
781
943
|
const command = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
782
944
|
const proc = spawn(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
|
|
945
|
+
let stderr = "";
|
|
946
|
+
proc.stderr?.on("data", (d) => {
|
|
947
|
+
stderr += d.toString();
|
|
948
|
+
});
|
|
783
949
|
proc.on("close", (code) => {
|
|
784
|
-
if (code
|
|
950
|
+
if (code !== 0) {
|
|
951
|
+
onLog?.(`\u26A0 npm install failed: ${stderr.split("\n")[0]}`);
|
|
952
|
+
resolve4(false);
|
|
953
|
+
} else {
|
|
785
954
|
writeInstallStamp(cwd);
|
|
955
|
+
onLog?.("\u2705 dependencies ready");
|
|
786
956
|
resolve4(true);
|
|
787
|
-
}
|
|
788
|
-
});
|
|
789
|
-
proc.on("error", () =>
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
async function runStatus(opts) {
|
|
793
|
-
const out = opts.out ?? ((l) => console.log(l));
|
|
794
|
-
out(`${opts.config.icon ?? "\u{1F4E6}"} ${opts.config.name} \u2014 ${opts.config.services.length} services`);
|
|
795
|
-
out("");
|
|
796
|
-
const maxLen = Math.max(...opts.config.services.map((s) => s.name.length), 12);
|
|
797
|
-
out(`${"Service".padEnd(maxLen)} ${"Port".padStart(5)} ${"Type".padEnd(4)} Health`);
|
|
798
|
-
out("-".repeat(maxLen + 24));
|
|
799
|
-
for (const svc of opts.config.services) {
|
|
800
|
-
const up = await checkHealth(svc.port, svc.healthCheck);
|
|
801
|
-
const health = up ? "\u2713 up" : "\u2717 down";
|
|
802
|
-
out(`${svc.name.padEnd(maxLen)} ${String(svc.port).padStart(5)} ${svc.type.padEnd(4)} ${health}`);
|
|
803
|
-
}
|
|
804
|
-
return 0;
|
|
805
|
-
}
|
|
806
|
-
function runHelp(argv, opts = {}) {
|
|
807
|
-
const out = opts.out ?? ((l) => console.log(l));
|
|
808
|
-
const sub = argv[0];
|
|
809
|
-
if (sub === "logs") {
|
|
810
|
-
out("Usage: devup logs <service> [--follow|-f]");
|
|
811
|
-
out(" Print the persisted log file for a service (works without devup running).");
|
|
812
|
-
out(" --follow tails new lines as they are appended.");
|
|
813
|
-
return 0;
|
|
814
|
-
}
|
|
815
|
-
if (sub === "install") {
|
|
816
|
-
out("Usage: devup install");
|
|
817
|
-
out(" Run `npm install` across every service.cwd in parallel (max 4 at a time).");
|
|
818
|
-
out(" Skips services whose .install-stamp matches package.json hash.");
|
|
819
|
-
return 0;
|
|
820
|
-
}
|
|
821
|
-
if (sub === "status") {
|
|
822
|
-
out("Usage: devup status");
|
|
823
|
-
out(" For each service, probes its health-check endpoint and prints up/down.");
|
|
824
|
-
return 0;
|
|
825
|
-
}
|
|
826
|
-
out("Subcommands:");
|
|
827
|
-
out(" devup logs <service> [--follow] Read the persisted log file");
|
|
828
|
-
out(" devup install Concurrent npm install across services");
|
|
829
|
-
out(" devup status Health check every service in config");
|
|
830
|
-
out(" devup help [<subcommand>] Show detailed help for a subcommand");
|
|
831
|
-
out("");
|
|
832
|
-
out("No subcommand \u2192 launch the interactive TUI.");
|
|
833
|
-
return 0;
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
// src/platform/detect.ts
|
|
837
|
-
async function detectPlatform() {
|
|
838
|
-
switch (process.platform) {
|
|
839
|
-
case "linux": {
|
|
840
|
-
const { LinuxPlatform } = await import("./linux-OQ3Q4Z2Z.js");
|
|
841
|
-
return new LinuxPlatform();
|
|
842
|
-
}
|
|
843
|
-
case "darwin": {
|
|
844
|
-
const { DarwinPlatform } = await import("./darwin-3KJ3IEXN.js");
|
|
845
|
-
return new DarwinPlatform();
|
|
846
|
-
}
|
|
847
|
-
case "win32": {
|
|
848
|
-
const { Win32Platform } = await import("./win32-3X2OLSI6.js");
|
|
849
|
-
return new Win32Platform();
|
|
850
|
-
}
|
|
851
|
-
default:
|
|
852
|
-
throw new Error(`Unsupported platform: ${process.platform}`);
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
// src/proxy-config/traefik.ts
|
|
857
|
-
import { existsSync as existsSync6, mkdirSync, writeFileSync as writeFileSync2 } from "fs";
|
|
858
|
-
import { dirname as dirname2 } from "path";
|
|
859
|
-
var EMPTY_CONFIG = "http:\n routers: {}\n services: {}\n";
|
|
860
|
-
var TraefikProvider = class {
|
|
861
|
-
name = "traefik";
|
|
862
|
-
generate(services, opts) {
|
|
863
|
-
const routers = [];
|
|
864
|
-
const svcs = [];
|
|
865
|
-
for (const [name, st] of services) {
|
|
866
|
-
if (st.health !== "up") continue;
|
|
867
|
-
const sub = opts.routes[name];
|
|
868
|
-
if (sub === void 0) continue;
|
|
869
|
-
const rule = sub ? `Host(\`${sub}.${opts.domain}\`)` : `Host(\`${opts.domain}\`)`;
|
|
870
|
-
const safe = name.replace(/[^a-z0-9-]/g, "-");
|
|
871
|
-
const port = st.realPort ?? st.port;
|
|
872
|
-
let router = ` ${safe}:
|
|
873
|
-
rule: "${rule}"
|
|
874
|
-
service: ${safe}
|
|
875
|
-
entryPoints:
|
|
876
|
-
- ${opts.entrypoint}`;
|
|
877
|
-
if (opts.tls) router += `
|
|
878
|
-
tls:
|
|
879
|
-
certResolver: le`;
|
|
880
|
-
routers.push(router);
|
|
881
|
-
svcs.push(` ${safe}:
|
|
882
|
-
loadBalancer:
|
|
883
|
-
servers:
|
|
884
|
-
- url: "http://${opts.host}:${port}"`);
|
|
885
|
-
}
|
|
886
|
-
if (!routers.length) return EMPTY_CONFIG;
|
|
887
|
-
return `http:
|
|
888
|
-
routers:
|
|
889
|
-
${routers.join("\n")}
|
|
890
|
-
services:
|
|
891
|
-
${svcs.join("\n")}
|
|
892
|
-
`;
|
|
893
|
-
}
|
|
894
|
-
write(content, opts) {
|
|
895
|
-
const dir = dirname2(opts.confPath);
|
|
896
|
-
if (!existsSync6(dir)) mkdirSync(dir, { recursive: true });
|
|
897
|
-
writeFileSync2(opts.confPath, content);
|
|
898
|
-
}
|
|
899
|
-
clear(opts) {
|
|
900
|
-
this.write(EMPTY_CONFIG, opts);
|
|
901
|
-
}
|
|
902
|
-
};
|
|
903
|
-
|
|
904
|
-
// src/proxy-config/nginx.ts
|
|
905
|
-
import { existsSync as existsSync7, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
906
|
-
import { dirname as dirname3 } from "path";
|
|
907
|
-
var EMPTY_CONFIG2 = "# devup: no healthy services\n";
|
|
908
|
-
var NginxProvider = class {
|
|
909
|
-
name = "nginx";
|
|
910
|
-
generate(services, opts) {
|
|
911
|
-
const blocks = [];
|
|
912
|
-
for (const [name, st] of services) {
|
|
913
|
-
if (st.health !== "up") continue;
|
|
914
|
-
const sub = opts.routes[name];
|
|
915
|
-
if (sub === void 0) continue;
|
|
916
|
-
const serverName = sub ? `${sub}.${opts.domain}` : opts.domain;
|
|
917
|
-
const port = st.realPort ?? st.port;
|
|
918
|
-
const listen = opts.tls ? "443 ssl" : "80";
|
|
919
|
-
const tlsBlock = opts.tls ? ` ssl_certificate /etc/nginx/certs/${serverName}.crt;
|
|
920
|
-
ssl_certificate_key /etc/nginx/certs/${serverName}.key;
|
|
921
|
-
` : "";
|
|
922
|
-
blocks.push(
|
|
923
|
-
`server {
|
|
924
|
-
listen ${listen};
|
|
925
|
-
server_name ${serverName};
|
|
926
|
-
` + tlsBlock + ` location / {
|
|
927
|
-
proxy_pass http://${opts.host}:${port};
|
|
928
|
-
proxy_set_header Host $host;
|
|
929
|
-
proxy_set_header X-Real-IP $remote_addr;
|
|
930
|
-
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
931
|
-
proxy_set_header X-Forwarded-Proto $scheme;
|
|
932
|
-
proxy_http_version 1.1;
|
|
933
|
-
proxy_set_header Upgrade $http_upgrade;
|
|
934
|
-
proxy_set_header Connection "upgrade";
|
|
935
|
-
}
|
|
936
|
-
}`
|
|
937
|
-
);
|
|
938
|
-
}
|
|
939
|
-
if (!blocks.length) return EMPTY_CONFIG2;
|
|
940
|
-
return blocks.join("\n\n") + "\n";
|
|
941
|
-
}
|
|
942
|
-
write(content, opts) {
|
|
943
|
-
const dir = dirname3(opts.confPath);
|
|
944
|
-
if (!existsSync7(dir)) mkdirSync2(dir, { recursive: true });
|
|
945
|
-
writeFileSync3(opts.confPath, content);
|
|
946
|
-
}
|
|
947
|
-
clear(opts) {
|
|
948
|
-
this.write(EMPTY_CONFIG2, opts);
|
|
949
|
-
}
|
|
950
|
-
};
|
|
951
|
-
|
|
952
|
-
// src/proxy-config/caddy.ts
|
|
953
|
-
import { existsSync as existsSync8, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
|
|
954
|
-
import { dirname as dirname4 } from "path";
|
|
955
|
-
var EMPTY_CONFIG3 = "# devup: no healthy services\n";
|
|
956
|
-
var CaddyProvider = class {
|
|
957
|
-
name = "caddy";
|
|
958
|
-
generate(services, opts) {
|
|
959
|
-
const blocks = [];
|
|
960
|
-
for (const [name, st] of services) {
|
|
961
|
-
if (st.health !== "up") continue;
|
|
962
|
-
const sub = opts.routes[name];
|
|
963
|
-
if (sub === void 0) continue;
|
|
964
|
-
const host = sub ? `${sub}.${opts.domain}` : opts.domain;
|
|
965
|
-
const port = st.realPort ?? st.port;
|
|
966
|
-
const siteAddr = opts.tls ? host : `http://${host}`;
|
|
967
|
-
blocks.push(
|
|
968
|
-
`${siteAddr} {
|
|
969
|
-
reverse_proxy ${opts.host}:${port}
|
|
970
|
-
}`
|
|
971
|
-
);
|
|
972
|
-
}
|
|
973
|
-
if (!blocks.length) return EMPTY_CONFIG3;
|
|
974
|
-
return blocks.join("\n\n") + "\n";
|
|
975
|
-
}
|
|
976
|
-
write(content, opts) {
|
|
977
|
-
const dir = dirname4(opts.confPath);
|
|
978
|
-
if (!existsSync8(dir)) mkdirSync3(dir, { recursive: true });
|
|
979
|
-
writeFileSync4(opts.confPath, content);
|
|
980
|
-
}
|
|
981
|
-
clear(opts) {
|
|
982
|
-
this.write(EMPTY_CONFIG3, opts);
|
|
983
|
-
}
|
|
984
|
-
};
|
|
985
|
-
|
|
986
|
-
// src/proxy-config/detect.ts
|
|
987
|
-
var providers = {
|
|
988
|
-
traefik: () => new TraefikProvider(),
|
|
989
|
-
nginx: () => new NginxProvider(),
|
|
990
|
-
caddy: () => new CaddyProvider()
|
|
991
|
-
};
|
|
992
|
-
function detectProxyProvider(name) {
|
|
993
|
-
const factory = providers[name];
|
|
994
|
-
if (!factory) {
|
|
995
|
-
const available = Object.keys(providers).join(", ");
|
|
996
|
-
throw new Error(`Unknown proxy provider: "${name}". Available: ${available}`);
|
|
997
|
-
}
|
|
998
|
-
return factory();
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
// src/tui/App.tsx
|
|
1002
|
-
import { useCallback as useCallback3, useRef as useRef5 } from "react";
|
|
1003
|
-
import { Box as Box6, Text as Text6 } from "ink";
|
|
1004
|
-
|
|
1005
|
-
// src/tui/hooks/useProcessManager.ts
|
|
1006
|
-
import { useState, useEffect, useRef, useCallback } from "react";
|
|
1007
|
-
|
|
1008
|
-
// src/process/manager.ts
|
|
1009
|
-
import { join as join5 } from "path";
|
|
1010
|
-
|
|
1011
|
-
// src/process/installer.ts
|
|
1012
|
-
import { spawn as spawn2 } from "child_process";
|
|
1013
|
-
import { existsSync as existsSync9 } from "fs";
|
|
1014
|
-
function installService(cwd, env, onLog) {
|
|
1015
|
-
if (!existsSync9(cwd)) {
|
|
1016
|
-
onLog?.(`\u26A0 directory not found: ${cwd}`);
|
|
1017
|
-
return Promise.resolve(false);
|
|
1018
|
-
}
|
|
1019
|
-
if (!needsInstall(cwd)) {
|
|
1020
|
-
onLog?.("\u2705 dependencies up to date");
|
|
1021
|
-
return Promise.resolve(true);
|
|
1022
|
-
}
|
|
1023
|
-
onLog?.("\u{1F4E6} npm install...");
|
|
1024
|
-
return new Promise((resolve4) => {
|
|
1025
|
-
const command = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
1026
|
-
const proc = spawn2(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
|
|
1027
|
-
let stderr = "";
|
|
1028
|
-
proc.stderr?.on("data", (d) => {
|
|
1029
|
-
stderr += d.toString();
|
|
1030
|
-
});
|
|
1031
|
-
proc.on("close", (code) => {
|
|
1032
|
-
if (code !== 0) {
|
|
1033
|
-
onLog?.(`\u26A0 npm install failed: ${stderr.split("\n")[0]}`);
|
|
1034
|
-
resolve4(false);
|
|
1035
|
-
} else {
|
|
1036
|
-
writeInstallStamp(cwd);
|
|
1037
|
-
onLog?.("\u2705 dependencies ready");
|
|
1038
|
-
resolve4(true);
|
|
1039
|
-
}
|
|
1040
|
-
});
|
|
1041
|
-
proc.on("error", (err) => {
|
|
1042
|
-
onLog?.(`\u26A0 spawn error: ${err.message}`);
|
|
1043
|
-
resolve4(false);
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
proc.on("error", (err) => {
|
|
960
|
+
onLog?.(`\u26A0 spawn error: ${err.message}`);
|
|
961
|
+
resolve4(false);
|
|
1044
962
|
});
|
|
1045
963
|
});
|
|
1046
964
|
}
|
|
1047
965
|
|
|
1048
966
|
// src/process/spawner.ts
|
|
1049
|
-
import { spawn as
|
|
1050
|
-
import { existsSync as
|
|
967
|
+
import { spawn as spawn2 } from "child_process";
|
|
968
|
+
import { existsSync as existsSync8 } from "fs";
|
|
1051
969
|
import { join as join4, resolve as resolve3 } from "path";
|
|
1052
970
|
|
|
1053
971
|
// src/process/internals.ts
|
|
@@ -1125,9 +1043,10 @@ var Spawner = class {
|
|
|
1125
1043
|
async start(svc, colorIdx, isRestart = false) {
|
|
1126
1044
|
const cwd = join4(this.baseCwd, svc.cwd);
|
|
1127
1045
|
if (svc.type === "api") {
|
|
1128
|
-
const
|
|
1129
|
-
if (
|
|
1046
|
+
const bindable = await isPortBindable(svc.port);
|
|
1047
|
+
if (!bindable && !isRestart) {
|
|
1130
1048
|
this.log(svc.name, `\u26A0 port ${svc.port} already in use \u2014 skipping`, colorIdx);
|
|
1049
|
+
this.recordCrashedState(svc, colorIdx);
|
|
1131
1050
|
return;
|
|
1132
1051
|
}
|
|
1133
1052
|
}
|
|
@@ -1139,14 +1058,14 @@ var Spawner = class {
|
|
|
1139
1058
|
}
|
|
1140
1059
|
}
|
|
1141
1060
|
const args = buildProcessArgs(svc);
|
|
1142
|
-
const missingWatchPaths = extractWatchPaths(args).filter((p) => !
|
|
1061
|
+
const missingWatchPaths = extractWatchPaths(args).filter((p) => !existsSync8(resolve3(cwd, p)));
|
|
1143
1062
|
if (missingWatchPaths.length) {
|
|
1144
1063
|
this.log(svc.name, `\u26A0 missing watch paths: ${missingWatchPaths.join(", ")}`, colorIdx);
|
|
1145
1064
|
this.recordCrashedState(svc, colorIdx);
|
|
1146
1065
|
return;
|
|
1147
1066
|
}
|
|
1148
1067
|
const env = buildProcessEnv(svc, this.env);
|
|
1149
|
-
const proc =
|
|
1068
|
+
const proc = spawn2(svc.cmd, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
1150
1069
|
const prev = this.state.get(svc.name);
|
|
1151
1070
|
const state = {
|
|
1152
1071
|
svc,
|
|
@@ -1224,7 +1143,7 @@ var Spawner = class {
|
|
|
1224
1143
|
const shell = isWin ? "cmd.exe" : "sh";
|
|
1225
1144
|
const shellFlag = isWin ? "/c" : "-c";
|
|
1226
1145
|
const env = buildProcessEnv(svc, this.env);
|
|
1227
|
-
const child =
|
|
1146
|
+
const child = spawn2(shell, [shellFlag, svc.preBuild], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
1228
1147
|
const outBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
|
|
1229
1148
|
const errBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
|
|
1230
1149
|
child.stdout?.on("data", (d) => outBuf.push(d));
|
|
@@ -1251,7 +1170,7 @@ var Spawner = class {
|
|
|
1251
1170
|
const isWin = process.platform === "win32";
|
|
1252
1171
|
const shell = isWin ? "cmd.exe" : "sh";
|
|
1253
1172
|
const shellFlag = isWin ? "/c" : "-c";
|
|
1254
|
-
const child =
|
|
1173
|
+
const child = spawn2(shell, [shellFlag, svc.watchBuild], {
|
|
1255
1174
|
cwd,
|
|
1256
1175
|
env,
|
|
1257
1176
|
detached: true,
|
|
@@ -1487,25 +1406,1317 @@ var ProcessManager = class {
|
|
|
1487
1406
|
}
|
|
1488
1407
|
};
|
|
1489
1408
|
|
|
1490
|
-
// src/
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1409
|
+
// src/process/log-sink.ts
|
|
1410
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync2, renameSync, createWriteStream } from "fs";
|
|
1411
|
+
import { join as join6, dirname as dirname2 } from "path";
|
|
1412
|
+
import { homedir as homedir2 } from "os";
|
|
1413
|
+
var LogSink = class {
|
|
1414
|
+
dir;
|
|
1415
|
+
rotateOnStart;
|
|
1416
|
+
streams = /* @__PURE__ */ new Map();
|
|
1417
|
+
seen = /* @__PURE__ */ new Set();
|
|
1418
|
+
constructor(opts) {
|
|
1419
|
+
const root = opts.rootDir ?? join6(homedir2(), ".devup", "logs");
|
|
1420
|
+
this.dir = join6(root, sanitize(opts.projectName));
|
|
1421
|
+
this.rotateOnStart = opts.rotateOnStart ?? true;
|
|
1422
|
+
mkdirSync2(this.dir, { recursive: true });
|
|
1423
|
+
}
|
|
1424
|
+
/** Returns the file path for a service log (useful for tests / UI). */
|
|
1425
|
+
pathFor(svcName) {
|
|
1426
|
+
return join6(this.dir, `${sanitize(svcName)}.log`);
|
|
1427
|
+
}
|
|
1428
|
+
write(svcName, line) {
|
|
1429
|
+
const stream = this.streamFor(svcName);
|
|
1430
|
+
stream.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
1431
|
+
`);
|
|
1432
|
+
}
|
|
1433
|
+
async close() {
|
|
1434
|
+
const closes = [...this.streams.values()].map(
|
|
1435
|
+
(s) => new Promise((r) => s.end(() => r()))
|
|
1436
|
+
);
|
|
1437
|
+
this.streams.clear();
|
|
1438
|
+
this.seen.clear();
|
|
1439
|
+
await Promise.all(closes);
|
|
1440
|
+
}
|
|
1441
|
+
streamFor(svcName) {
|
|
1442
|
+
let s = this.streams.get(svcName);
|
|
1443
|
+
if (s) return s;
|
|
1444
|
+
const file = this.pathFor(svcName);
|
|
1445
|
+
if (this.rotateOnStart && !this.seen.has(svcName) && existsSync9(file)) {
|
|
1446
|
+
try {
|
|
1447
|
+
mkdirSync2(dirname2(file), { recursive: true });
|
|
1448
|
+
renameSync(file, file + ".prev");
|
|
1449
|
+
} catch {
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
this.seen.add(svcName);
|
|
1453
|
+
s = createWriteStream(file, { flags: "a" });
|
|
1454
|
+
s.on("error", () => {
|
|
1455
|
+
});
|
|
1456
|
+
this.streams.set(svcName, s);
|
|
1457
|
+
return s;
|
|
1458
|
+
}
|
|
1459
|
+
};
|
|
1460
|
+
function sanitize(name) {
|
|
1461
|
+
return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// src/utils/broadcaster.ts
|
|
1465
|
+
var Broadcaster = class {
|
|
1466
|
+
subs = /* @__PURE__ */ new Set();
|
|
1467
|
+
subscribe(fn) {
|
|
1468
|
+
this.subs.add(fn);
|
|
1469
|
+
return () => this.subs.delete(fn);
|
|
1470
|
+
}
|
|
1471
|
+
emit(v) {
|
|
1472
|
+
for (const fn of this.subs) {
|
|
1473
|
+
try {
|
|
1474
|
+
fn(v);
|
|
1475
|
+
} catch {
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
};
|
|
1480
|
+
|
|
1481
|
+
// src/process/external.ts
|
|
1482
|
+
import { spawn as spawn3 } from "child_process";
|
|
1483
|
+
import { join as join7 } from "path";
|
|
1484
|
+
var DEFAULT_START_TIMEOUT_S = 60;
|
|
1485
|
+
async function startExternals(externals, opts) {
|
|
1486
|
+
const procs = [];
|
|
1487
|
+
const failed = [];
|
|
1488
|
+
for (const svc of externals) {
|
|
1489
|
+
const proc = spawnExternal(svc, opts);
|
|
1490
|
+
procs.push({ svc, proc, pid: proc.pid ?? null });
|
|
1491
|
+
if (!svc.healthCheck) {
|
|
1492
|
+
opts.onLog?.(svc.name, "\u2705 started (no healthCheck)");
|
|
1493
|
+
continue;
|
|
1494
|
+
}
|
|
1495
|
+
if (svc.healthCheck.type === "tcp" && !svc.port) {
|
|
1496
|
+
opts.onLog?.(svc.name, "\u26A0 tcp healthCheck requires `port` \u2014 skipping wait");
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
1499
|
+
const timeoutMs = (svc.startTimeout ?? DEFAULT_START_TIMEOUT_S) * 1e3;
|
|
1500
|
+
const ok = await waitHealthy(svc, timeoutMs);
|
|
1501
|
+
if (ok) {
|
|
1502
|
+
opts.onLog?.(svc.name, "\u2705 healthy");
|
|
1503
|
+
} else {
|
|
1504
|
+
opts.onLog?.(svc.name, `\u274C never became healthy (timeout ${timeoutMs / 1e3}s)`);
|
|
1505
|
+
failed.push(svc.name);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
return { procs, allHealthy: failed.length === 0, failed };
|
|
1509
|
+
}
|
|
1510
|
+
async function stopExternals(procs, platform, opts = {}) {
|
|
1511
|
+
for (const { svc, proc, pid } of procs) {
|
|
1512
|
+
try {
|
|
1513
|
+
if (pid) platform.killTree(pid);
|
|
1514
|
+
if (svc.stopCmd) {
|
|
1515
|
+
opts.onLog?.(svc.name, `\u{1F9F9} ${svc.stopCmd}`);
|
|
1516
|
+
await new Promise((resolve4) => {
|
|
1517
|
+
const isWin = process.platform === "win32";
|
|
1518
|
+
const shell = isWin ? "cmd.exe" : "sh";
|
|
1519
|
+
const flag = isWin ? "/c" : "-c";
|
|
1520
|
+
const cwd = svc.cwd ? join7(opts.baseCwd, svc.cwd) : opts.baseCwd;
|
|
1521
|
+
const env = { ...opts.env, ...svc.extraEnv ?? {} };
|
|
1522
|
+
const child = spawn3(shell, [flag, svc.stopCmd], { cwd, env, stdio: "ignore" });
|
|
1523
|
+
child.on("close", () => resolve4());
|
|
1524
|
+
child.on("error", () => resolve4());
|
|
1525
|
+
setTimeout(() => resolve4(), 1e4);
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
} catch {
|
|
1529
|
+
}
|
|
1530
|
+
void proc;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
function spawnExternal(svc, opts) {
|
|
1534
|
+
const isWin = process.platform === "win32";
|
|
1535
|
+
const shell = isWin ? "cmd.exe" : "sh";
|
|
1536
|
+
const flag = isWin ? "/c" : "-c";
|
|
1537
|
+
const cwd = svc.cwd ? join7(opts.baseCwd, svc.cwd) : opts.baseCwd;
|
|
1538
|
+
const env = { ...opts.env, ...svc.extraEnv ?? {} };
|
|
1539
|
+
opts.onLog?.(svc.name, `\u{1F680} ${svc.cmd}`);
|
|
1540
|
+
const child = spawn3(shell, [flag, svc.cmd], {
|
|
1541
|
+
cwd,
|
|
1542
|
+
env,
|
|
1543
|
+
detached: true,
|
|
1544
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1545
|
+
});
|
|
1546
|
+
child.stdout?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
|
|
1547
|
+
child.stderr?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
|
|
1548
|
+
child.on("error", (err) => opts.onLog?.(svc.name, `\u274C spawn error: ${err.message}`));
|
|
1549
|
+
return child;
|
|
1550
|
+
}
|
|
1551
|
+
async function waitHealthy(svc, timeoutMs) {
|
|
1552
|
+
const deadline = Date.now() + timeoutMs;
|
|
1553
|
+
const port = svc.port;
|
|
1554
|
+
while (Date.now() < deadline) {
|
|
1555
|
+
if (await checkHealth(port, svc.healthCheck)) return true;
|
|
1556
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1557
|
+
}
|
|
1558
|
+
return false;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// src/lazy/proxy.ts
|
|
1562
|
+
import net2 from "net";
|
|
1563
|
+
function createLazyProxy(opts) {
|
|
1564
|
+
const { listenPort, targetPort, timeoutMin, onDemandStart, onIdleStop, isAlive, onLog } = opts;
|
|
1565
|
+
let idleTimer = null;
|
|
1566
|
+
let lastActivity = Date.now();
|
|
1567
|
+
let starting = false;
|
|
1568
|
+
let serviceReady = false;
|
|
1569
|
+
let pendingConns = [];
|
|
1570
|
+
const activeConns = /* @__PURE__ */ new Set();
|
|
1571
|
+
function bumpActivity() {
|
|
1572
|
+
lastActivity = Date.now();
|
|
1573
|
+
}
|
|
1574
|
+
function scheduleIdleCheck() {
|
|
1575
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1576
|
+
if (timeoutMin <= 0) return;
|
|
1577
|
+
const periodMs = timeoutMin * 6e4;
|
|
1578
|
+
idleTimer = setTimeout(() => {
|
|
1579
|
+
const elapsed = Date.now() - lastActivity;
|
|
1580
|
+
if (activeConns.size > 0 || elapsed < periodMs) {
|
|
1581
|
+
scheduleIdleCheck();
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
serviceReady = false;
|
|
1585
|
+
onLog?.(`\u{1F4A4} idle ${timeoutMin}min \u2014 stopping`);
|
|
1586
|
+
onIdleStop();
|
|
1587
|
+
}, periodMs);
|
|
1588
|
+
}
|
|
1589
|
+
function pipeToTarget(client) {
|
|
1590
|
+
const target = net2.createConnection({ port: targetPort, host: "127.0.0.1", allowHalfOpen: true });
|
|
1591
|
+
activeConns.add(client);
|
|
1592
|
+
const cleanup = () => {
|
|
1593
|
+
activeConns.delete(client);
|
|
1594
|
+
bumpActivity();
|
|
1595
|
+
};
|
|
1596
|
+
target.on("error", () => {
|
|
1597
|
+
client.destroy();
|
|
1598
|
+
cleanup();
|
|
1599
|
+
});
|
|
1600
|
+
client.on("error", () => {
|
|
1601
|
+
target.destroy();
|
|
1602
|
+
cleanup();
|
|
1603
|
+
});
|
|
1604
|
+
client.on("close", cleanup);
|
|
1605
|
+
target.on("close", cleanup);
|
|
1606
|
+
target.on("connect", () => {
|
|
1607
|
+
target.on("data", (chunk) => {
|
|
1608
|
+
bumpActivity();
|
|
1609
|
+
if (!client.destroyed) client.write(chunk);
|
|
1610
|
+
});
|
|
1611
|
+
client.on("data", (chunk) => {
|
|
1612
|
+
bumpActivity();
|
|
1613
|
+
if (!target.destroyed) target.write(chunk);
|
|
1614
|
+
});
|
|
1615
|
+
target.on("end", () => {
|
|
1616
|
+
if (!client.destroyed) client.end();
|
|
1617
|
+
});
|
|
1618
|
+
client.on("end", () => {
|
|
1619
|
+
if (!target.destroyed) target.end();
|
|
1620
|
+
});
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
async function handleConnection(client) {
|
|
1624
|
+
bumpActivity();
|
|
1625
|
+
client.on("error", () => {
|
|
1626
|
+
});
|
|
1627
|
+
if (serviceReady && isAlive()) {
|
|
1628
|
+
pipeToTarget(client);
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
pendingConns.push(client);
|
|
1632
|
+
client.on("close", () => {
|
|
1633
|
+
pendingConns = pendingConns.filter((s) => s !== client);
|
|
1634
|
+
});
|
|
1635
|
+
if (starting) return;
|
|
1636
|
+
starting = true;
|
|
1637
|
+
onLog?.("\u26A1 on-demand start");
|
|
1638
|
+
let ok = false;
|
|
1639
|
+
try {
|
|
1640
|
+
await onDemandStart();
|
|
1641
|
+
ok = await waitForPort(targetPort, { timeout: 45e3, interval: 500 });
|
|
1642
|
+
if (ok) serviceReady = true;
|
|
1643
|
+
else onLog?.("\u26A0 timeout waiting for service");
|
|
1644
|
+
} catch (e) {
|
|
1645
|
+
onLog?.(`\u274C start failed: ${e.message}`);
|
|
1646
|
+
}
|
|
1647
|
+
starting = false;
|
|
1648
|
+
const conns = pendingConns.splice(0);
|
|
1649
|
+
if (!ok) {
|
|
1650
|
+
for (const conn of conns) {
|
|
1651
|
+
if (!conn.destroyed) conn.destroy();
|
|
1652
|
+
}
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
for (const conn of conns) {
|
|
1656
|
+
if (!conn.destroyed) pipeToTarget(conn);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
const server = net2.createServer({ allowHalfOpen: true }, (socket) => handleConnection(socket));
|
|
1660
|
+
server.listen(listenPort, "0.0.0.0");
|
|
1661
|
+
scheduleIdleCheck();
|
|
1662
|
+
return {
|
|
1663
|
+
server,
|
|
1664
|
+
resetTimer: bumpActivity,
|
|
1665
|
+
destroy: () => {
|
|
1666
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1667
|
+
pendingConns.forEach((s) => s.destroy());
|
|
1668
|
+
activeConns.forEach((s) => s.destroy());
|
|
1669
|
+
server.close();
|
|
1670
|
+
}
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// src/orchestrator/config-watcher.ts
|
|
1675
|
+
import { watchFile, unwatchFile } from "fs";
|
|
1676
|
+
|
|
1677
|
+
// src/config/diff.ts
|
|
1678
|
+
var SPAWN_RELEVANT = [
|
|
1679
|
+
"cwd",
|
|
1680
|
+
"cmd",
|
|
1681
|
+
"args",
|
|
1682
|
+
"port",
|
|
1683
|
+
"phase",
|
|
1684
|
+
"maxMem",
|
|
1685
|
+
"preBuild",
|
|
1686
|
+
"watchBuild",
|
|
1687
|
+
"nodeArgs",
|
|
1688
|
+
"extraEnv",
|
|
1689
|
+
"healthCheck",
|
|
1690
|
+
"readyPattern",
|
|
1691
|
+
"errorPattern",
|
|
1692
|
+
"type"
|
|
1693
|
+
];
|
|
1694
|
+
function hasSpawnRelevantChange(prev, next) {
|
|
1695
|
+
for (const k of SPAWN_RELEVANT) {
|
|
1696
|
+
if (JSON.stringify(prev[k]) !== JSON.stringify(next[k])) return true;
|
|
1697
|
+
}
|
|
1698
|
+
return false;
|
|
1699
|
+
}
|
|
1700
|
+
function diffServices(prev, next) {
|
|
1701
|
+
const prevByName = new Map(prev.map((s) => [s.name, s]));
|
|
1702
|
+
const nextByName = new Map(next.map((s) => [s.name, s]));
|
|
1703
|
+
const added = [];
|
|
1704
|
+
const removed = [];
|
|
1705
|
+
const changed = [];
|
|
1706
|
+
const unchanged = [];
|
|
1707
|
+
for (const [name, p] of prevByName) {
|
|
1708
|
+
if (!nextByName.has(name)) {
|
|
1709
|
+
removed.push(name);
|
|
1710
|
+
continue;
|
|
1711
|
+
}
|
|
1712
|
+
const n = nextByName.get(name);
|
|
1713
|
+
if (hasSpawnRelevantChange(p, n)) changed.push({ prev: p, next: n });
|
|
1714
|
+
else unchanged.push(name);
|
|
1715
|
+
}
|
|
1716
|
+
for (const [name, n] of nextByName) {
|
|
1717
|
+
if (!prevByName.has(name)) added.push(n);
|
|
1718
|
+
}
|
|
1719
|
+
return { added, removed, changed, unchanged };
|
|
1720
|
+
}
|
|
1721
|
+
function summariseDiff(d) {
|
|
1722
|
+
const parts = [];
|
|
1723
|
+
if (d.added.length) parts.push(`+${d.added.length} added`);
|
|
1724
|
+
if (d.removed.length) parts.push(`-${d.removed.length} removed`);
|
|
1725
|
+
if (d.changed.length) parts.push(`~${d.changed.length} changed`);
|
|
1726
|
+
if (!parts.length) parts.push("no changes");
|
|
1727
|
+
return parts.join(", ");
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// src/orchestrator/config-watcher.ts
|
|
1731
|
+
async function applyConfigChange(opts) {
|
|
1732
|
+
const { configPath, baseCwd, manager, log } = opts;
|
|
1733
|
+
try {
|
|
1734
|
+
const nextCfg = await loadConfig(configPath);
|
|
1735
|
+
const errs = validateConfig(nextCfg, baseCwd);
|
|
1736
|
+
if (errs.length) {
|
|
1737
|
+
log(`\u26A0 config reload failed:
|
|
1738
|
+
${formatValidationErrors(errs)}`);
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
const currentSvcs = [...manager.state.values()].map((s) => s.svc);
|
|
1742
|
+
const diff = diffServices(currentSvcs, nextCfg.services);
|
|
1743
|
+
if (!diff.added.length && !diff.removed.length && !diff.changed.length) return;
|
|
1744
|
+
for (const name of diff.removed) {
|
|
1745
|
+
manager.stop(name);
|
|
1746
|
+
manager.state.delete(name);
|
|
1747
|
+
}
|
|
1748
|
+
let colorIdx = currentSvcs.length;
|
|
1749
|
+
for (const { next } of diff.changed) {
|
|
1750
|
+
const prev = manager.state.get(next.name);
|
|
1751
|
+
const ci = prev?.colorIdx ?? colorIdx++;
|
|
1752
|
+
manager.stop(next.name);
|
|
1753
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
1754
|
+
await manager.install(next, ci);
|
|
1755
|
+
await manager.start(next, ci, true);
|
|
1756
|
+
}
|
|
1757
|
+
for (const next of diff.added) {
|
|
1758
|
+
const ci = colorIdx++;
|
|
1759
|
+
await manager.install(next, ci);
|
|
1760
|
+
await manager.start(next, ci);
|
|
1761
|
+
}
|
|
1762
|
+
log(`\u{1F501} config reloaded: ${summariseDiff(diff)}`);
|
|
1763
|
+
} catch (e) {
|
|
1764
|
+
log(`\u26A0 config reload error: ${e.message}`);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
function watchConfig(opts) {
|
|
1768
|
+
let debounceTimer = null;
|
|
1769
|
+
let reloadInFlight = false;
|
|
1770
|
+
let reloadAgain = false;
|
|
1771
|
+
const trigger = async () => {
|
|
1772
|
+
if (reloadInFlight) {
|
|
1773
|
+
reloadAgain = true;
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
reloadInFlight = true;
|
|
1777
|
+
try {
|
|
1778
|
+
await applyConfigChange(opts);
|
|
1779
|
+
} finally {
|
|
1780
|
+
reloadInFlight = false;
|
|
1781
|
+
if (reloadAgain) {
|
|
1782
|
+
reloadAgain = false;
|
|
1783
|
+
void trigger();
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
};
|
|
1787
|
+
const listener = (curr, prev) => {
|
|
1788
|
+
if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return;
|
|
1789
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
1790
|
+
debounceTimer = setTimeout(() => void trigger(), 250);
|
|
1791
|
+
};
|
|
1792
|
+
watchFile(opts.configPath, { interval: 500 }, listener);
|
|
1793
|
+
return () => {
|
|
1794
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
1795
|
+
unwatchFile(opts.configPath, listener);
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// src/orchestrator/daemon.ts
|
|
1800
|
+
var SAFE = /[^a-zA-Z0-9._-]+/g;
|
|
1801
|
+
var sanitize2 = (n) => n.replace(SAFE, "_").replace(/^_+|_+$/g, "") || "devup";
|
|
1802
|
+
var devupDir = () => join8(homedir3(), ".devup");
|
|
1803
|
+
function pidPathFor(projectName) {
|
|
1804
|
+
return join8(devupDir(), `${sanitize2(projectName)}.pid`);
|
|
1805
|
+
}
|
|
1806
|
+
function bootErrorPathFor(projectName) {
|
|
1807
|
+
return join8(devupDir(), `${sanitize2(projectName)}.boot-error`);
|
|
1808
|
+
}
|
|
1809
|
+
function pidAlive(pid) {
|
|
1810
|
+
try {
|
|
1811
|
+
process.kill(pid, 0);
|
|
1812
|
+
return true;
|
|
1813
|
+
} catch {
|
|
1814
|
+
return false;
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
function isDaemonRunning(projectName) {
|
|
1818
|
+
const path = pidPathFor(projectName);
|
|
1819
|
+
if (!existsSync10(path)) return { pid: null, stale: false };
|
|
1820
|
+
let pid;
|
|
1821
|
+
try {
|
|
1822
|
+
pid = Number(readFileSync3(path, "utf8").trim());
|
|
1823
|
+
if (!pid || !Number.isFinite(pid)) return { pid: null, stale: true };
|
|
1824
|
+
} catch {
|
|
1825
|
+
return { pid: null, stale: true };
|
|
1826
|
+
}
|
|
1827
|
+
return pidAlive(pid) ? { pid, stale: false } : { pid, stale: true };
|
|
1828
|
+
}
|
|
1829
|
+
async function daemonBody(opts) {
|
|
1830
|
+
const { config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts } = opts;
|
|
1831
|
+
const projectName = config.name;
|
|
1832
|
+
const errPath = bootErrorPathFor(projectName);
|
|
1833
|
+
const pidPath = pidPathFor(projectName);
|
|
1834
|
+
mkdirSync3(devupDir(), { recursive: true });
|
|
1835
|
+
if (existsSync10(errPath)) {
|
|
1836
|
+
try {
|
|
1837
|
+
unlinkSync2(errPath);
|
|
1838
|
+
} catch {
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
const logSink = new LogSink({ projectName, rootDir: cliArgs.logDir });
|
|
1842
|
+
const logBus = new Broadcaster();
|
|
1843
|
+
const stateBus = new Broadcaster();
|
|
1844
|
+
const lazyProxies = /* @__PURE__ */ new Map();
|
|
1845
|
+
let externals = [];
|
|
1846
|
+
let socket = null;
|
|
1847
|
+
let healthTimer = null;
|
|
1848
|
+
let proxyTimer = null;
|
|
1849
|
+
let stopConfigWatcher = null;
|
|
1850
|
+
const writeDevupLog = (text) => {
|
|
1851
|
+
logSink.write("devup", text);
|
|
1852
|
+
logBus.emit({ svc: "devup", text });
|
|
1853
|
+
};
|
|
1854
|
+
const mgr = new ProcessManager({
|
|
1855
|
+
baseCwd,
|
|
1856
|
+
env,
|
|
1857
|
+
platform,
|
|
1858
|
+
events: {
|
|
1859
|
+
onLog: (svcName, text) => {
|
|
1860
|
+
logSink.write(svcName, text);
|
|
1861
|
+
logBus.emit({ svc: svcName, text });
|
|
1862
|
+
},
|
|
1863
|
+
onStateChange: (name, state) => stateBus.emit({ name, state })
|
|
1864
|
+
}
|
|
1865
|
+
});
|
|
1866
|
+
const cleanup = async () => {
|
|
1867
|
+
if (healthTimer) clearInterval(healthTimer);
|
|
1868
|
+
if (proxyTimer) clearInterval(proxyTimer);
|
|
1869
|
+
if (stopConfigWatcher) {
|
|
1870
|
+
try {
|
|
1871
|
+
stopConfigWatcher();
|
|
1872
|
+
} catch {
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
for (const p of lazyProxies.values()) p.destroy();
|
|
1876
|
+
if (socket) await socket.close().catch(() => {
|
|
1877
|
+
});
|
|
1878
|
+
await mgr.cleanup().catch(() => {
|
|
1879
|
+
});
|
|
1880
|
+
if (externals.length) {
|
|
1881
|
+
await stopExternals(externals, platform, {
|
|
1882
|
+
baseCwd,
|
|
1883
|
+
env,
|
|
1884
|
+
onLog: (svc, msg) => logSink.write(`ext:${svc}`, msg)
|
|
1885
|
+
}).catch(() => {
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
if (proxyProvider && proxyOpts && cliArgs.proxy) {
|
|
1889
|
+
try {
|
|
1890
|
+
proxyProvider.clear(proxyOpts);
|
|
1891
|
+
} catch {
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
await logSink.close().catch(() => {
|
|
1895
|
+
});
|
|
1896
|
+
if (existsSync10(pidPath)) {
|
|
1897
|
+
try {
|
|
1898
|
+
unlinkSync2(pidPath);
|
|
1899
|
+
} catch {
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
};
|
|
1903
|
+
let shuttingDown = false;
|
|
1904
|
+
const onSignal = () => {
|
|
1905
|
+
if (shuttingDown) return;
|
|
1906
|
+
shuttingDown = true;
|
|
1907
|
+
cleanup().then(() => process.exit(0), () => process.exit(1));
|
|
1908
|
+
};
|
|
1909
|
+
process.on("SIGTERM", onSignal);
|
|
1910
|
+
process.on("SIGINT", onSignal);
|
|
1911
|
+
try {
|
|
1912
|
+
if (config.external?.length) {
|
|
1913
|
+
writeDevupLog(`\u25B6 externals (${config.external.length})`);
|
|
1914
|
+
const result = await startExternals(config.external, {
|
|
1915
|
+
baseCwd,
|
|
1916
|
+
env,
|
|
1917
|
+
platform,
|
|
1918
|
+
onLog: (svc, msg) => {
|
|
1919
|
+
logSink.write(`ext:${svc}`, msg);
|
|
1920
|
+
logBus.emit({ svc: `ext:${svc}`, text: msg });
|
|
1921
|
+
}
|
|
1922
|
+
});
|
|
1923
|
+
externals = result.procs;
|
|
1924
|
+
if (!result.allHealthy) {
|
|
1925
|
+
throw new Error(`externals failed: ${result.failed.join(", ")}`);
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
if (cliArgs.lazy && config.lazy) {
|
|
1929
|
+
await bootLazy(mgr, services, config.lazy, cliArgs.lazyTimeout, lazyProxies);
|
|
1930
|
+
} else {
|
|
1931
|
+
await bootNormal(mgr, services);
|
|
1932
|
+
}
|
|
1933
|
+
socket = await startSocketServer(projectName, {
|
|
1934
|
+
states: () => mgr.state,
|
|
1935
|
+
restart: (n) => mgr.restart(n),
|
|
1936
|
+
stop: (n) => mgr.stop(n),
|
|
1937
|
+
tailLogs: async (svcName, lines) => {
|
|
1938
|
+
const file = logSink.pathFor(svcName);
|
|
1939
|
+
if (!existsSync10(file)) return [];
|
|
1940
|
+
return new Promise((resolve4, reject) => {
|
|
1941
|
+
const buf = [];
|
|
1942
|
+
const rl = createInterface3({ input: createReadStream(file, { encoding: "utf8" }) });
|
|
1943
|
+
rl.on("line", (l) => {
|
|
1944
|
+
buf.push(l);
|
|
1945
|
+
if (buf.length > lines) buf.shift();
|
|
1946
|
+
});
|
|
1947
|
+
rl.on("close", () => resolve4(buf));
|
|
1948
|
+
rl.on("error", reject);
|
|
1949
|
+
});
|
|
1950
|
+
},
|
|
1951
|
+
watchLogs: (svcName, onLine) => logBus.subscribe(({ svc, text }) => {
|
|
1952
|
+
if (svcName === null || svc === svcName) onLine(svc, text);
|
|
1953
|
+
}),
|
|
1954
|
+
watchStatus: (onUpdate) => stateBus.subscribe(({ name, state }) => onUpdate(name, state))
|
|
1955
|
+
}, { onLog: (msg) => writeDevupLog(msg) });
|
|
1956
|
+
healthTimer = setInterval(() => {
|
|
1957
|
+
void mgr.checkAllHealth();
|
|
1958
|
+
}, 3e3);
|
|
1959
|
+
if (proxyProvider && proxyOpts && cliArgs.proxy) {
|
|
1960
|
+
let lastContent = null;
|
|
1961
|
+
const sync = () => {
|
|
1962
|
+
const svcStates = /* @__PURE__ */ new Map();
|
|
1963
|
+
for (const [n, st] of mgr.state) {
|
|
1964
|
+
svcStates.set(n, { port: st.svc.port, health: st.health, realPort: st.svc.realPort });
|
|
1965
|
+
}
|
|
1966
|
+
const content = proxyProvider.generate(svcStates, proxyOpts);
|
|
1967
|
+
if (content === lastContent) return;
|
|
1968
|
+
lastContent = content;
|
|
1969
|
+
try {
|
|
1970
|
+
proxyProvider.write(content, proxyOpts);
|
|
1971
|
+
} catch {
|
|
1972
|
+
}
|
|
1973
|
+
};
|
|
1974
|
+
sync();
|
|
1975
|
+
proxyTimer = setInterval(sync, 3e3);
|
|
1976
|
+
}
|
|
1977
|
+
if (cliArgs.watchConfig) {
|
|
1978
|
+
try {
|
|
1979
|
+
const configPath = findConfigFile(baseCwd, cliArgs.configPath);
|
|
1980
|
+
writeDevupLog(`\u{1F440} watching ${configPath}`);
|
|
1981
|
+
stopConfigWatcher = watchConfig({
|
|
1982
|
+
configPath,
|
|
1983
|
+
baseCwd,
|
|
1984
|
+
manager: mgr,
|
|
1985
|
+
log: (msg) => writeDevupLog(msg)
|
|
1986
|
+
});
|
|
1987
|
+
} catch (e) {
|
|
1988
|
+
writeDevupLog(`\u26A0 watch-config disabled: ${e.message ?? String(e)}`);
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
writeFileSync2(pidPath, String(process.pid));
|
|
1992
|
+
writeDevupLog(`\u2713 daemon ready (pid=${process.pid})`);
|
|
1993
|
+
} catch (e) {
|
|
1994
|
+
try {
|
|
1995
|
+
writeFileSync2(errPath, e.message ?? String(e));
|
|
1996
|
+
} catch {
|
|
1997
|
+
}
|
|
1998
|
+
try {
|
|
1999
|
+
writeDevupLog(`\u274C boot failed: ${e.message ?? String(e)}`);
|
|
2000
|
+
} catch {
|
|
2001
|
+
}
|
|
2002
|
+
await cleanup().catch(() => {
|
|
2003
|
+
});
|
|
2004
|
+
process.exit(1);
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
async function bootNormal(mgr, services) {
|
|
2008
|
+
const phases = groupByPhase(services);
|
|
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
|
+
}
|
|
2024
|
+
async function bootLazy(mgr, services, lazyCfg, lazyTimeout, lazyProxies) {
|
|
2025
|
+
const { alwaysOn, lazy } = classifyServices(services, lazyCfg);
|
|
2026
|
+
const phases = groupByPhase(alwaysOn);
|
|
2027
|
+
let colorIdx = 0;
|
|
2028
|
+
for (const num of Object.keys(phases).map(Number).sort((a, b) => a - b)) {
|
|
2029
|
+
for (const svc of phases[num]) {
|
|
2030
|
+
const ci = colorIdx++;
|
|
2031
|
+
await mgr.install(svc, ci);
|
|
2032
|
+
await mgr.start(svc, ci);
|
|
2033
|
+
}
|
|
2034
|
+
const apis = phases[num].filter((s) => s.type === "api");
|
|
2035
|
+
if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
|
|
2036
|
+
phases[num].filter((s) => s.type === "web").forEach((s) => {
|
|
2037
|
+
const st = mgr.state.get(s.name);
|
|
2038
|
+
if (st) st.status = "running";
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
for (const svc of lazy) {
|
|
2042
|
+
const ci = colorIdx++;
|
|
2043
|
+
const rewritten = rewriteServicePort(svc);
|
|
2044
|
+
mgr.state.set(svc.name, {
|
|
2045
|
+
svc: rewritten,
|
|
2046
|
+
proc: null,
|
|
2047
|
+
pid: null,
|
|
2048
|
+
status: "idle",
|
|
2049
|
+
health: "idle",
|
|
2050
|
+
errors: 0,
|
|
2051
|
+
restarts: 0,
|
|
2052
|
+
startedAt: null,
|
|
2053
|
+
intentionalStop: false,
|
|
2054
|
+
colorIdx: ci
|
|
2055
|
+
});
|
|
2056
|
+
const proxy = createLazyProxy({
|
|
2057
|
+
listenPort: svc.port,
|
|
2058
|
+
targetPort: rewritten.realPort,
|
|
2059
|
+
timeoutMin: lazyTimeout,
|
|
2060
|
+
onDemandStart: async () => {
|
|
2061
|
+
await mgr.install(rewritten, ci);
|
|
2062
|
+
await mgr.start(rewritten, ci);
|
|
2063
|
+
const ok = await waitForPort(rewritten.realPort, { timeout: 45e3 });
|
|
2064
|
+
const st = mgr.state.get(svc.name);
|
|
2065
|
+
if (st) {
|
|
2066
|
+
st.status = ok ? "running" : "timeout";
|
|
2067
|
+
if (ok) st.health = "up";
|
|
2068
|
+
}
|
|
2069
|
+
},
|
|
2070
|
+
onIdleStop: () => {
|
|
2071
|
+
mgr.stop(svc.name);
|
|
2072
|
+
const st = mgr.state.get(svc.name);
|
|
2073
|
+
if (st) {
|
|
2074
|
+
st.status = "idle";
|
|
2075
|
+
st.health = "idle";
|
|
2076
|
+
st.pid = null;
|
|
2077
|
+
st.proc = null;
|
|
2078
|
+
st.startedAt = null;
|
|
2079
|
+
}
|
|
2080
|
+
},
|
|
2081
|
+
isAlive: () => {
|
|
2082
|
+
const st = mgr.state.get(svc.name);
|
|
2083
|
+
return !!st && !!st.proc && !st.proc.killed && st.health === "up";
|
|
2084
|
+
}
|
|
2085
|
+
});
|
|
2086
|
+
lazyProxies.set(svc.name, proxy);
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
async function runDetached(opts) {
|
|
2090
|
+
const out = opts.out ?? ((l) => process.stdout.write(l + "\n"));
|
|
2091
|
+
const projectName = opts.config.name;
|
|
2092
|
+
if (process.platform === "win32") {
|
|
2093
|
+
out("\u274C daemon mode (devup up -d) is not yet supported on Windows. Run `devup` to use the TUI instead.");
|
|
2094
|
+
return 1;
|
|
2095
|
+
}
|
|
2096
|
+
const existing = isDaemonRunning(projectName);
|
|
2097
|
+
if (existing.pid && !existing.stale) {
|
|
2098
|
+
out(`\u274C daemon already running for "${projectName}" (pid=${existing.pid}). Run \`devup down\` to stop it.`);
|
|
2099
|
+
return 1;
|
|
2100
|
+
}
|
|
2101
|
+
if (existing.stale) {
|
|
2102
|
+
out(`\u2139 removing stale pid file for "${projectName}"`);
|
|
2103
|
+
try {
|
|
2104
|
+
unlinkSync2(pidPathFor(projectName));
|
|
2105
|
+
} catch {
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
mkdirSync3(devupDir(), { recursive: true });
|
|
2109
|
+
const errPath = bootErrorPathFor(projectName);
|
|
2110
|
+
const pidPath = pidPathFor(projectName);
|
|
2111
|
+
if (existsSync10(errPath)) {
|
|
2112
|
+
try {
|
|
2113
|
+
unlinkSync2(errPath);
|
|
2114
|
+
} catch {
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
const filteredArgs = process.argv.slice(2).filter((arg, i) => {
|
|
2118
|
+
if (i === 0 && arg === "up") return false;
|
|
2119
|
+
if (arg === "-d" || arg === "--detach") return false;
|
|
2120
|
+
return true;
|
|
2121
|
+
});
|
|
2122
|
+
out(`\u23F3 starting devup in detached mode for "${projectName}"...`);
|
|
2123
|
+
const child = spawn4(process.execPath, [...process.execArgv, process.argv[1], ...filteredArgs], {
|
|
2124
|
+
detached: true,
|
|
2125
|
+
stdio: "ignore",
|
|
2126
|
+
env: { ...process.env, DEVUP_DAEMON_CHILD: "1" },
|
|
2127
|
+
cwd: opts.baseCwd
|
|
2128
|
+
});
|
|
2129
|
+
child.unref();
|
|
2130
|
+
const deadline = Date.now() + 9e4;
|
|
2131
|
+
while (Date.now() < deadline) {
|
|
2132
|
+
if (existsSync10(pidPath)) {
|
|
2133
|
+
const pid = Number(readFileSync3(pidPath, "utf8").trim());
|
|
2134
|
+
out("");
|
|
2135
|
+
out(`\u{1F680} devup detached (PID ${pid})`);
|
|
2136
|
+
out(" inspect: devup ctl status");
|
|
2137
|
+
out(" logs: devup ctl logs <svc> --follow");
|
|
2138
|
+
out(" stop: devup down");
|
|
2139
|
+
return 0;
|
|
2140
|
+
}
|
|
2141
|
+
if (existsSync10(errPath)) {
|
|
2142
|
+
const msg = readFileSync3(errPath, "utf8").trim();
|
|
2143
|
+
out(`\u274C daemon boot failed: ${msg}`);
|
|
2144
|
+
try {
|
|
2145
|
+
unlinkSync2(errPath);
|
|
2146
|
+
} catch {
|
|
2147
|
+
}
|
|
2148
|
+
return 1;
|
|
2149
|
+
}
|
|
2150
|
+
await sleep(200);
|
|
2151
|
+
}
|
|
2152
|
+
out(`\u274C daemon did not become ready within 90s. Killing child (pid=${child.pid}).`);
|
|
2153
|
+
if (child.pid) {
|
|
2154
|
+
try {
|
|
2155
|
+
process.kill(child.pid, "SIGTERM");
|
|
2156
|
+
} catch {
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
return 1;
|
|
2160
|
+
}
|
|
2161
|
+
async function stopDaemon(projectName, opts = {}) {
|
|
2162
|
+
const out = opts.out ?? ((l) => process.stdout.write(l + "\n"));
|
|
2163
|
+
const grace = opts.gracePeriodMs ?? 1e4;
|
|
2164
|
+
const status = isDaemonRunning(projectName);
|
|
2165
|
+
if (!status.pid) {
|
|
2166
|
+
out(`\u2139 no daemon running for "${projectName}".`);
|
|
2167
|
+
return 1;
|
|
2168
|
+
}
|
|
2169
|
+
if (status.stale) {
|
|
2170
|
+
out(`\u2139 stale pid file for "${projectName}" (pid=${status.pid} not alive). Removing.`);
|
|
2171
|
+
try {
|
|
2172
|
+
unlinkSync2(pidPathFor(projectName));
|
|
2173
|
+
} catch {
|
|
2174
|
+
}
|
|
2175
|
+
return 1;
|
|
2176
|
+
}
|
|
2177
|
+
out(`\u23F3 stopping daemon (pid=${status.pid})...`);
|
|
2178
|
+
try {
|
|
2179
|
+
process.kill(status.pid, "SIGTERM");
|
|
2180
|
+
} catch (e) {
|
|
2181
|
+
out(`\u274C cannot signal pid=${status.pid}: ${e.message}`);
|
|
2182
|
+
return 1;
|
|
2183
|
+
}
|
|
2184
|
+
const deadline = Date.now() + grace;
|
|
2185
|
+
while (Date.now() < deadline) {
|
|
2186
|
+
if (!pidAlive(status.pid)) {
|
|
2187
|
+
out(`\u2713 stopped daemon (pid=${status.pid})`);
|
|
2188
|
+
const p2 = pidPathFor(projectName);
|
|
2189
|
+
if (existsSync10(p2)) {
|
|
2190
|
+
try {
|
|
2191
|
+
unlinkSync2(p2);
|
|
2192
|
+
} catch {
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
return 0;
|
|
2196
|
+
}
|
|
2197
|
+
await sleep(200);
|
|
2198
|
+
}
|
|
2199
|
+
out(`\u26A0 daemon did not exit within ${(grace / 1e3).toFixed(0)}s; sending SIGKILL.`);
|
|
2200
|
+
try {
|
|
2201
|
+
process.kill(status.pid, "SIGKILL");
|
|
2202
|
+
} catch {
|
|
2203
|
+
}
|
|
2204
|
+
const p = pidPathFor(projectName);
|
|
2205
|
+
if (existsSync10(p)) {
|
|
2206
|
+
try {
|
|
2207
|
+
unlinkSync2(p);
|
|
2208
|
+
} catch {
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
return 0;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
// src/orchestrator/subcommands.ts
|
|
2215
|
+
var KNOWN = /* @__PURE__ */ new Set(["logs", "install", "status", "help", "ctl", "up", "down"]);
|
|
2216
|
+
function detectSubcommand(argv) {
|
|
2217
|
+
const first = argv[0];
|
|
2218
|
+
return first && KNOWN.has(first) ? first : null;
|
|
2219
|
+
}
|
|
2220
|
+
function logRoot(config, override) {
|
|
2221
|
+
const root = override ?? join9(homedir4(), ".devup", "logs");
|
|
2222
|
+
return join9(root, sanitize3(config.name));
|
|
2223
|
+
}
|
|
2224
|
+
function sanitize3(name) {
|
|
2225
|
+
return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
|
|
2226
|
+
}
|
|
2227
|
+
async function runLogs(argv, opts) {
|
|
2228
|
+
const out = opts.out ?? ((l) => console.log(l));
|
|
2229
|
+
const follow = argv.includes("--follow") || argv.includes("-f");
|
|
2230
|
+
const svcArg = argv.find((a) => !a.startsWith("-"));
|
|
2231
|
+
if (!svcArg) {
|
|
2232
|
+
out("usage: devup logs <service> [--follow]");
|
|
2233
|
+
return 1;
|
|
2234
|
+
}
|
|
2235
|
+
const knownSvcs = opts.config.services.map((s) => s.name);
|
|
2236
|
+
if (!knownSvcs.includes(svcArg)) {
|
|
2237
|
+
out(`Unknown service "${svcArg}". Known: ${knownSvcs.join(", ")}`);
|
|
2238
|
+
return 1;
|
|
2239
|
+
}
|
|
2240
|
+
const file = join9(logRoot(opts.config, opts.logDir), `${sanitize3(svcArg)}.log`);
|
|
2241
|
+
if (!existsSync11(file)) {
|
|
2242
|
+
out(`No log file yet for "${svcArg}" (${file})`);
|
|
2243
|
+
return follow ? await followFile(file, out) : 1;
|
|
2244
|
+
}
|
|
2245
|
+
await streamFile(file, out);
|
|
2246
|
+
if (!follow) return 0;
|
|
2247
|
+
return await followFile(file, out, statSync2(file).size);
|
|
2248
|
+
}
|
|
2249
|
+
async function streamFile(file, out) {
|
|
2250
|
+
return new Promise((resolve4, reject) => {
|
|
2251
|
+
const rl = createInterface4({ input: createReadStream2(file, { encoding: "utf8" }) });
|
|
2252
|
+
rl.on("line", (l) => out(l));
|
|
2253
|
+
rl.on("close", () => resolve4());
|
|
2254
|
+
rl.on("error", reject);
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
2257
|
+
async function followFile(file, out, startAt = 0) {
|
|
2258
|
+
let pos = startAt;
|
|
2259
|
+
while (!existsSync11(file)) await new Promise((r) => setTimeout(r, 500));
|
|
2260
|
+
return new Promise((resolve4) => {
|
|
2261
|
+
const tick = async () => {
|
|
2262
|
+
const size = statSync2(file).size;
|
|
2263
|
+
if (size > pos) {
|
|
2264
|
+
await new Promise((res) => {
|
|
2265
|
+
const rl = createInterface4({ input: createReadStream2(file, { encoding: "utf8", start: pos, end: size - 1 }) });
|
|
2266
|
+
rl.on("line", (l) => out(l));
|
|
2267
|
+
rl.on("close", () => {
|
|
2268
|
+
pos = size;
|
|
2269
|
+
res();
|
|
2270
|
+
});
|
|
2271
|
+
});
|
|
2272
|
+
} else if (size < pos) {
|
|
2273
|
+
pos = 0;
|
|
2274
|
+
}
|
|
2275
|
+
};
|
|
2276
|
+
watchFile2(file, { interval: 500 }, () => {
|
|
2277
|
+
void tick();
|
|
2278
|
+
});
|
|
2279
|
+
process.once("SIGINT", () => {
|
|
2280
|
+
unwatchFile2(file);
|
|
2281
|
+
resolve4(0);
|
|
2282
|
+
});
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
async function runInstall(opts) {
|
|
2286
|
+
const out = opts.out ?? ((l) => console.log(l));
|
|
2287
|
+
const concurrency = opts.concurrency ?? 4;
|
|
2288
|
+
const items = opts.config.services.map((s) => ({ name: s.name, cwd: join9(opts.baseCwd, s.cwd) }));
|
|
2289
|
+
const queue = [...items];
|
|
2290
|
+
const failed = [];
|
|
2291
|
+
let inFlight = 0;
|
|
2292
|
+
await new Promise((resolve4) => {
|
|
2293
|
+
const pump = () => {
|
|
2294
|
+
while (inFlight < concurrency && queue.length) {
|
|
2295
|
+
const item = queue.shift();
|
|
2296
|
+
inFlight++;
|
|
2297
|
+
installOne(item.cwd, opts.env).then((ok) => {
|
|
2298
|
+
inFlight--;
|
|
2299
|
+
if (ok) out(`\u2713 ${item.name}`);
|
|
2300
|
+
else {
|
|
2301
|
+
failed.push(item.name);
|
|
2302
|
+
out(`\u2717 ${item.name}`);
|
|
2303
|
+
}
|
|
2304
|
+
if (queue.length === 0 && inFlight === 0) resolve4();
|
|
2305
|
+
else pump();
|
|
2306
|
+
});
|
|
2307
|
+
}
|
|
2308
|
+
};
|
|
2309
|
+
pump();
|
|
2310
|
+
});
|
|
2311
|
+
if (failed.length) {
|
|
2312
|
+
out(`
|
|
2313
|
+
failed: ${failed.join(", ")}`);
|
|
2314
|
+
return 1;
|
|
2315
|
+
}
|
|
2316
|
+
out(`
|
|
2317
|
+
${items.length} services up to date`);
|
|
2318
|
+
return 0;
|
|
2319
|
+
}
|
|
2320
|
+
function installOne(cwd, env) {
|
|
2321
|
+
if (!existsSync11(cwd)) return Promise.resolve(false);
|
|
2322
|
+
if (!needsInstall(cwd)) return Promise.resolve(true);
|
|
2323
|
+
return new Promise((resolve4) => {
|
|
2324
|
+
const command = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
2325
|
+
const proc = spawn5(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
|
|
2326
|
+
proc.on("close", (code) => {
|
|
2327
|
+
if (code === 0) {
|
|
2328
|
+
writeInstallStamp(cwd);
|
|
2329
|
+
resolve4(true);
|
|
2330
|
+
} else resolve4(false);
|
|
2331
|
+
});
|
|
2332
|
+
proc.on("error", () => resolve4(false));
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
async function runStatus(opts) {
|
|
2336
|
+
const out = opts.out ?? ((l) => console.log(l));
|
|
2337
|
+
out(`${opts.config.icon ?? "\u{1F4E6}"} ${opts.config.name} \u2014 ${opts.config.services.length} services`);
|
|
2338
|
+
out("");
|
|
2339
|
+
const maxLen = Math.max(...opts.config.services.map((s) => s.name.length), 12);
|
|
2340
|
+
out(`${"Service".padEnd(maxLen)} ${"Port".padStart(5)} ${"Type".padEnd(4)} Health`);
|
|
2341
|
+
out("-".repeat(maxLen + 24));
|
|
2342
|
+
for (const svc of opts.config.services) {
|
|
2343
|
+
const up = await checkHealth(svc.port, svc.healthCheck);
|
|
2344
|
+
const health = up ? "\u2713 up" : "\u2717 down";
|
|
2345
|
+
out(`${svc.name.padEnd(maxLen)} ${String(svc.port).padStart(5)} ${svc.type.padEnd(4)} ${health}`);
|
|
2346
|
+
}
|
|
2347
|
+
return 0;
|
|
2348
|
+
}
|
|
2349
|
+
function fmtStatus(rows, out) {
|
|
2350
|
+
const maxLen = Math.max(...rows.map((r) => r.name.length), 8);
|
|
2351
|
+
for (const r of rows) {
|
|
2352
|
+
const pid = r.pid != null ? `pid=${r.pid}` : " ";
|
|
2353
|
+
const name = r.name.padEnd(maxLen);
|
|
2354
|
+
const port = `:${r.port}`.padStart(6);
|
|
2355
|
+
const status = r.status.padEnd(8);
|
|
2356
|
+
const health = r.health.padEnd(4);
|
|
2357
|
+
out(`${name} ${port} ${status} ${health} ${pid} errors=${r.errors} restarts=${r.restarts}`);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
async function runCtl(argv, opts) {
|
|
2361
|
+
const out = opts.out ?? ((l) => process.stdout.write(l + "\n"));
|
|
2362
|
+
const method = argv[0];
|
|
2363
|
+
const follow = argv.includes("--follow") || argv.includes("-f");
|
|
2364
|
+
const socketPath = resolveSocket(opts.config.name, opts.socketPath);
|
|
2365
|
+
if (!method || method === "help") {
|
|
2366
|
+
out("Usage: devup ctl <method> [args] [--follow]");
|
|
2367
|
+
out(" ping Check if devup is running");
|
|
2368
|
+
out(" status [--follow] Service snapshot, or live updates");
|
|
2369
|
+
out(" logs <svc> [--follow] Tail logs (last 100), or follow live stream");
|
|
2370
|
+
out(" restart <svc> Restart a service");
|
|
2371
|
+
out(" stop <svc> Stop a service");
|
|
2372
|
+
return 0;
|
|
2373
|
+
}
|
|
2374
|
+
try {
|
|
2375
|
+
assertSocketExists(socketPath, opts.config.name);
|
|
2376
|
+
} catch (e) {
|
|
2377
|
+
out(e.message);
|
|
2378
|
+
return 1;
|
|
2379
|
+
}
|
|
2380
|
+
try {
|
|
2381
|
+
if (method === "ping") {
|
|
2382
|
+
const res = await sendRpc(socketPath, "ping");
|
|
2383
|
+
out(`pong ts=${res.ts}`);
|
|
2384
|
+
return 0;
|
|
2385
|
+
}
|
|
2386
|
+
if (method === "status" && !follow) {
|
|
2387
|
+
const res = await sendRpc(socketPath, "status");
|
|
2388
|
+
if (!res.services.length) {
|
|
2389
|
+
out("(no services)");
|
|
2390
|
+
return 0;
|
|
2391
|
+
}
|
|
2392
|
+
fmtStatus(res.services, out);
|
|
2393
|
+
return 0;
|
|
2394
|
+
}
|
|
2395
|
+
if (method === "status" && follow) {
|
|
2396
|
+
return await new Promise((resolve4) => {
|
|
2397
|
+
const abort = openStream(socketPath, "status.follow", {}, (frame) => {
|
|
2398
|
+
const rows = frame.data;
|
|
2399
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
|
|
2400
|
+
for (const r of rows) {
|
|
2401
|
+
out(`[${ts}] ${r.name.padEnd(24)} ${r.status}/${r.health}`);
|
|
2402
|
+
}
|
|
2403
|
+
}, (err) => {
|
|
2404
|
+
out(`error: ${err.message}`);
|
|
2405
|
+
resolve4(1);
|
|
2406
|
+
});
|
|
2407
|
+
process.once("SIGINT", () => {
|
|
2408
|
+
abort();
|
|
2409
|
+
resolve4(0);
|
|
2410
|
+
});
|
|
2411
|
+
});
|
|
2412
|
+
}
|
|
2413
|
+
if (method === "logs") {
|
|
2414
|
+
const svc = argv.find((a, i) => i > 0 && !a.startsWith("-"));
|
|
2415
|
+
if (!svc) {
|
|
2416
|
+
out("usage: devup ctl logs <service> [--follow]");
|
|
2417
|
+
return 1;
|
|
2418
|
+
}
|
|
2419
|
+
if (!follow) {
|
|
2420
|
+
const res = await sendRpc(socketPath, "logs.tail", { svc, lines: 100 });
|
|
2421
|
+
for (const l of res.lines) out(l);
|
|
2422
|
+
return 0;
|
|
2423
|
+
}
|
|
2424
|
+
return await new Promise((resolve4) => {
|
|
2425
|
+
const abort = openStream(socketPath, "logs.follow", { svc, tail: 100 }, (frame) => {
|
|
2426
|
+
out(frame.data);
|
|
2427
|
+
}, (err) => {
|
|
2428
|
+
out(`error: ${err.message}`);
|
|
2429
|
+
resolve4(1);
|
|
2430
|
+
});
|
|
2431
|
+
process.once("SIGINT", () => {
|
|
2432
|
+
abort();
|
|
2433
|
+
resolve4(0);
|
|
2434
|
+
});
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
if (method === "restart") {
|
|
2438
|
+
const svc = argv[1];
|
|
2439
|
+
if (!svc) {
|
|
2440
|
+
out("usage: devup ctl restart <service>");
|
|
2441
|
+
return 1;
|
|
2442
|
+
}
|
|
2443
|
+
await sendRpc(socketPath, "restart", { svc });
|
|
2444
|
+
out(`\u2713 restart sent to ${svc}`);
|
|
2445
|
+
return 0;
|
|
2446
|
+
}
|
|
2447
|
+
if (method === "stop") {
|
|
2448
|
+
const svc = argv[1];
|
|
2449
|
+
if (!svc) {
|
|
2450
|
+
out("usage: devup ctl stop <service>");
|
|
2451
|
+
return 1;
|
|
2452
|
+
}
|
|
2453
|
+
await sendRpc(socketPath, "stop", { svc });
|
|
2454
|
+
out(`\u2713 stop sent to ${svc}`);
|
|
2455
|
+
return 0;
|
|
2456
|
+
}
|
|
2457
|
+
out(`unknown ctl method: ${method}. Run \`devup ctl help\` for usage.`);
|
|
2458
|
+
return 1;
|
|
2459
|
+
} catch (e) {
|
|
2460
|
+
out(`error: ${e.message}`);
|
|
2461
|
+
return 1;
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
async function runDown(opts) {
|
|
2465
|
+
const out = opts.out ?? ((l) => process.stdout.write(l + "\n"));
|
|
2466
|
+
return stopDaemon(opts.config.name, { out });
|
|
2467
|
+
}
|
|
2468
|
+
function runHelp(argv, opts = {}) {
|
|
2469
|
+
const out = opts.out ?? ((l) => console.log(l));
|
|
2470
|
+
const sub = argv[0];
|
|
2471
|
+
if (sub === "logs") {
|
|
2472
|
+
out("Usage: devup logs <service> [--follow|-f]");
|
|
2473
|
+
out(" Print the persisted log file for a service (works without devup running).");
|
|
2474
|
+
out(" --follow tails new lines as they are appended.");
|
|
2475
|
+
return 0;
|
|
2476
|
+
}
|
|
2477
|
+
if (sub === "install") {
|
|
2478
|
+
out("Usage: devup install");
|
|
2479
|
+
out(" Run `npm install` across every service.cwd in parallel (max 4 at a time).");
|
|
2480
|
+
out(" Skips services whose .install-stamp matches package.json hash.");
|
|
2481
|
+
return 0;
|
|
2482
|
+
}
|
|
2483
|
+
if (sub === "status") {
|
|
2484
|
+
out("Usage: devup status");
|
|
2485
|
+
out(" For each service, probes its health-check endpoint and prints up/down.");
|
|
2486
|
+
return 0;
|
|
2487
|
+
}
|
|
2488
|
+
if (sub === "ctl") {
|
|
2489
|
+
out("Usage: devup ctl <method> [args] [--follow]");
|
|
2490
|
+
out(" Send commands to a running devup process via the control plane socket.");
|
|
2491
|
+
out("");
|
|
2492
|
+
out(" ping Check if devup is running");
|
|
2493
|
+
out(" status [--follow] Service snapshot, or live state-change stream");
|
|
2494
|
+
out(" logs <svc> [--follow] Tail last 100 lines, or follow the live stream");
|
|
2495
|
+
out(" restart <svc> Restart the named service");
|
|
2496
|
+
out(" stop <svc> Stop the named service");
|
|
2497
|
+
out("");
|
|
2498
|
+
out(" devup must be running in the same project directory.");
|
|
2499
|
+
return 0;
|
|
2500
|
+
}
|
|
2501
|
+
if (sub === "up") {
|
|
2502
|
+
out("Usage: devup up -d");
|
|
2503
|
+
out(" Boot the stack in detached/daemon mode (like `docker compose up -d`).");
|
|
2504
|
+
out(" Returns immediately once the stack is healthy; services keep running.");
|
|
2505
|
+
out(" Use `devup ctl status`, `devup ctl logs`, or `devup down` to interact.");
|
|
2506
|
+
out(" Not supported on Windows yet \u2014 use `devup` (TUI) instead.");
|
|
2507
|
+
return 0;
|
|
2508
|
+
}
|
|
2509
|
+
if (sub === "down") {
|
|
2510
|
+
out("Usage: devup down");
|
|
2511
|
+
out(" Stop the daemon for the current project. SIGTERM with 10s grace,");
|
|
2512
|
+
out(" then SIGKILL. Removes the PID file and the control-plane socket.");
|
|
2513
|
+
return 0;
|
|
2514
|
+
}
|
|
2515
|
+
out("Subcommands:");
|
|
2516
|
+
out(" devup logs <service> [--follow] Read the persisted log file");
|
|
2517
|
+
out(" devup install Concurrent npm install across services");
|
|
2518
|
+
out(" devup status Health check every service in config");
|
|
2519
|
+
out(" devup up -d Boot the stack in detached/daemon mode");
|
|
2520
|
+
out(" devup down Stop the running daemon");
|
|
2521
|
+
out(" devup ctl <method> [args] Control a running devup (restart/stop/logs/...)");
|
|
2522
|
+
out(" devup help [<subcommand>] Show detailed help for a subcommand");
|
|
2523
|
+
out("");
|
|
2524
|
+
out("No subcommand \u2192 launch the interactive TUI.");
|
|
2525
|
+
return 0;
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
// src/platform/detect.ts
|
|
2529
|
+
async function detectPlatform() {
|
|
2530
|
+
switch (process.platform) {
|
|
2531
|
+
case "linux": {
|
|
2532
|
+
const { LinuxPlatform } = await import("./linux-OQ3Q4Z2Z.js");
|
|
2533
|
+
return new LinuxPlatform();
|
|
2534
|
+
}
|
|
2535
|
+
case "darwin": {
|
|
2536
|
+
const { DarwinPlatform } = await import("./darwin-3KJ3IEXN.js");
|
|
2537
|
+
return new DarwinPlatform();
|
|
2538
|
+
}
|
|
2539
|
+
case "win32": {
|
|
2540
|
+
const { Win32Platform } = await import("./win32-3X2OLSI6.js");
|
|
2541
|
+
return new Win32Platform();
|
|
2542
|
+
}
|
|
2543
|
+
default:
|
|
2544
|
+
throw new Error(`Unsupported platform: ${process.platform}`);
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
// src/proxy-config/traefik.ts
|
|
2549
|
+
import { existsSync as existsSync12, mkdirSync as mkdirSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
2550
|
+
import { dirname as dirname4 } from "path";
|
|
2551
|
+
var EMPTY_CONFIG = "http:\n routers: {}\n services: {}\n";
|
|
2552
|
+
var TraefikProvider = class {
|
|
2553
|
+
name = "traefik";
|
|
2554
|
+
generate(services, opts) {
|
|
2555
|
+
const routers = [];
|
|
2556
|
+
const svcs = [];
|
|
2557
|
+
for (const [name, st] of services) {
|
|
2558
|
+
if (st.health !== "up") continue;
|
|
2559
|
+
const sub = opts.routes[name];
|
|
2560
|
+
if (sub === void 0) continue;
|
|
2561
|
+
const rule = sub ? `Host(\`${sub}.${opts.domain}\`)` : `Host(\`${opts.domain}\`)`;
|
|
2562
|
+
const safe = name.replace(/[^a-z0-9-]/g, "-");
|
|
2563
|
+
const port = st.realPort ?? st.port;
|
|
2564
|
+
let router = ` ${safe}:
|
|
2565
|
+
rule: "${rule}"
|
|
2566
|
+
service: ${safe}
|
|
2567
|
+
entryPoints:
|
|
2568
|
+
- ${opts.entrypoint}`;
|
|
2569
|
+
if (opts.tls) router += `
|
|
2570
|
+
tls:
|
|
2571
|
+
certResolver: le`;
|
|
2572
|
+
routers.push(router);
|
|
2573
|
+
svcs.push(` ${safe}:
|
|
2574
|
+
loadBalancer:
|
|
2575
|
+
servers:
|
|
2576
|
+
- url: "http://${opts.host}:${port}"`);
|
|
2577
|
+
}
|
|
2578
|
+
if (!routers.length) return EMPTY_CONFIG;
|
|
2579
|
+
return `http:
|
|
2580
|
+
routers:
|
|
2581
|
+
${routers.join("\n")}
|
|
2582
|
+
services:
|
|
2583
|
+
${svcs.join("\n")}
|
|
2584
|
+
`;
|
|
2585
|
+
}
|
|
2586
|
+
write(content, opts) {
|
|
2587
|
+
const dir = dirname4(opts.confPath);
|
|
2588
|
+
if (!existsSync12(dir)) mkdirSync4(dir, { recursive: true });
|
|
2589
|
+
writeFileSync3(opts.confPath, content);
|
|
2590
|
+
}
|
|
2591
|
+
clear(opts) {
|
|
2592
|
+
this.write(EMPTY_CONFIG, opts);
|
|
2593
|
+
}
|
|
2594
|
+
};
|
|
2595
|
+
|
|
2596
|
+
// src/proxy-config/nginx.ts
|
|
2597
|
+
import { existsSync as existsSync13, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
2598
|
+
import { dirname as dirname5 } from "path";
|
|
2599
|
+
var EMPTY_CONFIG2 = "# devup: no healthy services\n";
|
|
2600
|
+
var NginxProvider = class {
|
|
2601
|
+
name = "nginx";
|
|
2602
|
+
generate(services, opts) {
|
|
2603
|
+
const blocks = [];
|
|
2604
|
+
for (const [name, st] of services) {
|
|
2605
|
+
if (st.health !== "up") continue;
|
|
2606
|
+
const sub = opts.routes[name];
|
|
2607
|
+
if (sub === void 0) continue;
|
|
2608
|
+
const serverName = sub ? `${sub}.${opts.domain}` : opts.domain;
|
|
2609
|
+
const port = st.realPort ?? st.port;
|
|
2610
|
+
const listen = opts.tls ? "443 ssl" : "80";
|
|
2611
|
+
const tlsBlock = opts.tls ? ` ssl_certificate /etc/nginx/certs/${serverName}.crt;
|
|
2612
|
+
ssl_certificate_key /etc/nginx/certs/${serverName}.key;
|
|
2613
|
+
` : "";
|
|
2614
|
+
blocks.push(
|
|
2615
|
+
`server {
|
|
2616
|
+
listen ${listen};
|
|
2617
|
+
server_name ${serverName};
|
|
2618
|
+
` + tlsBlock + ` location / {
|
|
2619
|
+
proxy_pass http://${opts.host}:${port};
|
|
2620
|
+
proxy_set_header Host $host;
|
|
2621
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
2622
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
2623
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
2624
|
+
proxy_http_version 1.1;
|
|
2625
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
2626
|
+
proxy_set_header Connection "upgrade";
|
|
2627
|
+
}
|
|
2628
|
+
}`
|
|
2629
|
+
);
|
|
2630
|
+
}
|
|
2631
|
+
if (!blocks.length) return EMPTY_CONFIG2;
|
|
2632
|
+
return blocks.join("\n\n") + "\n";
|
|
2633
|
+
}
|
|
2634
|
+
write(content, opts) {
|
|
2635
|
+
const dir = dirname5(opts.confPath);
|
|
2636
|
+
if (!existsSync13(dir)) mkdirSync5(dir, { recursive: true });
|
|
2637
|
+
writeFileSync4(opts.confPath, content);
|
|
2638
|
+
}
|
|
2639
|
+
clear(opts) {
|
|
2640
|
+
this.write(EMPTY_CONFIG2, opts);
|
|
2641
|
+
}
|
|
2642
|
+
};
|
|
2643
|
+
|
|
2644
|
+
// src/proxy-config/caddy.ts
|
|
2645
|
+
import { existsSync as existsSync14, mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
2646
|
+
import { dirname as dirname6 } from "path";
|
|
2647
|
+
var EMPTY_CONFIG3 = "# devup: no healthy services\n";
|
|
2648
|
+
var CaddyProvider = class {
|
|
2649
|
+
name = "caddy";
|
|
2650
|
+
generate(services, opts) {
|
|
2651
|
+
const blocks = [];
|
|
2652
|
+
for (const [name, st] of services) {
|
|
2653
|
+
if (st.health !== "up") continue;
|
|
2654
|
+
const sub = opts.routes[name];
|
|
2655
|
+
if (sub === void 0) continue;
|
|
2656
|
+
const host = sub ? `${sub}.${opts.domain}` : opts.domain;
|
|
2657
|
+
const port = st.realPort ?? st.port;
|
|
2658
|
+
const siteAddr = opts.tls ? host : `http://${host}`;
|
|
2659
|
+
blocks.push(
|
|
2660
|
+
`${siteAddr} {
|
|
2661
|
+
reverse_proxy ${opts.host}:${port}
|
|
2662
|
+
}`
|
|
2663
|
+
);
|
|
2664
|
+
}
|
|
2665
|
+
if (!blocks.length) return EMPTY_CONFIG3;
|
|
2666
|
+
return blocks.join("\n\n") + "\n";
|
|
2667
|
+
}
|
|
2668
|
+
write(content, opts) {
|
|
2669
|
+
const dir = dirname6(opts.confPath);
|
|
2670
|
+
if (!existsSync14(dir)) mkdirSync6(dir, { recursive: true });
|
|
2671
|
+
writeFileSync5(opts.confPath, content);
|
|
2672
|
+
}
|
|
2673
|
+
clear(opts) {
|
|
2674
|
+
this.write(EMPTY_CONFIG3, opts);
|
|
2675
|
+
}
|
|
2676
|
+
};
|
|
2677
|
+
|
|
2678
|
+
// src/proxy-config/detect.ts
|
|
2679
|
+
var providers = {
|
|
2680
|
+
traefik: () => new TraefikProvider(),
|
|
2681
|
+
nginx: () => new NginxProvider(),
|
|
2682
|
+
caddy: () => new CaddyProvider()
|
|
2683
|
+
};
|
|
2684
|
+
function detectProxyProvider(name) {
|
|
2685
|
+
const factory = providers[name];
|
|
2686
|
+
if (!factory) {
|
|
2687
|
+
const available = Object.keys(providers).join(", ");
|
|
2688
|
+
throw new Error(`Unknown proxy provider: "${name}". Available: ${available}`);
|
|
2689
|
+
}
|
|
2690
|
+
return factory();
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
// src/tui/App.tsx
|
|
2694
|
+
import { useCallback as useCallback3, useRef as useRef5 } from "react";
|
|
2695
|
+
import { Box as Box6, Text as Text6 } from "ink";
|
|
2696
|
+
|
|
2697
|
+
// src/tui/hooks/useProcessManager.ts
|
|
2698
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
2699
|
+
function useProcessManager(platform, baseCwd, env, logSink = null) {
|
|
2700
|
+
const [states, setStates] = useState(/* @__PURE__ */ new Map());
|
|
2701
|
+
const [logs, setLogs] = useState([]);
|
|
2702
|
+
const [stats, setStats] = useState(/* @__PURE__ */ new Map());
|
|
2703
|
+
const mgrRef = useRef(null);
|
|
2704
|
+
const prevCpu = useRef(/* @__PURE__ */ new Map());
|
|
2705
|
+
const pausedRef = useRef(false);
|
|
2706
|
+
const pendingLogsRef = useRef([]);
|
|
2707
|
+
const sinkRef = useRef(logSink);
|
|
2708
|
+
sinkRef.current = logSink;
|
|
2709
|
+
const logBus = useRef(new Broadcaster());
|
|
2710
|
+
const stateBus = useRef(new Broadcaster());
|
|
2711
|
+
useEffect(() => {
|
|
2712
|
+
const mgr2 = new ProcessManager({
|
|
2713
|
+
baseCwd,
|
|
2714
|
+
env,
|
|
2715
|
+
platform,
|
|
2716
|
+
events: {
|
|
1507
2717
|
onLog: (svcName, text, colorIdx) => {
|
|
1508
2718
|
sinkRef.current?.write(svcName, text);
|
|
2719
|
+
logBus.current.emit({ svc: svcName, text });
|
|
1509
2720
|
const entry = { svcName, text, colorIdx, ts: Date.now(), level: detectLogLevel(text) };
|
|
1510
2721
|
if (pausedRef.current) {
|
|
1511
2722
|
pendingLogsRef.current.push(entry);
|
|
@@ -1519,7 +2730,10 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
|
|
|
1519
2730
|
return next.length > 5e3 ? next.slice(-5e3) : next;
|
|
1520
2731
|
});
|
|
1521
2732
|
},
|
|
1522
|
-
onStateChange: () =>
|
|
2733
|
+
onStateChange: (name, state) => {
|
|
2734
|
+
stateBus.current.emit({ name, state });
|
|
2735
|
+
setStates(new Map(mgr2.state));
|
|
2736
|
+
}
|
|
1523
2737
|
}
|
|
1524
2738
|
});
|
|
1525
2739
|
mgrRef.current = mgr2;
|
|
@@ -1564,6 +2778,7 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
|
|
|
1564
2778
|
}, []);
|
|
1565
2779
|
const pushLog = useCallback((svcName, text, colorIdx = 0) => {
|
|
1566
2780
|
sinkRef.current?.write(svcName, text);
|
|
2781
|
+
logBus.current.emit({ svc: svcName, text });
|
|
1567
2782
|
const entry = { svcName, text, colorIdx, ts: Date.now(), level: detectLogLevel(text) };
|
|
1568
2783
|
if (pausedRef.current) {
|
|
1569
2784
|
pendingLogsRef.current.push(entry);
|
|
@@ -1600,7 +2815,9 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
|
|
|
1600
2815
|
clearLogs,
|
|
1601
2816
|
setPaused,
|
|
1602
2817
|
pushLog,
|
|
1603
|
-
manager: mgr
|
|
2818
|
+
manager: mgr,
|
|
2819
|
+
logBus: logBus.current,
|
|
2820
|
+
stateBus: stateBus.current
|
|
1604
2821
|
};
|
|
1605
2822
|
}
|
|
1606
2823
|
|
|
@@ -1739,134 +2956,9 @@ function useLogsPause(setPaused, logsPaused, logsScrollOffset) {
|
|
|
1739
2956
|
|
|
1740
2957
|
// src/tui/hooks/useControlPlane.ts
|
|
1741
2958
|
import { useEffect as useEffect5, useRef as useRef3 } from "react";
|
|
1742
|
-
import { createInterface as
|
|
1743
|
-
import { createReadStream as
|
|
1744
|
-
|
|
1745
|
-
// src/control-plane/socket-server.ts
|
|
1746
|
-
import { createServer } from "net";
|
|
1747
|
-
import { createInterface as createInterface2 } from "readline";
|
|
1748
|
-
import { existsSync as existsSync11, unlinkSync, chmodSync, mkdirSync as mkdirSync4, statSync as statSync2 } from "fs";
|
|
1749
|
-
import { dirname as dirname5 } from "path";
|
|
1750
|
-
import { join as join6 } from "path";
|
|
1751
|
-
import { homedir as homedir2 } from "os";
|
|
1752
|
-
function defaultSocketPath(projectName) {
|
|
1753
|
-
const safe = projectName.replace(/[^a-zA-Z0-9._-]+/g, "_") || "devup";
|
|
1754
|
-
return join6(homedir2(), ".devup", `sock-${safe}.sock`);
|
|
1755
|
-
}
|
|
1756
|
-
async function startSocketServer(projectName, ctx, opts = {}) {
|
|
1757
|
-
const path = opts.path ?? defaultSocketPath(projectName);
|
|
1758
|
-
mkdirSync4(dirname5(path), { recursive: true });
|
|
1759
|
-
if (existsSync11(path)) {
|
|
1760
|
-
try {
|
|
1761
|
-
const st = statSync2(path);
|
|
1762
|
-
if (st.isSocket()) unlinkSync(path);
|
|
1763
|
-
} catch {
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
|
-
const server = createServer((socket) => handleClient(socket, ctx));
|
|
1767
|
-
await new Promise((resolve4, reject) => {
|
|
1768
|
-
server.once("error", reject);
|
|
1769
|
-
server.listen(path, () => {
|
|
1770
|
-
server.off("error", reject);
|
|
1771
|
-
try {
|
|
1772
|
-
chmodSync(path, 384);
|
|
1773
|
-
} catch {
|
|
1774
|
-
}
|
|
1775
|
-
opts.onLog?.(`\u{1F50C} control plane at ${path}`);
|
|
1776
|
-
resolve4();
|
|
1777
|
-
});
|
|
1778
|
-
});
|
|
1779
|
-
return {
|
|
1780
|
-
server,
|
|
1781
|
-
path,
|
|
1782
|
-
async close() {
|
|
1783
|
-
await new Promise((resolve4) => server.close(() => resolve4()));
|
|
1784
|
-
if (existsSync11(path)) {
|
|
1785
|
-
try {
|
|
1786
|
-
unlinkSync(path);
|
|
1787
|
-
} catch {
|
|
1788
|
-
}
|
|
1789
|
-
}
|
|
1790
|
-
}
|
|
1791
|
-
};
|
|
1792
|
-
}
|
|
1793
|
-
function handleClient(socket, ctx) {
|
|
1794
|
-
const rl = createInterface2({ input: socket });
|
|
1795
|
-
rl.on("line", async (line) => {
|
|
1796
|
-
if (!line.trim()) return;
|
|
1797
|
-
let req;
|
|
1798
|
-
try {
|
|
1799
|
-
req = JSON.parse(line);
|
|
1800
|
-
} catch (e) {
|
|
1801
|
-
respond(socket, { error: { code: -32700, message: `parse error: ${e.message}` } });
|
|
1802
|
-
return;
|
|
1803
|
-
}
|
|
1804
|
-
if (typeof req.method !== "string") {
|
|
1805
|
-
respond(socket, { id: req.id, error: { code: -32600, message: "method required" } });
|
|
1806
|
-
return;
|
|
1807
|
-
}
|
|
1808
|
-
try {
|
|
1809
|
-
const result = await dispatch(req.method, req.params ?? {}, ctx);
|
|
1810
|
-
respond(socket, { id: req.id, result });
|
|
1811
|
-
} catch (e) {
|
|
1812
|
-
respond(socket, { id: req.id, error: { code: -32603, message: e.message ?? String(e) } });
|
|
1813
|
-
}
|
|
1814
|
-
});
|
|
1815
|
-
socket.on("error", () => {
|
|
1816
|
-
});
|
|
1817
|
-
}
|
|
1818
|
-
function respond(socket, payload) {
|
|
1819
|
-
if (socket.writable) socket.write(JSON.stringify(payload) + "\n");
|
|
1820
|
-
}
|
|
1821
|
-
async function dispatch(method, params, ctx) {
|
|
1822
|
-
switch (method) {
|
|
1823
|
-
case "status": {
|
|
1824
|
-
const out = [];
|
|
1825
|
-
for (const [name, st] of ctx.states()) {
|
|
1826
|
-
out.push({
|
|
1827
|
-
name,
|
|
1828
|
-
status: st.status,
|
|
1829
|
-
health: st.health,
|
|
1830
|
-
port: st.svc.port,
|
|
1831
|
-
type: st.svc.type,
|
|
1832
|
-
errors: st.errors,
|
|
1833
|
-
restarts: st.restarts,
|
|
1834
|
-
pid: st.pid,
|
|
1835
|
-
startedAt: st.startedAt
|
|
1836
|
-
});
|
|
1837
|
-
}
|
|
1838
|
-
return { services: out };
|
|
1839
|
-
}
|
|
1840
|
-
case "restart": {
|
|
1841
|
-
const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
|
|
1842
|
-
await ctx.restart(svc);
|
|
1843
|
-
return { ok: true };
|
|
1844
|
-
}
|
|
1845
|
-
case "stop": {
|
|
1846
|
-
const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
|
|
1847
|
-
ctx.stop(svc);
|
|
1848
|
-
return { ok: true };
|
|
1849
|
-
}
|
|
1850
|
-
case "logs.tail": {
|
|
1851
|
-
const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
|
|
1852
|
-
const lines = Math.max(1, Math.min(1e4, Number(params["lines"] ?? 100)));
|
|
1853
|
-
return { lines: await ctx.tailLogs(svc, lines) };
|
|
1854
|
-
}
|
|
1855
|
-
case "ping":
|
|
1856
|
-
return { ok: true, ts: Date.now() };
|
|
1857
|
-
default:
|
|
1858
|
-
throw new Error(`unknown method: ${method}`);
|
|
1859
|
-
}
|
|
1860
|
-
}
|
|
1861
|
-
function stringOrThrow(v, paramName) {
|
|
1862
|
-
if (typeof v !== "string" || !v.trim()) {
|
|
1863
|
-
throw new Error(`param "${paramName}" must be a non-empty string`);
|
|
1864
|
-
}
|
|
1865
|
-
return v;
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
// src/tui/hooks/useControlPlane.ts
|
|
1869
|
-
function useControlPlane(manager, projectName, logSink, pushLog) {
|
|
2959
|
+
import { createInterface as createInterface5 } from "readline";
|
|
2960
|
+
import { createReadStream as createReadStream3, existsSync as existsSync15 } from "fs";
|
|
2961
|
+
function useControlPlane(manager, projectName, logSink, pushLog, logBus, stateBus) {
|
|
1870
2962
|
const handleRef = useRef3(null);
|
|
1871
2963
|
useEffect5(() => {
|
|
1872
2964
|
if (!manager) return;
|
|
@@ -1880,10 +2972,10 @@ function useControlPlane(manager, projectName, logSink, pushLog) {
|
|
|
1880
2972
|
tailLogs: async (svcName, lines) => {
|
|
1881
2973
|
if (!logSink) return [];
|
|
1882
2974
|
const file = logSink.pathFor(svcName);
|
|
1883
|
-
if (!
|
|
2975
|
+
if (!existsSync15(file)) return [];
|
|
1884
2976
|
return new Promise((resolve4, reject) => {
|
|
1885
2977
|
const buf = [];
|
|
1886
|
-
const rl =
|
|
2978
|
+
const rl = createInterface5({ input: createReadStream3(file, { encoding: "utf8" }) });
|
|
1887
2979
|
rl.on("line", (l) => {
|
|
1888
2980
|
buf.push(l);
|
|
1889
2981
|
if (buf.length > lines) buf.shift();
|
|
@@ -1891,6 +2983,14 @@ function useControlPlane(manager, projectName, logSink, pushLog) {
|
|
|
1891
2983
|
rl.on("close", () => resolve4(buf));
|
|
1892
2984
|
rl.on("error", reject);
|
|
1893
2985
|
});
|
|
2986
|
+
},
|
|
2987
|
+
watchLogs: (svcName, onLine) => {
|
|
2988
|
+
return logBus.subscribe(({ svc, text }) => {
|
|
2989
|
+
if (svcName === null || svc === svcName) onLine(svc, text);
|
|
2990
|
+
});
|
|
2991
|
+
},
|
|
2992
|
+
watchStatus: (onUpdate) => {
|
|
2993
|
+
return stateBus.subscribe(({ name, state }) => onUpdate(name, state));
|
|
1894
2994
|
}
|
|
1895
2995
|
}, { onLog: (msg) => pushLog("devup", msg, 12) });
|
|
1896
2996
|
handleRef.current = handle;
|
|
@@ -1902,72 +3002,15 @@ function useControlPlane(manager, projectName, logSink, pushLog) {
|
|
|
1902
3002
|
void handle?.close();
|
|
1903
3003
|
handleRef.current = null;
|
|
1904
3004
|
};
|
|
1905
|
-
}, [manager, projectName, logSink, pushLog]);
|
|
3005
|
+
}, [manager, projectName, logSink, pushLog, logBus, stateBus]);
|
|
1906
3006
|
return handleRef;
|
|
1907
3007
|
}
|
|
1908
3008
|
|
|
1909
3009
|
// src/tui/hooks/useHotReload.ts
|
|
1910
3010
|
import { useEffect as useEffect6 } from "react";
|
|
1911
|
-
import { watch as fsWatch } from "fs";
|
|
1912
|
-
|
|
1913
|
-
// src/config/diff.ts
|
|
1914
|
-
var SPAWN_RELEVANT = [
|
|
1915
|
-
"cwd",
|
|
1916
|
-
"cmd",
|
|
1917
|
-
"args",
|
|
1918
|
-
"port",
|
|
1919
|
-
"phase",
|
|
1920
|
-
"maxMem",
|
|
1921
|
-
"preBuild",
|
|
1922
|
-
"watchBuild",
|
|
1923
|
-
"nodeArgs",
|
|
1924
|
-
"extraEnv",
|
|
1925
|
-
"healthCheck",
|
|
1926
|
-
"readyPattern",
|
|
1927
|
-
"errorPattern",
|
|
1928
|
-
"type"
|
|
1929
|
-
];
|
|
1930
|
-
function hasSpawnRelevantChange(prev, next) {
|
|
1931
|
-
for (const k of SPAWN_RELEVANT) {
|
|
1932
|
-
if (JSON.stringify(prev[k]) !== JSON.stringify(next[k])) return true;
|
|
1933
|
-
}
|
|
1934
|
-
return false;
|
|
1935
|
-
}
|
|
1936
|
-
function diffServices(prev, next) {
|
|
1937
|
-
const prevByName = new Map(prev.map((s) => [s.name, s]));
|
|
1938
|
-
const nextByName = new Map(next.map((s) => [s.name, s]));
|
|
1939
|
-
const added = [];
|
|
1940
|
-
const removed = [];
|
|
1941
|
-
const changed = [];
|
|
1942
|
-
const unchanged = [];
|
|
1943
|
-
for (const [name, p] of prevByName) {
|
|
1944
|
-
if (!nextByName.has(name)) {
|
|
1945
|
-
removed.push(name);
|
|
1946
|
-
continue;
|
|
1947
|
-
}
|
|
1948
|
-
const n = nextByName.get(name);
|
|
1949
|
-
if (hasSpawnRelevantChange(p, n)) changed.push({ prev: p, next: n });
|
|
1950
|
-
else unchanged.push(name);
|
|
1951
|
-
}
|
|
1952
|
-
for (const [name, n] of nextByName) {
|
|
1953
|
-
if (!prevByName.has(name)) added.push(n);
|
|
1954
|
-
}
|
|
1955
|
-
return { added, removed, changed, unchanged };
|
|
1956
|
-
}
|
|
1957
|
-
function summariseDiff(d) {
|
|
1958
|
-
const parts = [];
|
|
1959
|
-
if (d.added.length) parts.push(`+${d.added.length} added`);
|
|
1960
|
-
if (d.removed.length) parts.push(`-${d.removed.length} removed`);
|
|
1961
|
-
if (d.changed.length) parts.push(`~${d.changed.length} changed`);
|
|
1962
|
-
if (!parts.length) parts.push("no changes");
|
|
1963
|
-
return parts.join(", ");
|
|
1964
|
-
}
|
|
1965
|
-
|
|
1966
|
-
// src/tui/hooks/useHotReload.ts
|
|
1967
3011
|
function useHotReload(manager, cliArgs, baseCwd, pushLog) {
|
|
1968
3012
|
useEffect6(() => {
|
|
1969
3013
|
if (!cliArgs.watchConfig || !manager) return;
|
|
1970
|
-
let watcher = null;
|
|
1971
3014
|
let configPath;
|
|
1972
3015
|
try {
|
|
1973
3016
|
configPath = findConfigFile(baseCwd, cliArgs.configPath);
|
|
@@ -1975,64 +3018,13 @@ function useHotReload(manager, cliArgs, baseCwd, pushLog) {
|
|
|
1975
3018
|
pushLog("devup", `\u26A0 watch-config disabled: ${e.message}`, 5);
|
|
1976
3019
|
return;
|
|
1977
3020
|
}
|
|
1978
|
-
pushLog("devup", `\u{1F440} watching ${configPath}`, 12);
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
return;
|
|
1985
|
-
}
|
|
1986
|
-
reloadInFlight = true;
|
|
1987
|
-
try {
|
|
1988
|
-
const nextCfg = await loadConfig(configPath);
|
|
1989
|
-
const errs = validateConfig(nextCfg, baseCwd);
|
|
1990
|
-
if (errs.length) {
|
|
1991
|
-
pushLog("devup", `\u26A0 config reload failed:
|
|
1992
|
-
${formatValidationErrors(errs)}`, 5);
|
|
1993
|
-
return;
|
|
1994
|
-
}
|
|
1995
|
-
const currentSvcs = [...manager.state.values()].map((s) => s.svc);
|
|
1996
|
-
const diff = diffServices(currentSvcs, nextCfg.services);
|
|
1997
|
-
if (!diff.added.length && !diff.removed.length && !diff.changed.length) return;
|
|
1998
|
-
for (const name of diff.removed) {
|
|
1999
|
-
manager.stop(name);
|
|
2000
|
-
manager.state.delete(name);
|
|
2001
|
-
}
|
|
2002
|
-
let colorIdx = currentSvcs.length;
|
|
2003
|
-
for (const { next } of diff.changed) {
|
|
2004
|
-
const prev = manager.state.get(next.name);
|
|
2005
|
-
const ci = prev?.colorIdx ?? colorIdx++;
|
|
2006
|
-
manager.stop(next.name);
|
|
2007
|
-
await new Promise((r) => setTimeout(r, 800));
|
|
2008
|
-
await manager.install(next, ci);
|
|
2009
|
-
await manager.start(next, ci, true);
|
|
2010
|
-
}
|
|
2011
|
-
for (const next of diff.added) {
|
|
2012
|
-
const ci = colorIdx++;
|
|
2013
|
-
await manager.install(next, ci);
|
|
2014
|
-
await manager.start(next, ci);
|
|
2015
|
-
}
|
|
2016
|
-
pushLog("devup", `\u{1F501} config reloaded: ${summariseDiff(diff)}`, 12);
|
|
2017
|
-
} catch (e) {
|
|
2018
|
-
pushLog("devup", `\u26A0 config reload error: ${e.message}`, 5);
|
|
2019
|
-
} finally {
|
|
2020
|
-
reloadInFlight = false;
|
|
2021
|
-
if (reloadAgain) {
|
|
2022
|
-
reloadAgain = false;
|
|
2023
|
-
void reload();
|
|
2024
|
-
}
|
|
2025
|
-
}
|
|
2026
|
-
};
|
|
2027
|
-
let debounceTimer = null;
|
|
2028
|
-
watcher = fsWatch(configPath, () => {
|
|
2029
|
-
if (debounceTimer) clearTimeout(debounceTimer);
|
|
2030
|
-
debounceTimer = setTimeout(() => void reload(), 250);
|
|
3021
|
+
pushLog("devup", `\u{1F440} watching ${configPath}`, 12);
|
|
3022
|
+
return watchConfig({
|
|
3023
|
+
configPath,
|
|
3024
|
+
baseCwd,
|
|
3025
|
+
manager,
|
|
3026
|
+
log: (msg) => pushLog("devup", msg, msg.startsWith("\u26A0") ? 5 : 12)
|
|
2031
3027
|
});
|
|
2032
|
-
return () => {
|
|
2033
|
-
if (debounceTimer) clearTimeout(debounceTimer);
|
|
2034
|
-
watcher?.close();
|
|
2035
|
-
};
|
|
2036
3028
|
}, [cliArgs.watchConfig, cliArgs.configPath, baseCwd, manager, pushLog]);
|
|
2037
3029
|
}
|
|
2038
3030
|
|
|
@@ -2289,201 +3281,6 @@ function useContextualTips(totalLogs, hasSearch, hasFilter, states) {
|
|
|
2289
3281
|
|
|
2290
3282
|
// src/tui/hooks/useBootSequence.ts
|
|
2291
3283
|
import { useEffect as useEffect9, useState as useState6 } from "react";
|
|
2292
|
-
|
|
2293
|
-
// src/lazy/proxy.ts
|
|
2294
|
-
import net2 from "net";
|
|
2295
|
-
function createLazyProxy(opts) {
|
|
2296
|
-
const { listenPort, targetPort, timeoutMin, onDemandStart, onIdleStop, isAlive, onLog } = opts;
|
|
2297
|
-
let idleTimer = null;
|
|
2298
|
-
let lastActivity = Date.now();
|
|
2299
|
-
let starting = false;
|
|
2300
|
-
let serviceReady = false;
|
|
2301
|
-
let pendingConns = [];
|
|
2302
|
-
const activeConns = /* @__PURE__ */ new Set();
|
|
2303
|
-
function bumpActivity() {
|
|
2304
|
-
lastActivity = Date.now();
|
|
2305
|
-
}
|
|
2306
|
-
function scheduleIdleCheck() {
|
|
2307
|
-
if (idleTimer) clearTimeout(idleTimer);
|
|
2308
|
-
if (timeoutMin <= 0) return;
|
|
2309
|
-
const periodMs = timeoutMin * 6e4;
|
|
2310
|
-
idleTimer = setTimeout(() => {
|
|
2311
|
-
const elapsed = Date.now() - lastActivity;
|
|
2312
|
-
if (activeConns.size > 0 || elapsed < periodMs) {
|
|
2313
|
-
scheduleIdleCheck();
|
|
2314
|
-
return;
|
|
2315
|
-
}
|
|
2316
|
-
serviceReady = false;
|
|
2317
|
-
onLog?.(`\u{1F4A4} idle ${timeoutMin}min \u2014 stopping`);
|
|
2318
|
-
onIdleStop();
|
|
2319
|
-
}, periodMs);
|
|
2320
|
-
}
|
|
2321
|
-
function pipeToTarget(client) {
|
|
2322
|
-
const target = net2.createConnection({ port: targetPort, host: "127.0.0.1", allowHalfOpen: true });
|
|
2323
|
-
activeConns.add(client);
|
|
2324
|
-
const cleanup = () => {
|
|
2325
|
-
activeConns.delete(client);
|
|
2326
|
-
bumpActivity();
|
|
2327
|
-
};
|
|
2328
|
-
target.on("error", () => {
|
|
2329
|
-
client.destroy();
|
|
2330
|
-
cleanup();
|
|
2331
|
-
});
|
|
2332
|
-
client.on("error", () => {
|
|
2333
|
-
target.destroy();
|
|
2334
|
-
cleanup();
|
|
2335
|
-
});
|
|
2336
|
-
client.on("close", cleanup);
|
|
2337
|
-
target.on("close", cleanup);
|
|
2338
|
-
target.on("connect", () => {
|
|
2339
|
-
target.on("data", (chunk) => {
|
|
2340
|
-
bumpActivity();
|
|
2341
|
-
if (!client.destroyed) client.write(chunk);
|
|
2342
|
-
});
|
|
2343
|
-
client.on("data", (chunk) => {
|
|
2344
|
-
bumpActivity();
|
|
2345
|
-
if (!target.destroyed) target.write(chunk);
|
|
2346
|
-
});
|
|
2347
|
-
target.on("end", () => {
|
|
2348
|
-
if (!client.destroyed) client.end();
|
|
2349
|
-
});
|
|
2350
|
-
client.on("end", () => {
|
|
2351
|
-
if (!target.destroyed) target.end();
|
|
2352
|
-
});
|
|
2353
|
-
});
|
|
2354
|
-
}
|
|
2355
|
-
async function handleConnection(client) {
|
|
2356
|
-
bumpActivity();
|
|
2357
|
-
client.on("error", () => {
|
|
2358
|
-
});
|
|
2359
|
-
if (serviceReady && isAlive()) {
|
|
2360
|
-
pipeToTarget(client);
|
|
2361
|
-
return;
|
|
2362
|
-
}
|
|
2363
|
-
pendingConns.push(client);
|
|
2364
|
-
client.on("close", () => {
|
|
2365
|
-
pendingConns = pendingConns.filter((s) => s !== client);
|
|
2366
|
-
});
|
|
2367
|
-
if (starting) return;
|
|
2368
|
-
starting = true;
|
|
2369
|
-
onLog?.("\u26A1 on-demand start");
|
|
2370
|
-
let ok = false;
|
|
2371
|
-
try {
|
|
2372
|
-
await onDemandStart();
|
|
2373
|
-
ok = await waitForPort(targetPort, { timeout: 45e3, interval: 500 });
|
|
2374
|
-
if (ok) serviceReady = true;
|
|
2375
|
-
else onLog?.("\u26A0 timeout waiting for service");
|
|
2376
|
-
} catch (e) {
|
|
2377
|
-
onLog?.(`\u274C start failed: ${e.message}`);
|
|
2378
|
-
}
|
|
2379
|
-
starting = false;
|
|
2380
|
-
const conns = pendingConns.splice(0);
|
|
2381
|
-
if (!ok) {
|
|
2382
|
-
for (const conn of conns) {
|
|
2383
|
-
if (!conn.destroyed) conn.destroy();
|
|
2384
|
-
}
|
|
2385
|
-
return;
|
|
2386
|
-
}
|
|
2387
|
-
for (const conn of conns) {
|
|
2388
|
-
if (!conn.destroyed) pipeToTarget(conn);
|
|
2389
|
-
}
|
|
2390
|
-
}
|
|
2391
|
-
const server = net2.createServer({ allowHalfOpen: true }, (socket) => handleConnection(socket));
|
|
2392
|
-
server.listen(listenPort, "0.0.0.0");
|
|
2393
|
-
scheduleIdleCheck();
|
|
2394
|
-
return {
|
|
2395
|
-
server,
|
|
2396
|
-
resetTimer: bumpActivity,
|
|
2397
|
-
destroy: () => {
|
|
2398
|
-
if (idleTimer) clearTimeout(idleTimer);
|
|
2399
|
-
pendingConns.forEach((s) => s.destroy());
|
|
2400
|
-
activeConns.forEach((s) => s.destroy());
|
|
2401
|
-
server.close();
|
|
2402
|
-
}
|
|
2403
|
-
};
|
|
2404
|
-
}
|
|
2405
|
-
|
|
2406
|
-
// src/process/external.ts
|
|
2407
|
-
import { spawn as spawn4 } from "child_process";
|
|
2408
|
-
import { join as join7 } from "path";
|
|
2409
|
-
var DEFAULT_START_TIMEOUT_S = 60;
|
|
2410
|
-
async function startExternals(externals, opts) {
|
|
2411
|
-
const procs = [];
|
|
2412
|
-
const failed = [];
|
|
2413
|
-
for (const svc of externals) {
|
|
2414
|
-
const proc = spawnExternal(svc, opts);
|
|
2415
|
-
procs.push({ svc, proc, pid: proc.pid ?? null });
|
|
2416
|
-
if (!svc.healthCheck) {
|
|
2417
|
-
opts.onLog?.(svc.name, "\u2705 started (no healthCheck)");
|
|
2418
|
-
continue;
|
|
2419
|
-
}
|
|
2420
|
-
if (svc.healthCheck.type === "tcp" && !svc.port) {
|
|
2421
|
-
opts.onLog?.(svc.name, "\u26A0 tcp healthCheck requires `port` \u2014 skipping wait");
|
|
2422
|
-
continue;
|
|
2423
|
-
}
|
|
2424
|
-
const timeoutMs = (svc.startTimeout ?? DEFAULT_START_TIMEOUT_S) * 1e3;
|
|
2425
|
-
const ok = await waitHealthy(svc, timeoutMs);
|
|
2426
|
-
if (ok) {
|
|
2427
|
-
opts.onLog?.(svc.name, "\u2705 healthy");
|
|
2428
|
-
} else {
|
|
2429
|
-
opts.onLog?.(svc.name, `\u274C never became healthy (timeout ${timeoutMs / 1e3}s)`);
|
|
2430
|
-
failed.push(svc.name);
|
|
2431
|
-
}
|
|
2432
|
-
}
|
|
2433
|
-
return { procs, allHealthy: failed.length === 0, failed };
|
|
2434
|
-
}
|
|
2435
|
-
async function stopExternals(procs, platform, opts = {}) {
|
|
2436
|
-
for (const { svc, proc, pid } of procs) {
|
|
2437
|
-
try {
|
|
2438
|
-
if (pid) platform.killTree(pid);
|
|
2439
|
-
if (svc.stopCmd) {
|
|
2440
|
-
opts.onLog?.(svc.name, `\u{1F9F9} ${svc.stopCmd}`);
|
|
2441
|
-
await new Promise((resolve4) => {
|
|
2442
|
-
const isWin = process.platform === "win32";
|
|
2443
|
-
const shell = isWin ? "cmd.exe" : "sh";
|
|
2444
|
-
const flag = isWin ? "/c" : "-c";
|
|
2445
|
-
const cwd = svc.cwd ? join7(opts.baseCwd, svc.cwd) : opts.baseCwd;
|
|
2446
|
-
const env = { ...opts.env, ...svc.extraEnv ?? {} };
|
|
2447
|
-
const child = spawn4(shell, [flag, svc.stopCmd], { cwd, env, stdio: "ignore" });
|
|
2448
|
-
child.on("close", () => resolve4());
|
|
2449
|
-
child.on("error", () => resolve4());
|
|
2450
|
-
setTimeout(() => resolve4(), 1e4);
|
|
2451
|
-
});
|
|
2452
|
-
}
|
|
2453
|
-
} catch {
|
|
2454
|
-
}
|
|
2455
|
-
void proc;
|
|
2456
|
-
}
|
|
2457
|
-
}
|
|
2458
|
-
function spawnExternal(svc, opts) {
|
|
2459
|
-
const isWin = process.platform === "win32";
|
|
2460
|
-
const shell = isWin ? "cmd.exe" : "sh";
|
|
2461
|
-
const flag = isWin ? "/c" : "-c";
|
|
2462
|
-
const cwd = svc.cwd ? join7(opts.baseCwd, svc.cwd) : opts.baseCwd;
|
|
2463
|
-
const env = { ...opts.env, ...svc.extraEnv ?? {} };
|
|
2464
|
-
opts.onLog?.(svc.name, `\u{1F680} ${svc.cmd}`);
|
|
2465
|
-
const child = spawn4(shell, [flag, svc.cmd], {
|
|
2466
|
-
cwd,
|
|
2467
|
-
env,
|
|
2468
|
-
detached: true,
|
|
2469
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
2470
|
-
});
|
|
2471
|
-
child.stdout?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
|
|
2472
|
-
child.stderr?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
|
|
2473
|
-
child.on("error", (err) => opts.onLog?.(svc.name, `\u274C spawn error: ${err.message}`));
|
|
2474
|
-
return child;
|
|
2475
|
-
}
|
|
2476
|
-
async function waitHealthy(svc, timeoutMs) {
|
|
2477
|
-
const deadline = Date.now() + timeoutMs;
|
|
2478
|
-
const port = svc.port;
|
|
2479
|
-
while (Date.now() < deadline) {
|
|
2480
|
-
if (await checkHealth(port, svc.healthCheck)) return true;
|
|
2481
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
2482
|
-
}
|
|
2483
|
-
return false;
|
|
2484
|
-
}
|
|
2485
|
-
|
|
2486
|
-
// src/tui/hooks/useBootSequence.ts
|
|
2487
3284
|
function useBootSequence(manager, config, services, cliArgs, platform, env, baseCwd, refs, pushLog) {
|
|
2488
3285
|
const [booted, setBooted] = useState6(false);
|
|
2489
3286
|
useEffect9(() => {
|
|
@@ -2813,7 +3610,7 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
|
|
|
2813
3610
|
onToggleProxy: () => {
|
|
2814
3611
|
}
|
|
2815
3612
|
});
|
|
2816
|
-
const socketServer = useControlPlane(pm.manager, config.name, logSink, pm.pushLog);
|
|
3613
|
+
const socketServer = useControlPlane(pm.manager, config.name, logSink, pm.pushLog, pm.logBus, pm.stateBus);
|
|
2817
3614
|
const shutdown = useCallback3(async () => {
|
|
2818
3615
|
lazyProxies.current.forEach((p) => p.destroy());
|
|
2819
3616
|
await socketServer.current?.close();
|
|
@@ -2916,61 +3713,6 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
|
|
|
2916
3713
|
] });
|
|
2917
3714
|
}
|
|
2918
3715
|
|
|
2919
|
-
// src/process/log-sink.ts
|
|
2920
|
-
import { existsSync as existsSync13, mkdirSync as mkdirSync5, renameSync, createWriteStream } from "fs";
|
|
2921
|
-
import { join as join8, dirname as dirname6 } from "path";
|
|
2922
|
-
import { homedir as homedir3 } from "os";
|
|
2923
|
-
var LogSink = class {
|
|
2924
|
-
dir;
|
|
2925
|
-
rotateOnStart;
|
|
2926
|
-
streams = /* @__PURE__ */ new Map();
|
|
2927
|
-
seen = /* @__PURE__ */ new Set();
|
|
2928
|
-
constructor(opts) {
|
|
2929
|
-
const root = opts.rootDir ?? join8(homedir3(), ".devup", "logs");
|
|
2930
|
-
this.dir = join8(root, sanitize2(opts.projectName));
|
|
2931
|
-
this.rotateOnStart = opts.rotateOnStart ?? true;
|
|
2932
|
-
mkdirSync5(this.dir, { recursive: true });
|
|
2933
|
-
}
|
|
2934
|
-
/** Returns the file path for a service log (useful for tests / UI). */
|
|
2935
|
-
pathFor(svcName) {
|
|
2936
|
-
return join8(this.dir, `${sanitize2(svcName)}.log`);
|
|
2937
|
-
}
|
|
2938
|
-
write(svcName, line) {
|
|
2939
|
-
const stream = this.streamFor(svcName);
|
|
2940
|
-
stream.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
2941
|
-
`);
|
|
2942
|
-
}
|
|
2943
|
-
async close() {
|
|
2944
|
-
const closes = [...this.streams.values()].map(
|
|
2945
|
-
(s) => new Promise((r) => s.end(() => r()))
|
|
2946
|
-
);
|
|
2947
|
-
this.streams.clear();
|
|
2948
|
-
this.seen.clear();
|
|
2949
|
-
await Promise.all(closes);
|
|
2950
|
-
}
|
|
2951
|
-
streamFor(svcName) {
|
|
2952
|
-
let s = this.streams.get(svcName);
|
|
2953
|
-
if (s) return s;
|
|
2954
|
-
const file = this.pathFor(svcName);
|
|
2955
|
-
if (this.rotateOnStart && !this.seen.has(svcName) && existsSync13(file)) {
|
|
2956
|
-
try {
|
|
2957
|
-
mkdirSync5(dirname6(file), { recursive: true });
|
|
2958
|
-
renameSync(file, file + ".prev");
|
|
2959
|
-
} catch {
|
|
2960
|
-
}
|
|
2961
|
-
}
|
|
2962
|
-
this.seen.add(svcName);
|
|
2963
|
-
s = createWriteStream(file, { flags: "a" });
|
|
2964
|
-
s.on("error", () => {
|
|
2965
|
-
});
|
|
2966
|
-
this.streams.set(svcName, s);
|
|
2967
|
-
return s;
|
|
2968
|
-
}
|
|
2969
|
-
};
|
|
2970
|
-
function sanitize2(name) {
|
|
2971
|
-
return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
|
|
2972
|
-
}
|
|
2973
|
-
|
|
2974
3716
|
// src/orchestrator/dry-run.ts
|
|
2975
3717
|
function renderDryRun(opts) {
|
|
2976
3718
|
const { config, services, cliArgs, env, proxyProvider, proxyOpts } = opts;
|
|
@@ -3141,8 +3883,8 @@ function defineConfig(config) {
|
|
|
3141
3883
|
function readVersion() {
|
|
3142
3884
|
try {
|
|
3143
3885
|
const here = dirname7(fileURLToPath2(import.meta.url));
|
|
3144
|
-
const pkgPath =
|
|
3145
|
-
return JSON.parse(
|
|
3886
|
+
const pkgPath = join10(here, "..", "package.json");
|
|
3887
|
+
return JSON.parse(readFileSync4(pkgPath, "utf8")).version ?? "unknown";
|
|
3146
3888
|
} catch {
|
|
3147
3889
|
return "unknown";
|
|
3148
3890
|
}
|
|
@@ -3177,6 +3919,8 @@ async function main() {
|
|
|
3177
3919
|
if (subcmd === "logs") process.exit(await runLogs(subArgs, subOpts));
|
|
3178
3920
|
if (subcmd === "install") process.exit(await runInstall(subOpts));
|
|
3179
3921
|
if (subcmd === "status") process.exit(await runStatus(subOpts));
|
|
3922
|
+
if (subcmd === "ctl") process.exit(await runCtl(subArgs, subOpts));
|
|
3923
|
+
if (subcmd === "down") process.exit(await runDown(subOpts));
|
|
3180
3924
|
}
|
|
3181
3925
|
let configPath;
|
|
3182
3926
|
try {
|
|
@@ -3209,7 +3953,7 @@ ${formatValidationWarnings(warnings)}`);
|
|
|
3209
3953
|
process.exit(1);
|
|
3210
3954
|
}
|
|
3211
3955
|
const platform = await detectPlatform();
|
|
3212
|
-
const envFile = config.envFile ?
|
|
3956
|
+
const envFile = config.envFile ? join10(cwd, config.envFile) : join10(cwd, ".env");
|
|
3213
3957
|
const env = parseEnvFile(envFile, process.env);
|
|
3214
3958
|
if (config.env) {
|
|
3215
3959
|
for (const [k, v] of Object.entries(config.env)) {
|
|
@@ -3226,7 +3970,7 @@ ${formatValidationWarnings(warnings)}`);
|
|
|
3226
3970
|
routes: config.proxy.routes,
|
|
3227
3971
|
tls: cliArgs.proxyTls ?? config.proxy.tls ?? true,
|
|
3228
3972
|
entrypoint: cliArgs.proxyEntrypoint ?? config.proxy.entrypoint ?? "websecure",
|
|
3229
|
-
confPath: cliArgs.proxyConf ?? config.proxy.confPath ??
|
|
3973
|
+
confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join10(homedir5(), ".traefik", "traefik_conf.yaml")
|
|
3230
3974
|
};
|
|
3231
3975
|
}
|
|
3232
3976
|
if (cliArgs.dryRun) {
|
|
@@ -3250,6 +3994,36 @@ ${formatValidationWarnings(warnings)}`);
|
|
|
3250
3994
|
await logSink?.close();
|
|
3251
3995
|
process.exit(code);
|
|
3252
3996
|
}
|
|
3997
|
+
if (process.env.DEVUP_DAEMON_CHILD === "1") {
|
|
3998
|
+
await daemonBody({ config, services, cliArgs, platform, env, baseCwd: cwd, proxyProvider, proxyOpts });
|
|
3999
|
+
return;
|
|
4000
|
+
}
|
|
4001
|
+
if (subcmd === "up") {
|
|
4002
|
+
if (!raw.includes("-d") && !raw.includes("--detach")) {
|
|
4003
|
+
console.error("usage: devup up -d (use plain `devup` for the TUI)");
|
|
4004
|
+
process.exit(1);
|
|
4005
|
+
}
|
|
4006
|
+
process.exit(await runDetached({
|
|
4007
|
+
config,
|
|
4008
|
+
services,
|
|
4009
|
+
cliArgs,
|
|
4010
|
+
platform,
|
|
4011
|
+
env,
|
|
4012
|
+
baseCwd: cwd,
|
|
4013
|
+
proxyProvider,
|
|
4014
|
+
proxyOpts
|
|
4015
|
+
}));
|
|
4016
|
+
}
|
|
4017
|
+
const daemonStatus = isDaemonRunning(config.name);
|
|
4018
|
+
if (daemonStatus.pid && !daemonStatus.stale) {
|
|
4019
|
+
console.error(`\u274C A devup daemon is already running for "${config.name}" (pid=${daemonStatus.pid}).`);
|
|
4020
|
+
console.error("");
|
|
4021
|
+
console.error("Stop it first with `devup down`, or interact via the control plane:");
|
|
4022
|
+
console.error(" devup ctl status");
|
|
4023
|
+
console.error(" devup ctl logs <svc> --follow");
|
|
4024
|
+
console.error(" devup ctl restart <svc>");
|
|
4025
|
+
process.exit(1);
|
|
4026
|
+
}
|
|
3253
4027
|
const isInteractive = process.stdin.isTTY ?? false;
|
|
3254
4028
|
const { waitUntilExit } = render(
|
|
3255
4029
|
React7.createElement(App, {
|