@gachlab/devup 0.2.0 → 0.3.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 +33 -3
- package/README.md +98 -2
- package/dist/config/cli.d.ts +3 -2
- package/dist/config/cli.d.ts.map +1 -1
- package/dist/config/types.d.ts +27 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/validator.d.ts.map +1 -1
- package/dist/index.js +357 -15
- package/dist/index.js.map +1 -1
- package/dist/orchestrator/dry-run.d.ts.map +1 -1
- package/dist/orchestrator/once.d.ts.map +1 -1
- package/dist/process/external.d.ts +30 -0
- package/dist/process/external.d.ts.map +1 -0
- package/dist/process/manager.d.ts +7 -0
- package/dist/process/manager.d.ts.map +1 -1
- package/dist/process/types.d.ts +2 -0
- package/dist/process/types.d.ts.map +1 -1
- package/dist/tui/App.d.ts.map +1 -1
- package/dist/tui/hooks/useProcessManager.d.ts +1 -0
- package/dist/tui/hooks/useProcessManager.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import React7 from "react";
|
|
5
5
|
import { render } from "ink";
|
|
6
|
-
import { join as
|
|
6
|
+
import { join as join6 } from "path";
|
|
7
7
|
import { homedir as homedir2 } from "os";
|
|
8
8
|
|
|
9
9
|
// src/config/loader.ts
|
|
@@ -112,6 +112,25 @@ function validateConfig(config, cwd) {
|
|
|
112
112
|
if (svc.cwd && !existsSync2(resolve2(cwd, svc.cwd))) {
|
|
113
113
|
errors.push({ field: `services[${svc.name}].cwd`, message: `Directory not found: ${svc.cwd}` });
|
|
114
114
|
}
|
|
115
|
+
if (svc.readyPattern !== void 0) {
|
|
116
|
+
if (typeof svc.readyPattern !== "string" || !svc.readyPattern.length) {
|
|
117
|
+
errors.push({ field: `services[${svc.name}].readyPattern`, message: `readyPattern must be a non-empty string` });
|
|
118
|
+
} else {
|
|
119
|
+
const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(svc.readyPattern);
|
|
120
|
+
try {
|
|
121
|
+
if (slashed) new RegExp(slashed[1], slashed[2] || "i");
|
|
122
|
+
else new RegExp(svc.readyPattern, "i");
|
|
123
|
+
} catch (e) {
|
|
124
|
+
errors.push({ field: `services[${svc.name}].readyPattern`, message: `Invalid regex: ${e.message}` });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (svc.preBuild !== void 0 && (typeof svc.preBuild !== "string" || !svc.preBuild.trim())) {
|
|
129
|
+
errors.push({ field: `services[${svc.name}].preBuild`, message: `preBuild must be a non-empty string` });
|
|
130
|
+
}
|
|
131
|
+
if (svc.watchBuild !== void 0 && (typeof svc.watchBuild !== "string" || !svc.watchBuild.trim())) {
|
|
132
|
+
errors.push({ field: `services[${svc.name}].watchBuild`, message: `watchBuild must be a non-empty string` });
|
|
133
|
+
}
|
|
115
134
|
if (svc.healthCheck) {
|
|
116
135
|
const hc = svc.healthCheck;
|
|
117
136
|
if (hc.type !== "tcp" && hc.type !== "http") {
|
|
@@ -145,6 +164,34 @@ function validateConfig(config, cwd) {
|
|
|
145
164
|
}
|
|
146
165
|
}
|
|
147
166
|
}
|
|
167
|
+
if (config.external) {
|
|
168
|
+
const extNames = /* @__PURE__ */ new Set();
|
|
169
|
+
for (const ext of config.external) {
|
|
170
|
+
if (!ext.name?.trim()) {
|
|
171
|
+
errors.push({ field: "external[].name", message: "External service name is required" });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (extNames.has(ext.name)) {
|
|
175
|
+
errors.push({ field: `external[${ext.name}].name`, message: `Duplicate external name: ${ext.name}` });
|
|
176
|
+
}
|
|
177
|
+
extNames.add(ext.name);
|
|
178
|
+
if (!ext.cmd?.trim()) {
|
|
179
|
+
errors.push({ field: `external[${ext.name}].cmd`, message: "cmd is required" });
|
|
180
|
+
}
|
|
181
|
+
if (ext.healthCheck) {
|
|
182
|
+
const hc = ext.healthCheck;
|
|
183
|
+
if (hc.type !== "tcp" && hc.type !== "http") {
|
|
184
|
+
errors.push({ field: `external[${ext.name}].healthCheck.type`, message: `Invalid healthCheck.type: ${hc.type}` });
|
|
185
|
+
}
|
|
186
|
+
if ((hc.type === "tcp" || hc.type === "http") && (typeof ext.port !== "number" || ext.port <= 0)) {
|
|
187
|
+
errors.push({ field: `external[${ext.name}].port`, message: `port is required when healthCheck is set (got ${ext.port})` });
|
|
188
|
+
}
|
|
189
|
+
if (hc.type === "http" && hc.path && !hc.path.startsWith("/")) {
|
|
190
|
+
errors.push({ field: `external[${ext.name}].healthCheck.path`, message: `must start with "/"` });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
148
195
|
if (config.proxy?.routes) {
|
|
149
196
|
for (const ref of Object.keys(config.proxy.routes)) {
|
|
150
197
|
if (!names.has(ref)) {
|
|
@@ -152,6 +199,19 @@ function validateConfig(config, cwd) {
|
|
|
152
199
|
}
|
|
153
200
|
}
|
|
154
201
|
}
|
|
202
|
+
if (config.profiles) {
|
|
203
|
+
for (const [profile, svcNames] of Object.entries(config.profiles)) {
|
|
204
|
+
if (!Array.isArray(svcNames) || !svcNames.length) {
|
|
205
|
+
errors.push({ field: `profiles.${profile}`, message: `Profile "${profile}" must be a non-empty array of service names` });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
for (const ref of svcNames) {
|
|
209
|
+
if (!names.has(ref)) {
|
|
210
|
+
errors.push({ field: `profiles.${profile}`, message: `Unknown service: ${ref}` });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
155
215
|
return errors;
|
|
156
216
|
}
|
|
157
217
|
function formatValidationErrors(errors) {
|
|
@@ -194,6 +254,10 @@ function parseCliArgs(argv) {
|
|
|
194
254
|
args.services = next?.split(",");
|
|
195
255
|
i++;
|
|
196
256
|
break;
|
|
257
|
+
case "--profile":
|
|
258
|
+
args.profile = next;
|
|
259
|
+
i++;
|
|
260
|
+
break;
|
|
197
261
|
case "--lazy":
|
|
198
262
|
args.lazy = true;
|
|
199
263
|
break;
|
|
@@ -246,9 +310,18 @@ function parseCliArgs(argv) {
|
|
|
246
310
|
}
|
|
247
311
|
return args;
|
|
248
312
|
}
|
|
249
|
-
function filterServices(services, args) {
|
|
313
|
+
function filterServices(services, args, config) {
|
|
250
314
|
let result = services;
|
|
251
|
-
if (args.
|
|
315
|
+
if (args.profile) {
|
|
316
|
+
const profileNames = config?.profiles?.[args.profile];
|
|
317
|
+
if (!profileNames) {
|
|
318
|
+
const available = Object.keys(config?.profiles ?? {});
|
|
319
|
+
const hint = available.length ? `Available: ${available.join(", ")}` : "No profiles defined in config.";
|
|
320
|
+
throw new Error(`Unknown profile: "${args.profile}". ${hint}`);
|
|
321
|
+
}
|
|
322
|
+
const set = new Set(profileNames);
|
|
323
|
+
result = result.filter((s) => set.has(s.name));
|
|
324
|
+
} else if (args.services) {
|
|
252
325
|
const explicit = new Set(args.services);
|
|
253
326
|
result = result.filter((s) => explicit.has(s.name));
|
|
254
327
|
} else if (args.only) {
|
|
@@ -666,6 +739,16 @@ function installService(cwd, env, onLog) {
|
|
|
666
739
|
// src/process/manager.ts
|
|
667
740
|
var MAX_RESTARTS = 3;
|
|
668
741
|
var BACKOFF_BASE_MS = 2e3;
|
|
742
|
+
function compileReadyPattern(pattern) {
|
|
743
|
+
if (!pattern) return null;
|
|
744
|
+
const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(pattern);
|
|
745
|
+
try {
|
|
746
|
+
if (slashed) return new RegExp(slashed[1], slashed[2] || "i");
|
|
747
|
+
return new RegExp(pattern, "i");
|
|
748
|
+
} catch {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
669
752
|
function lineBuffer(onLine) {
|
|
670
753
|
let buf = "";
|
|
671
754
|
return {
|
|
@@ -713,6 +796,13 @@ var ProcessManager = class {
|
|
|
713
796
|
return;
|
|
714
797
|
}
|
|
715
798
|
}
|
|
799
|
+
if (svc.preBuild) {
|
|
800
|
+
const built = await this.runPreBuild(svc, cwd, colorIdx);
|
|
801
|
+
if (!built) {
|
|
802
|
+
this.recordCrashedState(svc, colorIdx);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
716
806
|
const args = buildProcessArgs(svc);
|
|
717
807
|
const env = buildProcessEnv(svc, this.env);
|
|
718
808
|
const proc = spawn2(svc.cmd, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -732,9 +822,22 @@ var ProcessManager = class {
|
|
|
732
822
|
this.state.set(svc.name, state);
|
|
733
823
|
this.procs.add(proc);
|
|
734
824
|
this.events.onStateChange(svc.name, state);
|
|
735
|
-
const
|
|
825
|
+
const readyRegex = compileReadyPattern(svc.readyPattern);
|
|
826
|
+
const markReadyIfMatch = (line) => {
|
|
827
|
+
if (!readyRegex || state.health === "up") return;
|
|
828
|
+
if (readyRegex.test(line)) {
|
|
829
|
+
state.health = "up";
|
|
830
|
+
if (state.status === "starting") state.status = "running";
|
|
831
|
+
this.events.onStateChange(svc.name, state);
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
const stdoutBuf = lineBuffer((line) => {
|
|
835
|
+
markReadyIfMatch(line);
|
|
836
|
+
this.log(svc.name, line, colorIdx);
|
|
837
|
+
});
|
|
736
838
|
const stderrBuf = lineBuffer((line) => {
|
|
737
839
|
state.errors += 1;
|
|
840
|
+
markReadyIfMatch(line);
|
|
738
841
|
this.log(svc.name, line, colorIdx);
|
|
739
842
|
});
|
|
740
843
|
proc.stdout?.on("data", (d) => stdoutBuf.push(d));
|
|
@@ -743,6 +846,7 @@ var ProcessManager = class {
|
|
|
743
846
|
proc.stderr?.on("end", () => stderrBuf.flush());
|
|
744
847
|
proc.on("close", (code) => {
|
|
745
848
|
this.procs.delete(proc);
|
|
849
|
+
this.stopWatchProc(state);
|
|
746
850
|
if (state.intentionalStop) {
|
|
747
851
|
state.intentionalStop = false;
|
|
748
852
|
return;
|
|
@@ -766,13 +870,90 @@ var ProcessManager = class {
|
|
|
766
870
|
this.log(svc.name, "\u26D4 max restarts reached", colorIdx);
|
|
767
871
|
}
|
|
768
872
|
});
|
|
873
|
+
if (svc.watchBuild) {
|
|
874
|
+
state.watchProc = this.spawnWatchBuild(svc, cwd, env, colorIdx);
|
|
875
|
+
}
|
|
769
876
|
this.log(svc.name, isRestart ? `\u{1F504} restarted (:${svc.port})` : `\u{1F680} started (:${svc.port})`, colorIdx);
|
|
770
877
|
}
|
|
878
|
+
runPreBuild(svc, cwd, colorIdx) {
|
|
879
|
+
this.log(svc.name, `\u{1F528} preBuild: ${svc.preBuild}`, colorIdx);
|
|
880
|
+
return new Promise((resolve3) => {
|
|
881
|
+
const isWin = process.platform === "win32";
|
|
882
|
+
const shell = isWin ? "cmd.exe" : "sh";
|
|
883
|
+
const shellFlag = isWin ? "/c" : "-c";
|
|
884
|
+
const env = buildProcessEnv(svc, this.env);
|
|
885
|
+
const child = spawn2(shell, [shellFlag, svc.preBuild], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
886
|
+
const outBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
|
|
887
|
+
const errBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
|
|
888
|
+
child.stdout?.on("data", (d) => outBuf.push(d));
|
|
889
|
+
child.stderr?.on("data", (d) => errBuf.push(d));
|
|
890
|
+
child.on("error", (err) => {
|
|
891
|
+
this.log(svc.name, `[build] \u274C ${err.message}`, colorIdx);
|
|
892
|
+
resolve3(false);
|
|
893
|
+
});
|
|
894
|
+
child.on("close", (code) => {
|
|
895
|
+
outBuf.flush();
|
|
896
|
+
errBuf.flush();
|
|
897
|
+
if (code === 0) {
|
|
898
|
+
this.log(svc.name, `[build] \u2705 done`, colorIdx);
|
|
899
|
+
resolve3(true);
|
|
900
|
+
} else {
|
|
901
|
+
this.log(svc.name, `[build] \u274C exited with code ${code}`, colorIdx);
|
|
902
|
+
resolve3(false);
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
spawnWatchBuild(svc, cwd, env, colorIdx) {
|
|
908
|
+
this.log(svc.name, `\u{1F440} watchBuild: ${svc.watchBuild}`, colorIdx);
|
|
909
|
+
const isWin = process.platform === "win32";
|
|
910
|
+
const shell = isWin ? "cmd.exe" : "sh";
|
|
911
|
+
const shellFlag = isWin ? "/c" : "-c";
|
|
912
|
+
const child = spawn2(shell, [shellFlag, svc.watchBuild], {
|
|
913
|
+
cwd,
|
|
914
|
+
env,
|
|
915
|
+
detached: true,
|
|
916
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
917
|
+
});
|
|
918
|
+
const outBuf = lineBuffer((line) => this.log(svc.name, `[watch] ${line}`, colorIdx));
|
|
919
|
+
const errBuf = lineBuffer((line) => this.log(svc.name, `[watch] ${line}`, colorIdx));
|
|
920
|
+
child.stdout?.on("data", (d) => outBuf.push(d));
|
|
921
|
+
child.stderr?.on("data", (d) => errBuf.push(d));
|
|
922
|
+
child.on("error", (err) => this.log(svc.name, `[watch] \u274C ${err.message}`, colorIdx));
|
|
923
|
+
return child;
|
|
924
|
+
}
|
|
925
|
+
/** Create a state entry in 'crashed' status without spawning a process (used when preBuild fails). */
|
|
926
|
+
recordCrashedState(svc, colorIdx) {
|
|
927
|
+
const prev = this.state.get(svc.name);
|
|
928
|
+
this.state.set(svc.name, {
|
|
929
|
+
svc,
|
|
930
|
+
proc: null,
|
|
931
|
+
pid: null,
|
|
932
|
+
status: "crashed",
|
|
933
|
+
health: "down",
|
|
934
|
+
errors: prev?.errors ?? 0,
|
|
935
|
+
restarts: prev?.restarts ?? 0,
|
|
936
|
+
startedAt: null,
|
|
937
|
+
intentionalStop: false,
|
|
938
|
+
colorIdx
|
|
939
|
+
});
|
|
940
|
+
this.events.onStateChange(svc.name, this.state.get(svc.name));
|
|
941
|
+
}
|
|
771
942
|
stop(name) {
|
|
772
943
|
const st = this.state.get(name);
|
|
773
944
|
if (!st?.proc || !st.pid) return;
|
|
774
945
|
st.intentionalStop = true;
|
|
775
946
|
this.platform.killTree(st.pid);
|
|
947
|
+
this.stopWatchProc(st);
|
|
948
|
+
}
|
|
949
|
+
stopWatchProc(state) {
|
|
950
|
+
const wp = state.watchProc;
|
|
951
|
+
if (!wp || !wp.pid) return;
|
|
952
|
+
try {
|
|
953
|
+
this.platform.killTree(wp.pid);
|
|
954
|
+
} catch {
|
|
955
|
+
}
|
|
956
|
+
state.watchProc = null;
|
|
776
957
|
}
|
|
777
958
|
async restart(name) {
|
|
778
959
|
const st = this.state.get(name);
|
|
@@ -803,9 +984,13 @@ var ProcessManager = class {
|
|
|
803
984
|
if (!procs.length) return;
|
|
804
985
|
for (const proc of procs) {
|
|
805
986
|
const st = this.findStateByProc(proc);
|
|
806
|
-
if (st)
|
|
987
|
+
if (st) {
|
|
988
|
+
st.intentionalStop = true;
|
|
989
|
+
this.stopWatchProc(st);
|
|
990
|
+
}
|
|
807
991
|
if (proc.pid) this.platform.killTree(proc.pid);
|
|
808
992
|
}
|
|
993
|
+
for (const st of this.state.values()) this.stopWatchProc(st);
|
|
809
994
|
const waits = procs.map(
|
|
810
995
|
(p) => p.exitCode !== null || p.signalCode !== null ? Promise.resolve() : new Promise((resolve3) => p.once("close", () => resolve3()))
|
|
811
996
|
);
|
|
@@ -913,6 +1098,21 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
|
|
|
913
1098
|
pendingLogsRef.current = [];
|
|
914
1099
|
setLogs([]);
|
|
915
1100
|
}, []);
|
|
1101
|
+
const pushLog = useCallback((svcName, text, colorIdx = 0) => {
|
|
1102
|
+
sinkRef.current?.write(svcName, text);
|
|
1103
|
+
const entry = { svcName, text, colorIdx, ts: Date.now() };
|
|
1104
|
+
if (pausedRef.current) {
|
|
1105
|
+
pendingLogsRef.current.push(entry);
|
|
1106
|
+
if (pendingLogsRef.current.length > 5e3) {
|
|
1107
|
+
pendingLogsRef.current = pendingLogsRef.current.slice(-5e3);
|
|
1108
|
+
}
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
setLogs((prev) => {
|
|
1112
|
+
const next = prev.concat(entry);
|
|
1113
|
+
return next.length > 5e3 ? next.slice(-5e3) : next;
|
|
1114
|
+
});
|
|
1115
|
+
}, []);
|
|
916
1116
|
const setPaused = useCallback((paused) => {
|
|
917
1117
|
pausedRef.current = paused;
|
|
918
1118
|
if (!paused && pendingLogsRef.current.length) {
|
|
@@ -935,6 +1135,7 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
|
|
|
935
1135
|
cleanup: useCallback(() => mgr?.cleanup(), [mgr]),
|
|
936
1136
|
clearLogs,
|
|
937
1137
|
setPaused,
|
|
1138
|
+
pushLog,
|
|
938
1139
|
manager: mgr
|
|
939
1140
|
};
|
|
940
1141
|
}
|
|
@@ -1445,6 +1646,86 @@ function createLazyProxy(opts) {
|
|
|
1445
1646
|
};
|
|
1446
1647
|
}
|
|
1447
1648
|
|
|
1649
|
+
// src/process/external.ts
|
|
1650
|
+
import { spawn as spawn3 } from "child_process";
|
|
1651
|
+
import { join as join4 } from "path";
|
|
1652
|
+
var DEFAULT_START_TIMEOUT_S = 60;
|
|
1653
|
+
async function startExternals(externals, opts) {
|
|
1654
|
+
const procs = [];
|
|
1655
|
+
const failed = [];
|
|
1656
|
+
for (const svc of externals) {
|
|
1657
|
+
const proc = spawnExternal(svc, opts);
|
|
1658
|
+
procs.push({ svc, proc, pid: proc.pid ?? null });
|
|
1659
|
+
if (!svc.healthCheck) {
|
|
1660
|
+
opts.onLog?.(svc.name, "\u2705 started (no healthCheck)");
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
if (svc.healthCheck.type === "tcp" && !svc.port) {
|
|
1664
|
+
opts.onLog?.(svc.name, "\u26A0 tcp healthCheck requires `port` \u2014 skipping wait");
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1667
|
+
const timeoutMs = (svc.startTimeout ?? DEFAULT_START_TIMEOUT_S) * 1e3;
|
|
1668
|
+
const ok = await waitHealthy(svc, timeoutMs);
|
|
1669
|
+
if (ok) {
|
|
1670
|
+
opts.onLog?.(svc.name, "\u2705 healthy");
|
|
1671
|
+
} else {
|
|
1672
|
+
opts.onLog?.(svc.name, `\u274C never became healthy (timeout ${timeoutMs / 1e3}s)`);
|
|
1673
|
+
failed.push(svc.name);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
return { procs, allHealthy: failed.length === 0, failed };
|
|
1677
|
+
}
|
|
1678
|
+
async function stopExternals(procs, platform, opts = {}) {
|
|
1679
|
+
for (const { svc, proc, pid } of procs) {
|
|
1680
|
+
try {
|
|
1681
|
+
if (pid) platform.killTree(pid);
|
|
1682
|
+
if (svc.stopCmd) {
|
|
1683
|
+
opts.onLog?.(svc.name, `\u{1F9F9} ${svc.stopCmd}`);
|
|
1684
|
+
await new Promise((resolve3) => {
|
|
1685
|
+
const isWin = process.platform === "win32";
|
|
1686
|
+
const shell = isWin ? "cmd.exe" : "sh";
|
|
1687
|
+
const flag = isWin ? "/c" : "-c";
|
|
1688
|
+
const cwd = svc.cwd ? join4(opts.baseCwd, svc.cwd) : opts.baseCwd;
|
|
1689
|
+
const env = { ...opts.env, ...svc.extraEnv ?? {} };
|
|
1690
|
+
const child = spawn3(shell, [flag, svc.stopCmd], { cwd, env, stdio: "ignore" });
|
|
1691
|
+
child.on("close", () => resolve3());
|
|
1692
|
+
child.on("error", () => resolve3());
|
|
1693
|
+
setTimeout(() => resolve3(), 1e4);
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
} catch {
|
|
1697
|
+
}
|
|
1698
|
+
void proc;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
function spawnExternal(svc, opts) {
|
|
1702
|
+
const isWin = process.platform === "win32";
|
|
1703
|
+
const shell = isWin ? "cmd.exe" : "sh";
|
|
1704
|
+
const flag = isWin ? "/c" : "-c";
|
|
1705
|
+
const cwd = svc.cwd ? join4(opts.baseCwd, svc.cwd) : opts.baseCwd;
|
|
1706
|
+
const env = { ...opts.env, ...svc.extraEnv ?? {} };
|
|
1707
|
+
opts.onLog?.(svc.name, `\u{1F680} ${svc.cmd}`);
|
|
1708
|
+
const child = spawn3(shell, [flag, svc.cmd], {
|
|
1709
|
+
cwd,
|
|
1710
|
+
env,
|
|
1711
|
+
detached: true,
|
|
1712
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1713
|
+
});
|
|
1714
|
+
child.stdout?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
|
|
1715
|
+
child.stderr?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
|
|
1716
|
+
child.on("error", (err) => opts.onLog?.(svc.name, `\u274C spawn error: ${err.message}`));
|
|
1717
|
+
return child;
|
|
1718
|
+
}
|
|
1719
|
+
async function waitHealthy(svc, timeoutMs) {
|
|
1720
|
+
const deadline = Date.now() + timeoutMs;
|
|
1721
|
+
const port = svc.port;
|
|
1722
|
+
while (Date.now() < deadline) {
|
|
1723
|
+
if (await checkHealth(port, svc.healthCheck)) return true;
|
|
1724
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1725
|
+
}
|
|
1726
|
+
return false;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1448
1729
|
// src/tui/App.tsx
|
|
1449
1730
|
import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
1450
1731
|
function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts, logSink }) {
|
|
@@ -1464,6 +1745,7 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
|
|
|
1464
1745
|
const pm = useProcessManager(platform, baseCwd, env, logSink);
|
|
1465
1746
|
const [booted, setBooted] = useState5(false);
|
|
1466
1747
|
const lazyProxies = useRef3(/* @__PURE__ */ new Map());
|
|
1748
|
+
const externals = useRef3([]);
|
|
1467
1749
|
const kb = useKeyBindings({
|
|
1468
1750
|
onQuit: () => {
|
|
1469
1751
|
void shutdown();
|
|
@@ -1475,9 +1757,17 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
|
|
|
1475
1757
|
const shutdown = useCallback3(async () => {
|
|
1476
1758
|
lazyProxies.current.forEach((p) => p.destroy());
|
|
1477
1759
|
await pm.cleanup();
|
|
1760
|
+
if (externals.current.length) {
|
|
1761
|
+
await stopExternals(externals.current, platform, {
|
|
1762
|
+
baseCwd,
|
|
1763
|
+
env,
|
|
1764
|
+
onLog: (svc, msg) => pm.pushLog(`ext:${svc}`, msg, 12)
|
|
1765
|
+
});
|
|
1766
|
+
externals.current = [];
|
|
1767
|
+
}
|
|
1478
1768
|
await logSink?.close();
|
|
1479
1769
|
process.exit(0);
|
|
1480
|
-
}, [pm, logSink]);
|
|
1770
|
+
}, [pm, logSink, platform, baseCwd, env]);
|
|
1481
1771
|
useEffect5(() => {
|
|
1482
1772
|
pm.setPaused(kb.logsPaused || kb.logsScrollOffset > 0);
|
|
1483
1773
|
}, [kb.logsPaused, kb.logsScrollOffset, pm]);
|
|
@@ -1489,6 +1779,19 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
|
|
|
1489
1779
|
(async () => {
|
|
1490
1780
|
const lazyMode = cliArgs.lazy;
|
|
1491
1781
|
const lazyTimeout = cliArgs.lazyTimeout;
|
|
1782
|
+
if (config.external?.length) {
|
|
1783
|
+
const result = await startExternals(config.external, {
|
|
1784
|
+
baseCwd,
|
|
1785
|
+
env,
|
|
1786
|
+
platform,
|
|
1787
|
+
onLog: (svc, msg) => pm.pushLog(`ext:${svc}`, msg, 12)
|
|
1788
|
+
});
|
|
1789
|
+
externals.current = result.procs;
|
|
1790
|
+
if (!result.allHealthy) {
|
|
1791
|
+
pm.pushLog("devup", `\u274C external(s) failed: ${result.failed.join(", ")}. Aborting boot.`, 5);
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1492
1795
|
if (lazyMode && config.lazy) {
|
|
1493
1796
|
const { alwaysOn, lazy } = classifyServices(services, config.lazy);
|
|
1494
1797
|
const aoPhases = groupByPhase(alwaysOn);
|
|
@@ -1637,7 +1940,7 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
|
|
|
1637
1940
|
|
|
1638
1941
|
// src/process/log-sink.ts
|
|
1639
1942
|
import { existsSync as existsSync8, mkdirSync as mkdirSync4, renameSync, createWriteStream } from "fs";
|
|
1640
|
-
import { join as
|
|
1943
|
+
import { join as join5, dirname as dirname4 } from "path";
|
|
1641
1944
|
import { homedir } from "os";
|
|
1642
1945
|
var LogSink = class {
|
|
1643
1946
|
dir;
|
|
@@ -1645,14 +1948,14 @@ var LogSink = class {
|
|
|
1645
1948
|
streams = /* @__PURE__ */ new Map();
|
|
1646
1949
|
seen = /* @__PURE__ */ new Set();
|
|
1647
1950
|
constructor(opts) {
|
|
1648
|
-
const root = opts.rootDir ??
|
|
1649
|
-
this.dir =
|
|
1951
|
+
const root = opts.rootDir ?? join5(homedir(), ".devup", "logs");
|
|
1952
|
+
this.dir = join5(root, sanitize(opts.projectName));
|
|
1650
1953
|
this.rotateOnStart = opts.rotateOnStart ?? true;
|
|
1651
1954
|
mkdirSync4(this.dir, { recursive: true });
|
|
1652
1955
|
}
|
|
1653
1956
|
/** Returns the file path for a service log (useful for tests / UI). */
|
|
1654
1957
|
pathFor(svcName) {
|
|
1655
|
-
return
|
|
1958
|
+
return join5(this.dir, `${sanitize(svcName)}.log`);
|
|
1656
1959
|
}
|
|
1657
1960
|
write(svcName, line) {
|
|
1658
1961
|
const stream = this.streamFor(svcName);
|
|
@@ -1696,8 +1999,18 @@ function renderDryRun(opts) {
|
|
|
1696
1999
|
const lines = [];
|
|
1697
2000
|
lines.push(`Project: ${config.icon ?? "\u{1F4E6}"} ${config.name}`);
|
|
1698
2001
|
lines.push(`Mode: ${cliArgs.lazy && config.lazy ? "lazy" : "normal"}`);
|
|
2002
|
+
if (cliArgs.profile) lines.push(`Profile: ${cliArgs.profile}`);
|
|
1699
2003
|
lines.push(`Services: ${services.length}`);
|
|
1700
2004
|
lines.push("");
|
|
2005
|
+
if (config.external?.length) {
|
|
2006
|
+
lines.push(`Externals (${config.external.length}):`);
|
|
2007
|
+
for (const ext of config.external) {
|
|
2008
|
+
const hc = ext.healthCheck;
|
|
2009
|
+
const hcTag = hc ? ` health=${hc.type}${hc.type === "http" ? " " + (hc.path ?? "/") : ""} :${ext.port ?? "?"}` : "";
|
|
2010
|
+
lines.push(` - ${ext.name.padEnd(20)} ${ext.cmd}${hcTag}`);
|
|
2011
|
+
}
|
|
2012
|
+
lines.push("");
|
|
2013
|
+
}
|
|
1701
2014
|
const lazyMode = cliArgs.lazy && !!config.lazy;
|
|
1702
2015
|
let alwaysOn = services;
|
|
1703
2016
|
let lazy = [];
|
|
@@ -1771,6 +2084,26 @@ async function runOnce(opts) {
|
|
|
1771
2084
|
}
|
|
1772
2085
|
}
|
|
1773
2086
|
});
|
|
2087
|
+
let externals = [];
|
|
2088
|
+
if (config.external?.length) {
|
|
2089
|
+
out(`\u25B6 externals (${config.external.length})`);
|
|
2090
|
+
const result = await startExternals(config.external, {
|
|
2091
|
+
baseCwd,
|
|
2092
|
+
env,
|
|
2093
|
+
platform,
|
|
2094
|
+
onLog: (svc, msg) => {
|
|
2095
|
+
logSink?.write(`ext:${svc}`, msg);
|
|
2096
|
+
out(`[ext:${svc}] ${msg}`);
|
|
2097
|
+
}
|
|
2098
|
+
});
|
|
2099
|
+
externals = result.procs;
|
|
2100
|
+
if (!result.allHealthy) {
|
|
2101
|
+
out(`\u2717 externals failed: ${result.failed.join(", ")}`);
|
|
2102
|
+
await stopExternals(externals, platform, { baseCwd, env });
|
|
2103
|
+
await mgr.cleanup();
|
|
2104
|
+
return 1;
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
1774
2107
|
const phases = groupByPhase(services);
|
|
1775
2108
|
const phaseNums = Object.keys(phases).map(Number).sort((a, b) => a - b);
|
|
1776
2109
|
const apiNames = services.filter((s) => s.type === "api").map((s) => s.name);
|
|
@@ -1784,16 +2117,18 @@ async function runOnce(opts) {
|
|
|
1784
2117
|
if (!installed) {
|
|
1785
2118
|
out(`\u2717 install failed for ${svc.name}`);
|
|
1786
2119
|
await mgr.cleanup();
|
|
2120
|
+
await stopExternals(externals, platform, { baseCwd, env });
|
|
1787
2121
|
return 1;
|
|
1788
2122
|
}
|
|
1789
2123
|
await mgr.start(svc, ci);
|
|
1790
2124
|
}
|
|
1791
2125
|
const apis = phases[num].filter((s) => s.type === "api");
|
|
1792
2126
|
for (const api of apis) {
|
|
1793
|
-
const ok = await
|
|
2127
|
+
const ok = await waitHealthy2(api, deadline);
|
|
1794
2128
|
if (!ok) {
|
|
1795
2129
|
out(`\u2717 ${api.name} did not become healthy within ${cliArgs.onceTimeout}s`);
|
|
1796
2130
|
await mgr.cleanup();
|
|
2131
|
+
await stopExternals(externals, platform, { baseCwd, env });
|
|
1797
2132
|
return 1;
|
|
1798
2133
|
}
|
|
1799
2134
|
out(`\u2713 ${api.name} ready`);
|
|
@@ -1807,9 +2142,10 @@ async function runOnce(opts) {
|
|
|
1807
2142
|
const summary = `ready: ${apiNames.length} APIs in ${((cliArgs.onceTimeout * 1e3 - (deadline - Date.now())) / 1e3).toFixed(1)}s`;
|
|
1808
2143
|
out(summary);
|
|
1809
2144
|
await mgr.cleanup();
|
|
2145
|
+
await stopExternals(externals, platform, { baseCwd, env });
|
|
1810
2146
|
return 0;
|
|
1811
2147
|
}
|
|
1812
|
-
async function
|
|
2148
|
+
async function waitHealthy2(svc, deadline) {
|
|
1813
2149
|
while (Date.now() < deadline) {
|
|
1814
2150
|
const ok = await checkHealth(svc.port, svc.healthCheck);
|
|
1815
2151
|
if (ok) return true;
|
|
@@ -1841,13 +2177,19 @@ async function main() {
|
|
|
1841
2177
|
${formatValidationErrors(errors)}`);
|
|
1842
2178
|
process.exit(1);
|
|
1843
2179
|
}
|
|
1844
|
-
|
|
2180
|
+
let services;
|
|
2181
|
+
try {
|
|
2182
|
+
services = filterServices(config.services, cliArgs, config);
|
|
2183
|
+
} catch (e) {
|
|
2184
|
+
console.error(`\u274C ${e.message}`);
|
|
2185
|
+
process.exit(1);
|
|
2186
|
+
}
|
|
1845
2187
|
if (!services.length) {
|
|
1846
2188
|
console.error("\u274C No services to run after filtering");
|
|
1847
2189
|
process.exit(1);
|
|
1848
2190
|
}
|
|
1849
2191
|
const platform = await detectPlatform();
|
|
1850
|
-
const envFile = config.envFile ?
|
|
2192
|
+
const envFile = config.envFile ? join6(cwd, config.envFile) : join6(cwd, ".env");
|
|
1851
2193
|
const env = parseEnvFile(envFile, process.env);
|
|
1852
2194
|
if (config.env) {
|
|
1853
2195
|
for (const [k, v] of Object.entries(config.env)) {
|
|
@@ -1864,7 +2206,7 @@ ${formatValidationErrors(errors)}`);
|
|
|
1864
2206
|
routes: config.proxy.routes,
|
|
1865
2207
|
tls: cliArgs.proxyTls ?? config.proxy.tls ?? true,
|
|
1866
2208
|
entrypoint: cliArgs.proxyEntrypoint ?? config.proxy.entrypoint ?? "websecure",
|
|
1867
|
-
confPath: cliArgs.proxyConf ?? config.proxy.confPath ??
|
|
2209
|
+
confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join6(homedir2(), ".traefik", "traefik_conf.yaml")
|
|
1868
2210
|
};
|
|
1869
2211
|
}
|
|
1870
2212
|
if (cliArgs.dryRun) {
|