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