@gachlab/devup 0.2.0 → 0.4.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 +63 -3
- package/README.md +114 -2
- package/dist/config/cli.d.ts +4 -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.d.ts.map +1 -1
- package/dist/index.js +1007 -288
- 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/orchestrator/subcommands.d.ts +20 -0
- package/dist/orchestrator/subcommands.d.ts.map +1 -0
- package/dist/process/external.d.ts +30 -0
- package/dist/process/external.d.ts.map +1 -0
- package/dist/process/manager.d.ts +10 -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 +4 -0
- package/dist/tui/App.d.ts.map +1 -1
- package/dist/tui/ServiceList.d.ts.map +1 -1
- package/dist/tui/StatsPanel.d.ts +3 -0
- package/dist/tui/StatsPanel.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/dist/tui/tips.d.ts +17 -0
- package/dist/tui/tips.d.ts.map +1 -0
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import React7 from "react";
|
|
5
5
|
import { render } from "ink";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
7
|
+
import { dirname as dirname6, join as join7 } from "path";
|
|
8
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
9
|
+
import { homedir as homedir3 } from "os";
|
|
8
10
|
|
|
9
11
|
// src/config/loader.ts
|
|
10
12
|
import { existsSync } from "fs";
|
|
@@ -112,6 +114,25 @@ function validateConfig(config, cwd) {
|
|
|
112
114
|
if (svc.cwd && !existsSync2(resolve2(cwd, svc.cwd))) {
|
|
113
115
|
errors.push({ field: `services[${svc.name}].cwd`, message: `Directory not found: ${svc.cwd}` });
|
|
114
116
|
}
|
|
117
|
+
if (svc.readyPattern !== void 0) {
|
|
118
|
+
if (typeof svc.readyPattern !== "string" || !svc.readyPattern.length) {
|
|
119
|
+
errors.push({ field: `services[${svc.name}].readyPattern`, message: `readyPattern must be a non-empty string` });
|
|
120
|
+
} else {
|
|
121
|
+
const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(svc.readyPattern);
|
|
122
|
+
try {
|
|
123
|
+
if (slashed) new RegExp(slashed[1], slashed[2] || "i");
|
|
124
|
+
else new RegExp(svc.readyPattern, "i");
|
|
125
|
+
} catch (e) {
|
|
126
|
+
errors.push({ field: `services[${svc.name}].readyPattern`, message: `Invalid regex: ${e.message}` });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (svc.preBuild !== void 0 && (typeof svc.preBuild !== "string" || !svc.preBuild.trim())) {
|
|
131
|
+
errors.push({ field: `services[${svc.name}].preBuild`, message: `preBuild must be a non-empty string` });
|
|
132
|
+
}
|
|
133
|
+
if (svc.watchBuild !== void 0 && (typeof svc.watchBuild !== "string" || !svc.watchBuild.trim())) {
|
|
134
|
+
errors.push({ field: `services[${svc.name}].watchBuild`, message: `watchBuild must be a non-empty string` });
|
|
135
|
+
}
|
|
115
136
|
if (svc.healthCheck) {
|
|
116
137
|
const hc = svc.healthCheck;
|
|
117
138
|
if (hc.type !== "tcp" && hc.type !== "http") {
|
|
@@ -145,6 +166,34 @@ function validateConfig(config, cwd) {
|
|
|
145
166
|
}
|
|
146
167
|
}
|
|
147
168
|
}
|
|
169
|
+
if (config.external) {
|
|
170
|
+
const extNames = /* @__PURE__ */ new Set();
|
|
171
|
+
for (const ext of config.external) {
|
|
172
|
+
if (!ext.name?.trim()) {
|
|
173
|
+
errors.push({ field: "external[].name", message: "External service name is required" });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (extNames.has(ext.name)) {
|
|
177
|
+
errors.push({ field: `external[${ext.name}].name`, message: `Duplicate external name: ${ext.name}` });
|
|
178
|
+
}
|
|
179
|
+
extNames.add(ext.name);
|
|
180
|
+
if (!ext.cmd?.trim()) {
|
|
181
|
+
errors.push({ field: `external[${ext.name}].cmd`, message: "cmd is required" });
|
|
182
|
+
}
|
|
183
|
+
if (ext.healthCheck) {
|
|
184
|
+
const hc = ext.healthCheck;
|
|
185
|
+
if (hc.type !== "tcp" && hc.type !== "http") {
|
|
186
|
+
errors.push({ field: `external[${ext.name}].healthCheck.type`, message: `Invalid healthCheck.type: ${hc.type}` });
|
|
187
|
+
}
|
|
188
|
+
if ((hc.type === "tcp" || hc.type === "http") && (typeof ext.port !== "number" || ext.port <= 0)) {
|
|
189
|
+
errors.push({ field: `external[${ext.name}].port`, message: `port is required when healthCheck is set (got ${ext.port})` });
|
|
190
|
+
}
|
|
191
|
+
if (hc.type === "http" && hc.path && !hc.path.startsWith("/")) {
|
|
192
|
+
errors.push({ field: `external[${ext.name}].healthCheck.path`, message: `must start with "/"` });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
148
197
|
if (config.proxy?.routes) {
|
|
149
198
|
for (const ref of Object.keys(config.proxy.routes)) {
|
|
150
199
|
if (!names.has(ref)) {
|
|
@@ -152,6 +201,19 @@ function validateConfig(config, cwd) {
|
|
|
152
201
|
}
|
|
153
202
|
}
|
|
154
203
|
}
|
|
204
|
+
if (config.profiles) {
|
|
205
|
+
for (const [profile, svcNames] of Object.entries(config.profiles)) {
|
|
206
|
+
if (!Array.isArray(svcNames) || !svcNames.length) {
|
|
207
|
+
errors.push({ field: `profiles.${profile}`, message: `Profile "${profile}" must be a non-empty array of service names` });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
for (const ref of svcNames) {
|
|
211
|
+
if (!names.has(ref)) {
|
|
212
|
+
errors.push({ field: `profiles.${profile}`, message: `Unknown service: ${ref}` });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
155
217
|
return errors;
|
|
156
218
|
}
|
|
157
219
|
function formatValidationErrors(errors) {
|
|
@@ -161,6 +223,44 @@ function formatValidationErrors(errors) {
|
|
|
161
223
|
// src/config/cli.ts
|
|
162
224
|
var DEFAULT_LAZY_TIMEOUT = 10;
|
|
163
225
|
var DEFAULT_ONCE_TIMEOUT = 90;
|
|
226
|
+
var USAGE = `devup \u2014 terminal UI dev stack runner
|
|
227
|
+
|
|
228
|
+
Usage: devup [options]
|
|
229
|
+
|
|
230
|
+
Service selection:
|
|
231
|
+
--only apis | webs Start only APIs or only webs
|
|
232
|
+
--services a,b,c Start only the named services
|
|
233
|
+
--profile <name> Start the services in a named profile (see ROADMAP)
|
|
234
|
+
--skip a,b,c Start everything except these
|
|
235
|
+
--config <path> Use a custom config file
|
|
236
|
+
|
|
237
|
+
Lazy mode:
|
|
238
|
+
--lazy Enable lazy mode (default)
|
|
239
|
+
--no-lazy Start every service immediately
|
|
240
|
+
--timeout <minutes> Idle timeout for lazy services. Default: 10
|
|
241
|
+
|
|
242
|
+
Reverse proxy:
|
|
243
|
+
--proxy Enable proxy config generation
|
|
244
|
+
--proxy-host <host> Override the target host (Docker/local)
|
|
245
|
+
--proxy-conf <path> Override the generated config file path
|
|
246
|
+
--proxy-tls Enable TLS in the generated config (default)
|
|
247
|
+
--no-proxy-tls Disable TLS
|
|
248
|
+
--proxy-entrypoint <n> Override entrypoint name (Traefik only)
|
|
249
|
+
|
|
250
|
+
CI / scripting:
|
|
251
|
+
--dry-run Print the resolved boot plan and exit
|
|
252
|
+
--once Boot, wait for readiness, exit 0/1 (no TUI)
|
|
253
|
+
--once-timeout <s> Max seconds to wait in --once mode. Default: 90
|
|
254
|
+
|
|
255
|
+
Log files:
|
|
256
|
+
--no-log-file Disable persistent log files
|
|
257
|
+
--log-dir <path> Override log root (default: ~/.devup/logs)
|
|
258
|
+
|
|
259
|
+
Other:
|
|
260
|
+
-h, --help Show this help and exit
|
|
261
|
+
-v, --version Show version and exit
|
|
262
|
+
|
|
263
|
+
See https://github.com/gachlab/devup for the full documentation.`;
|
|
164
264
|
function parseCliArgs(argv) {
|
|
165
265
|
const args = {
|
|
166
266
|
skip: [],
|
|
@@ -194,6 +294,10 @@ function parseCliArgs(argv) {
|
|
|
194
294
|
args.services = next?.split(",");
|
|
195
295
|
i++;
|
|
196
296
|
break;
|
|
297
|
+
case "--profile":
|
|
298
|
+
args.profile = next;
|
|
299
|
+
i++;
|
|
300
|
+
break;
|
|
197
301
|
case "--lazy":
|
|
198
302
|
args.lazy = true;
|
|
199
303
|
break;
|
|
@@ -246,9 +350,18 @@ function parseCliArgs(argv) {
|
|
|
246
350
|
}
|
|
247
351
|
return args;
|
|
248
352
|
}
|
|
249
|
-
function filterServices(services, args) {
|
|
353
|
+
function filterServices(services, args, config) {
|
|
250
354
|
let result = services;
|
|
251
|
-
if (args.
|
|
355
|
+
if (args.profile) {
|
|
356
|
+
const profileNames = config?.profiles?.[args.profile];
|
|
357
|
+
if (!profileNames) {
|
|
358
|
+
const available = Object.keys(config?.profiles ?? {});
|
|
359
|
+
const hint = available.length ? `Available: ${available.join(", ")}` : "No profiles defined in config.";
|
|
360
|
+
throw new Error(`Unknown profile: "${args.profile}". ${hint}`);
|
|
361
|
+
}
|
|
362
|
+
const set = new Set(profileNames);
|
|
363
|
+
result = result.filter((s) => set.has(s.name));
|
|
364
|
+
} else if (args.services) {
|
|
252
365
|
const explicit = new Set(args.services);
|
|
253
366
|
result = result.filter((s) => explicit.has(s.name));
|
|
254
367
|
} else if (args.only) {
|
|
@@ -271,6 +384,359 @@ function filterServices(services, args) {
|
|
|
271
384
|
return result;
|
|
272
385
|
}
|
|
273
386
|
|
|
387
|
+
// src/orchestrator/subcommands.ts
|
|
388
|
+
import { spawn } from "child_process";
|
|
389
|
+
import { createReadStream, watchFile, unwatchFile, existsSync as existsSync4, statSync } from "fs";
|
|
390
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
391
|
+
import { join as join3, dirname } from "path";
|
|
392
|
+
import { fileURLToPath } from "url";
|
|
393
|
+
import { homedir } from "os";
|
|
394
|
+
import { createInterface } from "readline";
|
|
395
|
+
|
|
396
|
+
// src/process/health.ts
|
|
397
|
+
import net from "net";
|
|
398
|
+
import http from "http";
|
|
399
|
+
function checkPort(port, host = "127.0.0.1", timeoutMs = 2e3) {
|
|
400
|
+
return new Promise((resolve4) => {
|
|
401
|
+
const socket = new net.Socket();
|
|
402
|
+
socket.setTimeout(timeoutMs);
|
|
403
|
+
socket.once("connect", () => {
|
|
404
|
+
socket.destroy();
|
|
405
|
+
resolve4(true);
|
|
406
|
+
});
|
|
407
|
+
socket.once("error", () => {
|
|
408
|
+
socket.destroy();
|
|
409
|
+
resolve4(false);
|
|
410
|
+
});
|
|
411
|
+
socket.once("timeout", () => {
|
|
412
|
+
socket.destroy();
|
|
413
|
+
resolve4(false);
|
|
414
|
+
});
|
|
415
|
+
socket.connect(port, host);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
function checkHttp(port, opts = {}) {
|
|
419
|
+
const path = opts.path ?? "/";
|
|
420
|
+
const host = opts.host ?? "127.0.0.1";
|
|
421
|
+
const timeoutMs = opts.timeoutMs ?? 2e3;
|
|
422
|
+
const accept = (code) => {
|
|
423
|
+
if (opts.expect === void 0) return code >= 200 && code < 300;
|
|
424
|
+
if (Array.isArray(opts.expect)) return opts.expect.includes(code);
|
|
425
|
+
return code === opts.expect;
|
|
426
|
+
};
|
|
427
|
+
return new Promise((resolve4) => {
|
|
428
|
+
const req = http.get({ host, port, path, timeout: timeoutMs }, (res) => {
|
|
429
|
+
const ok = typeof res.statusCode === "number" && accept(res.statusCode);
|
|
430
|
+
res.resume();
|
|
431
|
+
resolve4(ok);
|
|
432
|
+
});
|
|
433
|
+
req.on("error", () => resolve4(false));
|
|
434
|
+
req.on("timeout", () => {
|
|
435
|
+
req.destroy();
|
|
436
|
+
resolve4(false);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
function checkHealth(port, hc) {
|
|
441
|
+
if (hc?.type === "http") {
|
|
442
|
+
return checkHttp(port, {
|
|
443
|
+
path: hc.path,
|
|
444
|
+
expect: hc.expect,
|
|
445
|
+
host: hc.host,
|
|
446
|
+
timeoutMs: hc.timeoutMs
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
return checkPort(port, "127.0.0.1", hc?.timeoutMs);
|
|
450
|
+
}
|
|
451
|
+
function waitForPort(port, opts = {}) {
|
|
452
|
+
const { timeout = 45e3, interval = 1e3 } = opts;
|
|
453
|
+
return new Promise((resolve4) => {
|
|
454
|
+
const start = Date.now();
|
|
455
|
+
const check = () => {
|
|
456
|
+
checkPort(port).then((ok) => {
|
|
457
|
+
if (ok) return resolve4(true);
|
|
458
|
+
if (Date.now() - start > timeout) return resolve4(false);
|
|
459
|
+
setTimeout(check, interval);
|
|
460
|
+
});
|
|
461
|
+
};
|
|
462
|
+
check();
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
function deriveHealth(isUp, currentStatus) {
|
|
466
|
+
if (currentStatus === "idle") return "idle";
|
|
467
|
+
if (isUp) return "up";
|
|
468
|
+
return currentStatus === "starting" ? "wait" : "down";
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// src/utils.ts
|
|
472
|
+
import { existsSync as existsSync3, readFileSync, writeFileSync } from "fs";
|
|
473
|
+
import { createHash } from "crypto";
|
|
474
|
+
import { join as join2 } from "path";
|
|
475
|
+
function parseEnvFile(filePath, baseEnv = {}) {
|
|
476
|
+
const env = { ...baseEnv };
|
|
477
|
+
if (!existsSync3(filePath)) return env;
|
|
478
|
+
for (const line of readFileSync(filePath, "utf8").split("\n")) {
|
|
479
|
+
const trimmed = line.trim();
|
|
480
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
481
|
+
const eqIdx = trimmed.indexOf("=");
|
|
482
|
+
if (eqIdx === -1) continue;
|
|
483
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
484
|
+
let val = trimmed.slice(eqIdx + 1).trim();
|
|
485
|
+
if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
|
|
486
|
+
val = val.slice(1, -1);
|
|
487
|
+
}
|
|
488
|
+
if (!env[key]) env[key] = val;
|
|
489
|
+
}
|
|
490
|
+
return env;
|
|
491
|
+
}
|
|
492
|
+
function fmtUptime(ms) {
|
|
493
|
+
if (!ms || ms < 0) return "-";
|
|
494
|
+
const s = Math.floor(ms / 1e3);
|
|
495
|
+
if (s < 60) return `${s}s`;
|
|
496
|
+
const m = Math.floor(s / 60);
|
|
497
|
+
if (m < 60) return `${m}m${s % 60}s`;
|
|
498
|
+
const h = Math.floor(m / 60);
|
|
499
|
+
if (h < 24) return `${h}h${m % 60}m`;
|
|
500
|
+
const d = Math.floor(h / 24);
|
|
501
|
+
return `${d}d${h % 24}h`;
|
|
502
|
+
}
|
|
503
|
+
function needsInstall(fullCwd) {
|
|
504
|
+
const nm = join2(fullCwd, "node_modules");
|
|
505
|
+
if (!existsSync3(nm)) return true;
|
|
506
|
+
try {
|
|
507
|
+
const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
|
|
508
|
+
const stampFile = join2(nm, ".install-stamp");
|
|
509
|
+
if (existsSync3(stampFile) && readFileSync(stampFile, "utf8") === pkgHash) return false;
|
|
510
|
+
} catch {
|
|
511
|
+
}
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
function writeInstallStamp(fullCwd) {
|
|
515
|
+
try {
|
|
516
|
+
const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
|
|
517
|
+
writeFileSync(join2(fullCwd, "node_modules", ".install-stamp"), pkgHash);
|
|
518
|
+
} catch {
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function sortServiceNames(names, sortMode, statsMap, procState) {
|
|
522
|
+
if (sortMode === "name") return names.slice().sort();
|
|
523
|
+
return names.slice().sort((a, b) => {
|
|
524
|
+
if (sortMode === "mem") {
|
|
525
|
+
return (parseFloat(statsMap[b]?.mem ?? "0") || 0) - (parseFloat(statsMap[a]?.mem ?? "0") || 0);
|
|
526
|
+
}
|
|
527
|
+
return (procState[b]?.errors ?? 0) - (procState[a]?.errors ?? 0);
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
function groupByPhase(services) {
|
|
531
|
+
const phases = {};
|
|
532
|
+
for (const s of services) {
|
|
533
|
+
(phases[s.phase] ??= []).push(s);
|
|
534
|
+
}
|
|
535
|
+
return phases;
|
|
536
|
+
}
|
|
537
|
+
function buildProcessArgs(svc) {
|
|
538
|
+
const extra = svc.nodeArgs ?? [];
|
|
539
|
+
if (!svc.maxMem) return [...extra, ...svc.args];
|
|
540
|
+
if (svc.cmd === "node") return [`--max-old-space-size=${svc.maxMem}`, ...extra, ...svc.args];
|
|
541
|
+
return [...extra, ...svc.args];
|
|
542
|
+
}
|
|
543
|
+
function buildProcessEnv(svc, baseEnv) {
|
|
544
|
+
const env = { ...baseEnv, ...svc.extraEnv ?? {} };
|
|
545
|
+
if (svc.maxMem && svc.cmd !== "node") {
|
|
546
|
+
const existing = env["NODE_OPTIONS"] ?? "";
|
|
547
|
+
const flag = `--max-old-space-size=${svc.maxMem}`;
|
|
548
|
+
if (!existing.includes("max-old-space-size")) {
|
|
549
|
+
env["NODE_OPTIONS"] = existing ? `${existing} ${flag}` : flag;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return env;
|
|
553
|
+
}
|
|
554
|
+
function calcCpuPercent(totalCpuSec, prevCpu, prevTime) {
|
|
555
|
+
const elapsed = (Date.now() - prevTime) / 1e3;
|
|
556
|
+
const cpuDelta = totalCpuSec - prevCpu;
|
|
557
|
+
return elapsed > 0 ? cpuDelta / elapsed * 100 : 0;
|
|
558
|
+
}
|
|
559
|
+
var tagColors = [
|
|
560
|
+
"cyan",
|
|
561
|
+
"yellow",
|
|
562
|
+
"green",
|
|
563
|
+
"magenta",
|
|
564
|
+
"blue",
|
|
565
|
+
"red",
|
|
566
|
+
"#5faf5f",
|
|
567
|
+
"#d7af5f",
|
|
568
|
+
"#5f87d7",
|
|
569
|
+
"#af5faf",
|
|
570
|
+
"#5fd7d7",
|
|
571
|
+
"#d75f5f",
|
|
572
|
+
"white"
|
|
573
|
+
];
|
|
574
|
+
|
|
575
|
+
// src/orchestrator/subcommands.ts
|
|
576
|
+
var KNOWN = /* @__PURE__ */ new Set(["logs", "install", "status", "help"]);
|
|
577
|
+
function detectSubcommand(argv) {
|
|
578
|
+
const first = argv[0];
|
|
579
|
+
return first && KNOWN.has(first) ? first : null;
|
|
580
|
+
}
|
|
581
|
+
function logRoot(config, override) {
|
|
582
|
+
const root = override ?? join3(homedir(), ".devup", "logs");
|
|
583
|
+
return join3(root, sanitize(config.name));
|
|
584
|
+
}
|
|
585
|
+
function sanitize(name) {
|
|
586
|
+
return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
|
|
587
|
+
}
|
|
588
|
+
async function runLogs(argv, opts) {
|
|
589
|
+
const out = opts.out ?? ((l) => console.log(l));
|
|
590
|
+
const follow = argv.includes("--follow") || argv.includes("-f");
|
|
591
|
+
const svcArg = argv.find((a) => !a.startsWith("-"));
|
|
592
|
+
if (!svcArg) {
|
|
593
|
+
out("usage: devup logs <service> [--follow]");
|
|
594
|
+
return 1;
|
|
595
|
+
}
|
|
596
|
+
const knownSvcs = opts.config.services.map((s) => s.name);
|
|
597
|
+
if (!knownSvcs.includes(svcArg)) {
|
|
598
|
+
out(`Unknown service "${svcArg}". Known: ${knownSvcs.join(", ")}`);
|
|
599
|
+
return 1;
|
|
600
|
+
}
|
|
601
|
+
const file = join3(logRoot(opts.config, opts.logDir), `${sanitize(svcArg)}.log`);
|
|
602
|
+
if (!existsSync4(file)) {
|
|
603
|
+
out(`No log file yet for "${svcArg}" (${file})`);
|
|
604
|
+
return follow ? await followFile(file, out) : 1;
|
|
605
|
+
}
|
|
606
|
+
await streamFile(file, out);
|
|
607
|
+
if (!follow) return 0;
|
|
608
|
+
return await followFile(file, out, statSync(file).size);
|
|
609
|
+
}
|
|
610
|
+
async function streamFile(file, out) {
|
|
611
|
+
return new Promise((resolve4, reject) => {
|
|
612
|
+
const rl = createInterface({ input: createReadStream(file, { encoding: "utf8" }) });
|
|
613
|
+
rl.on("line", (l) => out(l));
|
|
614
|
+
rl.on("close", () => resolve4());
|
|
615
|
+
rl.on("error", reject);
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
async function followFile(file, out, startAt = 0) {
|
|
619
|
+
let pos = startAt;
|
|
620
|
+
while (!existsSync4(file)) await new Promise((r) => setTimeout(r, 500));
|
|
621
|
+
return new Promise((resolve4) => {
|
|
622
|
+
const tick = async () => {
|
|
623
|
+
const size = statSync(file).size;
|
|
624
|
+
if (size > pos) {
|
|
625
|
+
await new Promise((res) => {
|
|
626
|
+
const rl = createInterface({ input: createReadStream(file, { encoding: "utf8", start: pos, end: size - 1 }) });
|
|
627
|
+
rl.on("line", (l) => out(l));
|
|
628
|
+
rl.on("close", () => {
|
|
629
|
+
pos = size;
|
|
630
|
+
res();
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
} else if (size < pos) {
|
|
634
|
+
pos = 0;
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
watchFile(file, { interval: 500 }, () => {
|
|
638
|
+
void tick();
|
|
639
|
+
});
|
|
640
|
+
process.once("SIGINT", () => {
|
|
641
|
+
unwatchFile(file);
|
|
642
|
+
resolve4(0);
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
async function runInstall(opts) {
|
|
647
|
+
const out = opts.out ?? ((l) => console.log(l));
|
|
648
|
+
const concurrency = opts.concurrency ?? 4;
|
|
649
|
+
const items = opts.config.services.map((s) => ({ name: s.name, cwd: join3(opts.baseCwd, s.cwd) }));
|
|
650
|
+
const queue = [...items];
|
|
651
|
+
const failed = [];
|
|
652
|
+
let inFlight = 0;
|
|
653
|
+
await new Promise((resolve4) => {
|
|
654
|
+
const pump = () => {
|
|
655
|
+
while (inFlight < concurrency && queue.length) {
|
|
656
|
+
const item = queue.shift();
|
|
657
|
+
inFlight++;
|
|
658
|
+
installOne(item.cwd, opts.env).then((ok) => {
|
|
659
|
+
inFlight--;
|
|
660
|
+
if (ok) out(`\u2713 ${item.name}`);
|
|
661
|
+
else {
|
|
662
|
+
failed.push(item.name);
|
|
663
|
+
out(`\u2717 ${item.name}`);
|
|
664
|
+
}
|
|
665
|
+
if (queue.length === 0 && inFlight === 0) resolve4();
|
|
666
|
+
else pump();
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
pump();
|
|
671
|
+
});
|
|
672
|
+
if (failed.length) {
|
|
673
|
+
out(`
|
|
674
|
+
failed: ${failed.join(", ")}`);
|
|
675
|
+
return 1;
|
|
676
|
+
}
|
|
677
|
+
out(`
|
|
678
|
+
${items.length} services up to date`);
|
|
679
|
+
return 0;
|
|
680
|
+
}
|
|
681
|
+
function installOne(cwd, env) {
|
|
682
|
+
if (!existsSync4(cwd)) return Promise.resolve(false);
|
|
683
|
+
if (!needsInstall(cwd)) return Promise.resolve(true);
|
|
684
|
+
return new Promise((resolve4) => {
|
|
685
|
+
const command = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
686
|
+
const proc = spawn(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
|
|
687
|
+
proc.on("close", (code) => {
|
|
688
|
+
if (code === 0) {
|
|
689
|
+
writeInstallStamp(cwd);
|
|
690
|
+
resolve4(true);
|
|
691
|
+
} else resolve4(false);
|
|
692
|
+
});
|
|
693
|
+
proc.on("error", () => resolve4(false));
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
async function runStatus(opts) {
|
|
697
|
+
const out = opts.out ?? ((l) => console.log(l));
|
|
698
|
+
out(`${opts.config.icon ?? "\u{1F4E6}"} ${opts.config.name} \u2014 ${opts.config.services.length} services`);
|
|
699
|
+
out("");
|
|
700
|
+
const maxLen = Math.max(...opts.config.services.map((s) => s.name.length), 12);
|
|
701
|
+
out(`${"Service".padEnd(maxLen)} ${"Port".padStart(5)} ${"Type".padEnd(4)} Health`);
|
|
702
|
+
out("-".repeat(maxLen + 24));
|
|
703
|
+
for (const svc of opts.config.services) {
|
|
704
|
+
const up = await checkHealth(svc.port, svc.healthCheck);
|
|
705
|
+
const health = up ? "\u2713 up" : "\u2717 down";
|
|
706
|
+
out(`${svc.name.padEnd(maxLen)} ${String(svc.port).padStart(5)} ${svc.type.padEnd(4)} ${health}`);
|
|
707
|
+
}
|
|
708
|
+
return 0;
|
|
709
|
+
}
|
|
710
|
+
function runHelp(argv, opts = {}) {
|
|
711
|
+
const out = opts.out ?? ((l) => console.log(l));
|
|
712
|
+
const sub = argv[0];
|
|
713
|
+
if (sub === "logs") {
|
|
714
|
+
out("Usage: devup logs <service> [--follow|-f]");
|
|
715
|
+
out(" Print the persisted log file for a service (works without devup running).");
|
|
716
|
+
out(" --follow tails new lines as they are appended.");
|
|
717
|
+
return 0;
|
|
718
|
+
}
|
|
719
|
+
if (sub === "install") {
|
|
720
|
+
out("Usage: devup install");
|
|
721
|
+
out(" Run `npm install` across every service.cwd in parallel (max 4 at a time).");
|
|
722
|
+
out(" Skips services whose .install-stamp matches package.json hash.");
|
|
723
|
+
return 0;
|
|
724
|
+
}
|
|
725
|
+
if (sub === "status") {
|
|
726
|
+
out("Usage: devup status");
|
|
727
|
+
out(" For each service, probes its health-check endpoint and prints up/down.");
|
|
728
|
+
return 0;
|
|
729
|
+
}
|
|
730
|
+
out("Subcommands:");
|
|
731
|
+
out(" devup logs <service> [--follow] Read the persisted log file");
|
|
732
|
+
out(" devup install Concurrent npm install across services");
|
|
733
|
+
out(" devup status Health check every service in config");
|
|
734
|
+
out(" devup help [<subcommand>] Show detailed help for a subcommand");
|
|
735
|
+
out("");
|
|
736
|
+
out("No subcommand \u2192 launch the interactive TUI.");
|
|
737
|
+
return 0;
|
|
738
|
+
}
|
|
739
|
+
|
|
274
740
|
// src/platform/detect.ts
|
|
275
741
|
async function detectPlatform() {
|
|
276
742
|
switch (process.platform) {
|
|
@@ -292,8 +758,8 @@ async function detectPlatform() {
|
|
|
292
758
|
}
|
|
293
759
|
|
|
294
760
|
// src/proxy-config/traefik.ts
|
|
295
|
-
import { existsSync as
|
|
296
|
-
import { dirname } from "path";
|
|
761
|
+
import { existsSync as existsSync5, mkdirSync, writeFileSync as writeFileSync2 } from "fs";
|
|
762
|
+
import { dirname as dirname2 } from "path";
|
|
297
763
|
var EMPTY_CONFIG = "http:\n routers: {}\n services: {}\n";
|
|
298
764
|
var TraefikProvider = class {
|
|
299
765
|
name = "traefik";
|
|
@@ -330,9 +796,9 @@ ${svcs.join("\n")}
|
|
|
330
796
|
`;
|
|
331
797
|
}
|
|
332
798
|
write(content, opts) {
|
|
333
|
-
const dir =
|
|
334
|
-
if (!
|
|
335
|
-
|
|
799
|
+
const dir = dirname2(opts.confPath);
|
|
800
|
+
if (!existsSync5(dir)) mkdirSync(dir, { recursive: true });
|
|
801
|
+
writeFileSync2(opts.confPath, content);
|
|
336
802
|
}
|
|
337
803
|
clear(opts) {
|
|
338
804
|
this.write(EMPTY_CONFIG, opts);
|
|
@@ -340,8 +806,8 @@ ${svcs.join("\n")}
|
|
|
340
806
|
};
|
|
341
807
|
|
|
342
808
|
// src/proxy-config/nginx.ts
|
|
343
|
-
import { existsSync as
|
|
344
|
-
import { dirname as
|
|
809
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
810
|
+
import { dirname as dirname3 } from "path";
|
|
345
811
|
var EMPTY_CONFIG2 = "# devup: no healthy services\n";
|
|
346
812
|
var NginxProvider = class {
|
|
347
813
|
name = "nginx";
|
|
@@ -378,9 +844,9 @@ var NginxProvider = class {
|
|
|
378
844
|
return blocks.join("\n\n") + "\n";
|
|
379
845
|
}
|
|
380
846
|
write(content, opts) {
|
|
381
|
-
const dir =
|
|
382
|
-
if (!
|
|
383
|
-
|
|
847
|
+
const dir = dirname3(opts.confPath);
|
|
848
|
+
if (!existsSync6(dir)) mkdirSync2(dir, { recursive: true });
|
|
849
|
+
writeFileSync3(opts.confPath, content);
|
|
384
850
|
}
|
|
385
851
|
clear(opts) {
|
|
386
852
|
this.write(EMPTY_CONFIG2, opts);
|
|
@@ -388,8 +854,8 @@ var NginxProvider = class {
|
|
|
388
854
|
};
|
|
389
855
|
|
|
390
856
|
// src/proxy-config/caddy.ts
|
|
391
|
-
import { existsSync as
|
|
392
|
-
import { dirname as
|
|
857
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
|
|
858
|
+
import { dirname as dirname4 } from "path";
|
|
393
859
|
var EMPTY_CONFIG3 = "# devup: no healthy services\n";
|
|
394
860
|
var CaddyProvider = class {
|
|
395
861
|
name = "caddy";
|
|
@@ -401,144 +867,40 @@ var CaddyProvider = class {
|
|
|
401
867
|
if (sub === void 0) continue;
|
|
402
868
|
const host = sub ? `${sub}.${opts.domain}` : opts.domain;
|
|
403
869
|
const port = st.realPort ?? st.port;
|
|
404
|
-
const siteAddr = opts.tls ? host : `http://${host}`;
|
|
405
|
-
blocks.push(
|
|
406
|
-
`${siteAddr} {
|
|
407
|
-
reverse_proxy ${opts.host}:${port}
|
|
408
|
-
}`
|
|
409
|
-
);
|
|
410
|
-
}
|
|
411
|
-
if (!blocks.length) return EMPTY_CONFIG3;
|
|
412
|
-
return blocks.join("\n\n") + "\n";
|
|
413
|
-
}
|
|
414
|
-
write(content, opts) {
|
|
415
|
-
const dir = dirname3(opts.confPath);
|
|
416
|
-
if (!existsSync5(dir)) mkdirSync3(dir, { recursive: true });
|
|
417
|
-
writeFileSync3(opts.confPath, content);
|
|
418
|
-
}
|
|
419
|
-
clear(opts) {
|
|
420
|
-
this.write(EMPTY_CONFIG3, opts);
|
|
421
|
-
}
|
|
422
|
-
};
|
|
423
|
-
|
|
424
|
-
// src/proxy-config/detect.ts
|
|
425
|
-
var providers = {
|
|
426
|
-
traefik: () => new TraefikProvider(),
|
|
427
|
-
nginx: () => new NginxProvider(),
|
|
428
|
-
caddy: () => new CaddyProvider()
|
|
429
|
-
};
|
|
430
|
-
function detectProxyProvider(name) {
|
|
431
|
-
const factory = providers[name];
|
|
432
|
-
if (!factory) {
|
|
433
|
-
const available = Object.keys(providers).join(", ");
|
|
434
|
-
throw new Error(`Unknown proxy provider: "${name}". Available: ${available}`);
|
|
435
|
-
}
|
|
436
|
-
return factory();
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
// src/utils.ts
|
|
440
|
-
import { existsSync as existsSync6, readFileSync, writeFileSync as writeFileSync4 } from "fs";
|
|
441
|
-
import { createHash } from "crypto";
|
|
442
|
-
import { join as join2 } from "path";
|
|
443
|
-
function parseEnvFile(filePath, baseEnv = {}) {
|
|
444
|
-
const env = { ...baseEnv };
|
|
445
|
-
if (!existsSync6(filePath)) return env;
|
|
446
|
-
for (const line of readFileSync(filePath, "utf8").split("\n")) {
|
|
447
|
-
const trimmed = line.trim();
|
|
448
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
449
|
-
const eqIdx = trimmed.indexOf("=");
|
|
450
|
-
if (eqIdx === -1) continue;
|
|
451
|
-
const key = trimmed.slice(0, eqIdx).trim();
|
|
452
|
-
let val = trimmed.slice(eqIdx + 1).trim();
|
|
453
|
-
if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
|
|
454
|
-
val = val.slice(1, -1);
|
|
455
|
-
}
|
|
456
|
-
if (!env[key]) env[key] = val;
|
|
457
|
-
}
|
|
458
|
-
return env;
|
|
459
|
-
}
|
|
460
|
-
function fmtUptime(ms) {
|
|
461
|
-
if (!ms || ms < 0) return "-";
|
|
462
|
-
const s = Math.floor(ms / 1e3);
|
|
463
|
-
if (s < 60) return `${s}s`;
|
|
464
|
-
const m = Math.floor(s / 60);
|
|
465
|
-
if (m < 60) return `${m}m${s % 60}s`;
|
|
466
|
-
const h = Math.floor(m / 60);
|
|
467
|
-
if (h < 24) return `${h}h${m % 60}m`;
|
|
468
|
-
const d = Math.floor(h / 24);
|
|
469
|
-
return `${d}d${h % 24}h`;
|
|
470
|
-
}
|
|
471
|
-
function needsInstall(fullCwd) {
|
|
472
|
-
const nm = join2(fullCwd, "node_modules");
|
|
473
|
-
if (!existsSync6(nm)) return true;
|
|
474
|
-
try {
|
|
475
|
-
const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
|
|
476
|
-
const stampFile = join2(nm, ".install-stamp");
|
|
477
|
-
if (existsSync6(stampFile) && readFileSync(stampFile, "utf8") === pkgHash) return false;
|
|
478
|
-
} catch {
|
|
479
|
-
}
|
|
480
|
-
return true;
|
|
481
|
-
}
|
|
482
|
-
function writeInstallStamp(fullCwd) {
|
|
483
|
-
try {
|
|
484
|
-
const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
|
|
485
|
-
writeFileSync4(join2(fullCwd, "node_modules", ".install-stamp"), pkgHash);
|
|
486
|
-
} catch {
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
function sortServiceNames(names, sortMode, statsMap, procState) {
|
|
490
|
-
if (sortMode === "name") return names.slice().sort();
|
|
491
|
-
return names.slice().sort((a, b) => {
|
|
492
|
-
if (sortMode === "mem") {
|
|
493
|
-
return (parseFloat(statsMap[b]?.mem ?? "0") || 0) - (parseFloat(statsMap[a]?.mem ?? "0") || 0);
|
|
494
|
-
}
|
|
495
|
-
return (procState[b]?.errors ?? 0) - (procState[a]?.errors ?? 0);
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
|
-
function groupByPhase(services) {
|
|
499
|
-
const phases = {};
|
|
500
|
-
for (const s of services) {
|
|
501
|
-
(phases[s.phase] ??= []).push(s);
|
|
502
|
-
}
|
|
503
|
-
return phases;
|
|
504
|
-
}
|
|
505
|
-
function buildProcessArgs(svc) {
|
|
506
|
-
const extra = svc.nodeArgs ?? [];
|
|
507
|
-
if (!svc.maxMem) return [...extra, ...svc.args];
|
|
508
|
-
if (svc.cmd === "node") return [`--max-old-space-size=${svc.maxMem}`, ...extra, ...svc.args];
|
|
509
|
-
return [...extra, ...svc.args];
|
|
510
|
-
}
|
|
511
|
-
function buildProcessEnv(svc, baseEnv) {
|
|
512
|
-
const env = { ...baseEnv, ...svc.extraEnv ?? {} };
|
|
513
|
-
if (svc.maxMem && svc.cmd !== "node") {
|
|
514
|
-
const existing = env["NODE_OPTIONS"] ?? "";
|
|
515
|
-
const flag = `--max-old-space-size=${svc.maxMem}`;
|
|
516
|
-
if (!existing.includes("max-old-space-size")) {
|
|
517
|
-
env["NODE_OPTIONS"] = existing ? `${existing} ${flag}` : flag;
|
|
870
|
+
const siteAddr = opts.tls ? host : `http://${host}`;
|
|
871
|
+
blocks.push(
|
|
872
|
+
`${siteAddr} {
|
|
873
|
+
reverse_proxy ${opts.host}:${port}
|
|
874
|
+
}`
|
|
875
|
+
);
|
|
518
876
|
}
|
|
877
|
+
if (!blocks.length) return EMPTY_CONFIG3;
|
|
878
|
+
return blocks.join("\n\n") + "\n";
|
|
519
879
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
880
|
+
write(content, opts) {
|
|
881
|
+
const dir = dirname4(opts.confPath);
|
|
882
|
+
if (!existsSync7(dir)) mkdirSync3(dir, { recursive: true });
|
|
883
|
+
writeFileSync4(opts.confPath, content);
|
|
884
|
+
}
|
|
885
|
+
clear(opts) {
|
|
886
|
+
this.write(EMPTY_CONFIG3, opts);
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
// src/proxy-config/detect.ts
|
|
891
|
+
var providers = {
|
|
892
|
+
traefik: () => new TraefikProvider(),
|
|
893
|
+
nginx: () => new NginxProvider(),
|
|
894
|
+
caddy: () => new CaddyProvider()
|
|
895
|
+
};
|
|
896
|
+
function detectProxyProvider(name) {
|
|
897
|
+
const factory = providers[name];
|
|
898
|
+
if (!factory) {
|
|
899
|
+
const available = Object.keys(providers).join(", ");
|
|
900
|
+
throw new Error(`Unknown proxy provider: "${name}". Available: ${available}`);
|
|
901
|
+
}
|
|
902
|
+
return factory();
|
|
526
903
|
}
|
|
527
|
-
var tagColors = [
|
|
528
|
-
"cyan",
|
|
529
|
-
"yellow",
|
|
530
|
-
"green",
|
|
531
|
-
"magenta",
|
|
532
|
-
"blue",
|
|
533
|
-
"red",
|
|
534
|
-
"#5faf5f",
|
|
535
|
-
"#d7af5f",
|
|
536
|
-
"#5f87d7",
|
|
537
|
-
"#af5faf",
|
|
538
|
-
"#5fd7d7",
|
|
539
|
-
"#d75f5f",
|
|
540
|
-
"white"
|
|
541
|
-
];
|
|
542
904
|
|
|
543
905
|
// src/tui/App.tsx
|
|
544
906
|
import { useEffect as useEffect5, useState as useState5, useCallback as useCallback3, useRef as useRef3 } from "react";
|
|
@@ -548,89 +910,15 @@ import { Box as Box6, Text as Text6, useStdout } from "ink";
|
|
|
548
910
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
549
911
|
|
|
550
912
|
// src/process/manager.ts
|
|
551
|
-
import { spawn as
|
|
552
|
-
import {
|
|
553
|
-
|
|
554
|
-
// src/process/health.ts
|
|
555
|
-
import net from "net";
|
|
556
|
-
import http from "http";
|
|
557
|
-
function checkPort(port, host = "127.0.0.1", timeoutMs = 2e3) {
|
|
558
|
-
return new Promise((resolve3) => {
|
|
559
|
-
const socket = new net.Socket();
|
|
560
|
-
socket.setTimeout(timeoutMs);
|
|
561
|
-
socket.once("connect", () => {
|
|
562
|
-
socket.destroy();
|
|
563
|
-
resolve3(true);
|
|
564
|
-
});
|
|
565
|
-
socket.once("error", () => {
|
|
566
|
-
socket.destroy();
|
|
567
|
-
resolve3(false);
|
|
568
|
-
});
|
|
569
|
-
socket.once("timeout", () => {
|
|
570
|
-
socket.destroy();
|
|
571
|
-
resolve3(false);
|
|
572
|
-
});
|
|
573
|
-
socket.connect(port, host);
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
function checkHttp(port, opts = {}) {
|
|
577
|
-
const path = opts.path ?? "/";
|
|
578
|
-
const host = opts.host ?? "127.0.0.1";
|
|
579
|
-
const timeoutMs = opts.timeoutMs ?? 2e3;
|
|
580
|
-
const accept = (code) => {
|
|
581
|
-
if (opts.expect === void 0) return code >= 200 && code < 300;
|
|
582
|
-
if (Array.isArray(opts.expect)) return opts.expect.includes(code);
|
|
583
|
-
return code === opts.expect;
|
|
584
|
-
};
|
|
585
|
-
return new Promise((resolve3) => {
|
|
586
|
-
const req = http.get({ host, port, path, timeout: timeoutMs }, (res) => {
|
|
587
|
-
const ok = typeof res.statusCode === "number" && accept(res.statusCode);
|
|
588
|
-
res.resume();
|
|
589
|
-
resolve3(ok);
|
|
590
|
-
});
|
|
591
|
-
req.on("error", () => resolve3(false));
|
|
592
|
-
req.on("timeout", () => {
|
|
593
|
-
req.destroy();
|
|
594
|
-
resolve3(false);
|
|
595
|
-
});
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
function checkHealth(port, hc) {
|
|
599
|
-
if (hc?.type === "http") {
|
|
600
|
-
return checkHttp(port, {
|
|
601
|
-
path: hc.path,
|
|
602
|
-
expect: hc.expect,
|
|
603
|
-
host: hc.host,
|
|
604
|
-
timeoutMs: hc.timeoutMs
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
return checkPort(port, "127.0.0.1", hc?.timeoutMs);
|
|
608
|
-
}
|
|
609
|
-
function waitForPort(port, opts = {}) {
|
|
610
|
-
const { timeout = 45e3, interval = 1e3 } = opts;
|
|
611
|
-
return new Promise((resolve3) => {
|
|
612
|
-
const start = Date.now();
|
|
613
|
-
const check = () => {
|
|
614
|
-
checkPort(port).then((ok) => {
|
|
615
|
-
if (ok) return resolve3(true);
|
|
616
|
-
if (Date.now() - start > timeout) return resolve3(false);
|
|
617
|
-
setTimeout(check, interval);
|
|
618
|
-
});
|
|
619
|
-
};
|
|
620
|
-
check();
|
|
621
|
-
});
|
|
622
|
-
}
|
|
623
|
-
function deriveHealth(isUp, currentStatus) {
|
|
624
|
-
if (currentStatus === "idle") return "idle";
|
|
625
|
-
if (isUp) return "up";
|
|
626
|
-
return currentStatus === "starting" ? "wait" : "down";
|
|
627
|
-
}
|
|
913
|
+
import { spawn as spawn3 } from "child_process";
|
|
914
|
+
import { existsSync as existsSync9 } from "fs";
|
|
915
|
+
import { join as join4, resolve as resolve3 } from "path";
|
|
628
916
|
|
|
629
917
|
// src/process/installer.ts
|
|
630
|
-
import { spawn } from "child_process";
|
|
631
|
-
import { existsSync as
|
|
918
|
+
import { spawn as spawn2 } from "child_process";
|
|
919
|
+
import { existsSync as existsSync8 } from "fs";
|
|
632
920
|
function installService(cwd, env, onLog) {
|
|
633
|
-
if (!
|
|
921
|
+
if (!existsSync8(cwd)) {
|
|
634
922
|
onLog?.(`\u26A0 directory not found: ${cwd}`);
|
|
635
923
|
return Promise.resolve(false);
|
|
636
924
|
}
|
|
@@ -639,9 +927,9 @@ function installService(cwd, env, onLog) {
|
|
|
639
927
|
return Promise.resolve(true);
|
|
640
928
|
}
|
|
641
929
|
onLog?.("\u{1F4E6} npm install...");
|
|
642
|
-
return new Promise((
|
|
930
|
+
return new Promise((resolve4) => {
|
|
643
931
|
const command = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
644
|
-
const proc =
|
|
932
|
+
const proc = spawn2(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
|
|
645
933
|
let stderr = "";
|
|
646
934
|
proc.stderr?.on("data", (d) => {
|
|
647
935
|
stderr += d.toString();
|
|
@@ -649,16 +937,16 @@ function installService(cwd, env, onLog) {
|
|
|
649
937
|
proc.on("close", (code) => {
|
|
650
938
|
if (code !== 0) {
|
|
651
939
|
onLog?.(`\u26A0 npm install failed: ${stderr.split("\n")[0]}`);
|
|
652
|
-
|
|
940
|
+
resolve4(false);
|
|
653
941
|
} else {
|
|
654
942
|
writeInstallStamp(cwd);
|
|
655
943
|
onLog?.("\u2705 dependencies ready");
|
|
656
|
-
|
|
944
|
+
resolve4(true);
|
|
657
945
|
}
|
|
658
946
|
});
|
|
659
947
|
proc.on("error", (err) => {
|
|
660
948
|
onLog?.(`\u26A0 spawn error: ${err.message}`);
|
|
661
|
-
|
|
949
|
+
resolve4(false);
|
|
662
950
|
});
|
|
663
951
|
});
|
|
664
952
|
}
|
|
@@ -666,6 +954,36 @@ function installService(cwd, env, onLog) {
|
|
|
666
954
|
// src/process/manager.ts
|
|
667
955
|
var MAX_RESTARTS = 3;
|
|
668
956
|
var BACKOFF_BASE_MS = 2e3;
|
|
957
|
+
function compileReadyPattern(pattern) {
|
|
958
|
+
if (!pattern) return null;
|
|
959
|
+
const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(pattern);
|
|
960
|
+
try {
|
|
961
|
+
if (slashed) return new RegExp(slashed[1], slashed[2] || "i");
|
|
962
|
+
return new RegExp(pattern, "i");
|
|
963
|
+
} catch {
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
function extractWatchPaths(args) {
|
|
968
|
+
const watchFlags = /* @__PURE__ */ new Set(["--watch", "--watch-path"]);
|
|
969
|
+
const out = [];
|
|
970
|
+
for (let i = 0; i < args.length; i++) {
|
|
971
|
+
const a = args[i];
|
|
972
|
+
if (watchFlags.has(a)) {
|
|
973
|
+
const v = args[i + 1];
|
|
974
|
+
if (v && !v.startsWith("-")) {
|
|
975
|
+
out.push(v);
|
|
976
|
+
i++;
|
|
977
|
+
}
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
const eq = a.indexOf("=");
|
|
981
|
+
if (eq > 0 && watchFlags.has(a.slice(0, eq))) {
|
|
982
|
+
out.push(a.slice(eq + 1));
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return out;
|
|
986
|
+
}
|
|
669
987
|
function lineBuffer(onLine) {
|
|
670
988
|
let buf = "";
|
|
671
989
|
return {
|
|
@@ -700,12 +1018,12 @@ var ProcessManager = class {
|
|
|
700
1018
|
this.events = opts.events;
|
|
701
1019
|
}
|
|
702
1020
|
async install(svc, colorIdx) {
|
|
703
|
-
const cwd =
|
|
1021
|
+
const cwd = join4(this.baseCwd, svc.cwd);
|
|
704
1022
|
const idx = colorIdx ?? this.state.get(svc.name)?.colorIdx ?? 0;
|
|
705
1023
|
return installService(cwd, this.env, (msg) => this.log(svc.name, msg, idx));
|
|
706
1024
|
}
|
|
707
1025
|
async start(svc, colorIdx, isRestart = false) {
|
|
708
|
-
const cwd =
|
|
1026
|
+
const cwd = join4(this.baseCwd, svc.cwd);
|
|
709
1027
|
if (svc.type === "api") {
|
|
710
1028
|
const occupied = await checkPort(svc.port);
|
|
711
1029
|
if (occupied && !isRestart) {
|
|
@@ -713,9 +1031,22 @@ var ProcessManager = class {
|
|
|
713
1031
|
return;
|
|
714
1032
|
}
|
|
715
1033
|
}
|
|
1034
|
+
if (svc.preBuild) {
|
|
1035
|
+
const built = await this.runPreBuild(svc, cwd, colorIdx);
|
|
1036
|
+
if (!built) {
|
|
1037
|
+
this.recordCrashedState(svc, colorIdx);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
716
1041
|
const args = buildProcessArgs(svc);
|
|
1042
|
+
const missingWatchPaths = extractWatchPaths(args).filter((p) => !existsSync9(resolve3(cwd, p)));
|
|
1043
|
+
if (missingWatchPaths.length) {
|
|
1044
|
+
this.log(svc.name, `\u26A0 missing watch paths: ${missingWatchPaths.join(", ")}`, colorIdx);
|
|
1045
|
+
this.recordCrashedState(svc, colorIdx);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
717
1048
|
const env = buildProcessEnv(svc, this.env);
|
|
718
|
-
const proc =
|
|
1049
|
+
const proc = spawn3(svc.cmd, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
719
1050
|
const prev = this.state.get(svc.name);
|
|
720
1051
|
const state = {
|
|
721
1052
|
svc,
|
|
@@ -732,9 +1063,22 @@ var ProcessManager = class {
|
|
|
732
1063
|
this.state.set(svc.name, state);
|
|
733
1064
|
this.procs.add(proc);
|
|
734
1065
|
this.events.onStateChange(svc.name, state);
|
|
735
|
-
const
|
|
1066
|
+
const readyRegex = compileReadyPattern(svc.readyPattern);
|
|
1067
|
+
const markReadyIfMatch = (line) => {
|
|
1068
|
+
if (!readyRegex || state.health === "up") return;
|
|
1069
|
+
if (readyRegex.test(line)) {
|
|
1070
|
+
state.health = "up";
|
|
1071
|
+
if (state.status === "starting") state.status = "running";
|
|
1072
|
+
this.events.onStateChange(svc.name, state);
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
const stdoutBuf = lineBuffer((line) => {
|
|
1076
|
+
markReadyIfMatch(line);
|
|
1077
|
+
this.log(svc.name, line, colorIdx);
|
|
1078
|
+
});
|
|
736
1079
|
const stderrBuf = lineBuffer((line) => {
|
|
737
1080
|
state.errors += 1;
|
|
1081
|
+
markReadyIfMatch(line);
|
|
738
1082
|
this.log(svc.name, line, colorIdx);
|
|
739
1083
|
});
|
|
740
1084
|
proc.stdout?.on("data", (d) => stdoutBuf.push(d));
|
|
@@ -743,6 +1087,7 @@ var ProcessManager = class {
|
|
|
743
1087
|
proc.stderr?.on("end", () => stderrBuf.flush());
|
|
744
1088
|
proc.on("close", (code) => {
|
|
745
1089
|
this.procs.delete(proc);
|
|
1090
|
+
this.stopWatchProc(state);
|
|
746
1091
|
if (state.intentionalStop) {
|
|
747
1092
|
state.intentionalStop = false;
|
|
748
1093
|
return;
|
|
@@ -766,13 +1111,90 @@ var ProcessManager = class {
|
|
|
766
1111
|
this.log(svc.name, "\u26D4 max restarts reached", colorIdx);
|
|
767
1112
|
}
|
|
768
1113
|
});
|
|
1114
|
+
if (svc.watchBuild) {
|
|
1115
|
+
state.watchProc = this.spawnWatchBuild(svc, cwd, env, colorIdx);
|
|
1116
|
+
}
|
|
769
1117
|
this.log(svc.name, isRestart ? `\u{1F504} restarted (:${svc.port})` : `\u{1F680} started (:${svc.port})`, colorIdx);
|
|
770
1118
|
}
|
|
1119
|
+
runPreBuild(svc, cwd, colorIdx) {
|
|
1120
|
+
this.log(svc.name, `\u{1F528} preBuild: ${svc.preBuild}`, colorIdx);
|
|
1121
|
+
return new Promise((resolve4) => {
|
|
1122
|
+
const isWin = process.platform === "win32";
|
|
1123
|
+
const shell = isWin ? "cmd.exe" : "sh";
|
|
1124
|
+
const shellFlag = isWin ? "/c" : "-c";
|
|
1125
|
+
const env = buildProcessEnv(svc, this.env);
|
|
1126
|
+
const child = spawn3(shell, [shellFlag, svc.preBuild], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
1127
|
+
const outBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
|
|
1128
|
+
const errBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
|
|
1129
|
+
child.stdout?.on("data", (d) => outBuf.push(d));
|
|
1130
|
+
child.stderr?.on("data", (d) => errBuf.push(d));
|
|
1131
|
+
child.on("error", (err) => {
|
|
1132
|
+
this.log(svc.name, `[build] \u274C ${err.message}`, colorIdx);
|
|
1133
|
+
resolve4(false);
|
|
1134
|
+
});
|
|
1135
|
+
child.on("close", (code) => {
|
|
1136
|
+
outBuf.flush();
|
|
1137
|
+
errBuf.flush();
|
|
1138
|
+
if (code === 0) {
|
|
1139
|
+
this.log(svc.name, `[build] \u2705 done`, colorIdx);
|
|
1140
|
+
resolve4(true);
|
|
1141
|
+
} else {
|
|
1142
|
+
this.log(svc.name, `[build] \u274C exited with code ${code}`, colorIdx);
|
|
1143
|
+
resolve4(false);
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
spawnWatchBuild(svc, cwd, env, colorIdx) {
|
|
1149
|
+
this.log(svc.name, `\u{1F440} watchBuild: ${svc.watchBuild}`, colorIdx);
|
|
1150
|
+
const isWin = process.platform === "win32";
|
|
1151
|
+
const shell = isWin ? "cmd.exe" : "sh";
|
|
1152
|
+
const shellFlag = isWin ? "/c" : "-c";
|
|
1153
|
+
const child = spawn3(shell, [shellFlag, svc.watchBuild], {
|
|
1154
|
+
cwd,
|
|
1155
|
+
env,
|
|
1156
|
+
detached: true,
|
|
1157
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1158
|
+
});
|
|
1159
|
+
const outBuf = lineBuffer((line) => this.log(svc.name, `[watch] ${line}`, colorIdx));
|
|
1160
|
+
const errBuf = lineBuffer((line) => this.log(svc.name, `[watch] ${line}`, colorIdx));
|
|
1161
|
+
child.stdout?.on("data", (d) => outBuf.push(d));
|
|
1162
|
+
child.stderr?.on("data", (d) => errBuf.push(d));
|
|
1163
|
+
child.on("error", (err) => this.log(svc.name, `[watch] \u274C ${err.message}`, colorIdx));
|
|
1164
|
+
return child;
|
|
1165
|
+
}
|
|
1166
|
+
/** Create a state entry in 'crashed' status without spawning a process (used when preBuild fails). */
|
|
1167
|
+
recordCrashedState(svc, colorIdx) {
|
|
1168
|
+
const prev = this.state.get(svc.name);
|
|
1169
|
+
this.state.set(svc.name, {
|
|
1170
|
+
svc,
|
|
1171
|
+
proc: null,
|
|
1172
|
+
pid: null,
|
|
1173
|
+
status: "crashed",
|
|
1174
|
+
health: "down",
|
|
1175
|
+
errors: prev?.errors ?? 0,
|
|
1176
|
+
restarts: prev?.restarts ?? 0,
|
|
1177
|
+
startedAt: null,
|
|
1178
|
+
intentionalStop: false,
|
|
1179
|
+
colorIdx
|
|
1180
|
+
});
|
|
1181
|
+
this.events.onStateChange(svc.name, this.state.get(svc.name));
|
|
1182
|
+
}
|
|
771
1183
|
stop(name) {
|
|
772
1184
|
const st = this.state.get(name);
|
|
773
1185
|
if (!st?.proc || !st.pid) return;
|
|
774
1186
|
st.intentionalStop = true;
|
|
775
1187
|
this.platform.killTree(st.pid);
|
|
1188
|
+
this.stopWatchProc(st);
|
|
1189
|
+
}
|
|
1190
|
+
stopWatchProc(state) {
|
|
1191
|
+
const wp = state.watchProc;
|
|
1192
|
+
if (!wp || !wp.pid) return;
|
|
1193
|
+
try {
|
|
1194
|
+
this.platform.killTree(wp.pid);
|
|
1195
|
+
} catch {
|
|
1196
|
+
}
|
|
1197
|
+
state.watchProc = null;
|
|
776
1198
|
}
|
|
777
1199
|
async restart(name) {
|
|
778
1200
|
const st = this.state.get(name);
|
|
@@ -803,18 +1225,22 @@ var ProcessManager = class {
|
|
|
803
1225
|
if (!procs.length) return;
|
|
804
1226
|
for (const proc of procs) {
|
|
805
1227
|
const st = this.findStateByProc(proc);
|
|
806
|
-
if (st)
|
|
1228
|
+
if (st) {
|
|
1229
|
+
st.intentionalStop = true;
|
|
1230
|
+
this.stopWatchProc(st);
|
|
1231
|
+
}
|
|
807
1232
|
if (proc.pid) this.platform.killTree(proc.pid);
|
|
808
1233
|
}
|
|
1234
|
+
for (const st of this.state.values()) this.stopWatchProc(st);
|
|
809
1235
|
const waits = procs.map(
|
|
810
|
-
(p) => p.exitCode !== null || p.signalCode !== null ? Promise.resolve() : new Promise((
|
|
1236
|
+
(p) => p.exitCode !== null || p.signalCode !== null ? Promise.resolve() : new Promise((resolve4) => p.once("close", () => resolve4()))
|
|
811
1237
|
);
|
|
812
1238
|
let timedOut = false;
|
|
813
1239
|
await Promise.race([
|
|
814
1240
|
Promise.all(waits),
|
|
815
|
-
new Promise((
|
|
1241
|
+
new Promise((resolve4) => setTimeout(() => {
|
|
816
1242
|
timedOut = true;
|
|
817
|
-
|
|
1243
|
+
resolve4();
|
|
818
1244
|
}, grace))
|
|
819
1245
|
]);
|
|
820
1246
|
if (timedOut) {
|
|
@@ -825,7 +1251,7 @@ var ProcessManager = class {
|
|
|
825
1251
|
}
|
|
826
1252
|
await Promise.race([
|
|
827
1253
|
Promise.all(waits),
|
|
828
|
-
new Promise((
|
|
1254
|
+
new Promise((resolve4) => setTimeout(resolve4, 1e3))
|
|
829
1255
|
]);
|
|
830
1256
|
}
|
|
831
1257
|
}
|
|
@@ -913,6 +1339,21 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
|
|
|
913
1339
|
pendingLogsRef.current = [];
|
|
914
1340
|
setLogs([]);
|
|
915
1341
|
}, []);
|
|
1342
|
+
const pushLog = useCallback((svcName, text, colorIdx = 0) => {
|
|
1343
|
+
sinkRef.current?.write(svcName, text);
|
|
1344
|
+
const entry = { svcName, text, colorIdx, ts: Date.now() };
|
|
1345
|
+
if (pausedRef.current) {
|
|
1346
|
+
pendingLogsRef.current.push(entry);
|
|
1347
|
+
if (pendingLogsRef.current.length > 5e3) {
|
|
1348
|
+
pendingLogsRef.current = pendingLogsRef.current.slice(-5e3);
|
|
1349
|
+
}
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
setLogs((prev) => {
|
|
1353
|
+
const next = prev.concat(entry);
|
|
1354
|
+
return next.length > 5e3 ? next.slice(-5e3) : next;
|
|
1355
|
+
});
|
|
1356
|
+
}, []);
|
|
916
1357
|
const setPaused = useCallback((paused) => {
|
|
917
1358
|
pausedRef.current = paused;
|
|
918
1359
|
if (!paused && pendingLogsRef.current.length) {
|
|
@@ -935,6 +1376,7 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
|
|
|
935
1376
|
cleanup: useCallback(() => mgr?.cleanup(), [mgr]),
|
|
936
1377
|
clearLogs,
|
|
937
1378
|
setPaused,
|
|
1379
|
+
pushLog,
|
|
938
1380
|
manager: mgr
|
|
939
1381
|
};
|
|
940
1382
|
}
|
|
@@ -1105,19 +1547,25 @@ var H = {
|
|
|
1105
1547
|
down: { c: "\u25CF", color: "red" },
|
|
1106
1548
|
idle: { c: "\u25CB", color: "blue" }
|
|
1107
1549
|
};
|
|
1550
|
+
var MAX_RESTARTS2 = 3;
|
|
1551
|
+
function isCrashLooped(st) {
|
|
1552
|
+
return st.status === "crashed" && st.restarts >= MAX_RESTARTS2;
|
|
1553
|
+
}
|
|
1108
1554
|
function Row({ name, st, stat, ml }) {
|
|
1109
|
-
const
|
|
1555
|
+
const looped = isCrashLooped(st);
|
|
1556
|
+
const indicator = looped ? /* @__PURE__ */ jsx2(Text2, { color: "red", bold: true, children: "\u2716" }) : /* @__PURE__ */ jsx2(Text2, { color: (H[st.health] ?? H["down"]).color, children: (H[st.health] ?? H["down"]).c });
|
|
1110
1557
|
const color = tagColors[st.colorIdx % tagColors.length];
|
|
1111
|
-
const sc = st.status === "running" ? "green" : st.status === "starting" ? "yellow" : st.status === "idle" ? "blue" : "red";
|
|
1558
|
+
const sc = looped ? "red" : st.status === "running" ? "green" : st.status === "starting" ? "yellow" : st.status === "idle" ? "blue" : "red";
|
|
1559
|
+
const statusLabel = looped ? "looping" : st.status;
|
|
1112
1560
|
const up = st.startedAt ? fmtUptime(Date.now() - st.startedAt) : "-";
|
|
1113
1561
|
return /* @__PURE__ */ jsxs2(Text2, { children: [
|
|
1114
|
-
|
|
1562
|
+
indicator,
|
|
1115
1563
|
" ",
|
|
1116
1564
|
/* @__PURE__ */ jsx2(Text2, { color, children: name.padEnd(ml) }),
|
|
1117
1565
|
" ",
|
|
1118
1566
|
String(st.svc.port).padStart(5),
|
|
1119
1567
|
" ",
|
|
1120
|
-
/* @__PURE__ */ jsx2(Text2, { color: sc, children:
|
|
1568
|
+
/* @__PURE__ */ jsx2(Text2, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
|
|
1121
1569
|
" ",
|
|
1122
1570
|
(stat?.cpu ?? "-").padStart(6),
|
|
1123
1571
|
" ",
|
|
@@ -1184,6 +1632,7 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
|
|
|
1184
1632
|
const totalRowsLong = Math.max(apis.length, webs.length);
|
|
1185
1633
|
const positionInfo = focused && totalRowsLong > 0 ? `(${effectiveOffset + 1}-${Math.min(effectiveOffset + rowsPerCol, totalRowsLong)}/${totalRowsLong})` : "";
|
|
1186
1634
|
const scrolled = effectiveOffset > 0;
|
|
1635
|
+
const loopedCount = [...states.values()].filter(isCrashLooped).length;
|
|
1187
1636
|
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "green" : "gray", height, children: [
|
|
1188
1637
|
/* @__PURE__ */ jsxs2(Box2, { children: [
|
|
1189
1638
|
/* @__PURE__ */ jsxs2(Text2, { bold: true, color: "green", children: [
|
|
@@ -1191,6 +1640,11 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
|
|
|
1191
1640
|
positionInfo
|
|
1192
1641
|
] }),
|
|
1193
1642
|
scrolled && /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: " [SCROLL]" }),
|
|
1643
|
+
loopedCount > 0 && /* @__PURE__ */ jsxs2(Text2, { color: "red", bold: true, children: [
|
|
1644
|
+
" \u26A0 ",
|
|
1645
|
+
loopedCount,
|
|
1646
|
+
" need attention"
|
|
1647
|
+
] }),
|
|
1194
1648
|
/* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
|
|
1195
1649
|
" System: ",
|
|
1196
1650
|
cpus,
|
|
@@ -1283,33 +1737,69 @@ function StatusBar() {
|
|
|
1283
1737
|
}
|
|
1284
1738
|
|
|
1285
1739
|
// src/tui/ServiceList.tsx
|
|
1286
|
-
import { useState as useState3 } from "react";
|
|
1740
|
+
import { useState as useState3, useMemo } from "react";
|
|
1287
1741
|
import { Box as Box4, Text as Text4, useInput as useInput2 } from "ink";
|
|
1288
1742
|
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
1289
1743
|
function ServiceList({ title, services, onSelect, onClose, filterType }) {
|
|
1290
|
-
const
|
|
1744
|
+
const allNames = useMemo(
|
|
1745
|
+
() => [...services.keys()].filter((n) => !filterType || services.get(n).svc.type === filterType),
|
|
1746
|
+
[services, filterType]
|
|
1747
|
+
);
|
|
1291
1748
|
const [idx, setIdx] = useState3(0);
|
|
1749
|
+
const [query, setQuery] = useState3("");
|
|
1750
|
+
const names = useMemo(() => {
|
|
1751
|
+
if (!query) return allNames;
|
|
1752
|
+
const q = query.toLowerCase();
|
|
1753
|
+
return allNames.filter((n) => n.toLowerCase().includes(q));
|
|
1754
|
+
}, [allNames, query]);
|
|
1755
|
+
const clamped = Math.min(idx, Math.max(0, names.length - 1));
|
|
1292
1756
|
useInput2((input, key) => {
|
|
1293
|
-
if (key.escape)
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1757
|
+
if (key.escape) {
|
|
1758
|
+
if (query) setQuery("");
|
|
1759
|
+
else onClose();
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
if (key.return) {
|
|
1763
|
+
if (names[clamped]) onSelect(names[clamped]);
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
if (key.upArrow) {
|
|
1767
|
+
setIdx((i) => Math.max(0, i - 1));
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
if (key.downArrow) {
|
|
1771
|
+
setIdx((i) => Math.min(names.length - 1, i + 1));
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
if (key.backspace || key.delete) {
|
|
1775
|
+
setQuery((q) => q.slice(0, -1));
|
|
1776
|
+
setIdx(0);
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
if (input && !key.ctrl && !key.meta && input.length === 1) {
|
|
1780
|
+
setQuery((q) => q + input);
|
|
1781
|
+
setIdx(0);
|
|
1782
|
+
}
|
|
1298
1783
|
}, { isActive: process.stdin.isTTY ?? false });
|
|
1299
1784
|
return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
|
|
1300
1785
|
/* @__PURE__ */ jsxs4(Text4, { bold: true, color: "cyan", children: [
|
|
1301
1786
|
" ",
|
|
1302
1787
|
title,
|
|
1303
|
-
" "
|
|
1788
|
+
" ",
|
|
1789
|
+
query && /* @__PURE__ */ jsxs4(Text4, { color: "yellow", children: [
|
|
1790
|
+
"[",
|
|
1791
|
+
query,
|
|
1792
|
+
"]"
|
|
1793
|
+
] })
|
|
1304
1794
|
] }),
|
|
1305
|
-
names.map((name, i) => /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs4(Text4, { color: i ===
|
|
1795
|
+
names.length === 0 ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " (no matches) " }) : names.map((name, i) => /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs4(Text4, { color: i === clamped ? "cyan" : void 0, inverse: i === clamped, children: [
|
|
1306
1796
|
" ",
|
|
1307
1797
|
name,
|
|
1308
1798
|
" :",
|
|
1309
1799
|
services.get(name).svc.port,
|
|
1310
1800
|
" "
|
|
1311
1801
|
] }) }, name)),
|
|
1312
|
-
/* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u2191\u2193 navigate Enter select Esc close" })
|
|
1802
|
+
/* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "type to filter \u2191\u2193 navigate Enter select Esc clear/close" })
|
|
1313
1803
|
] });
|
|
1314
1804
|
}
|
|
1315
1805
|
|
|
@@ -1445,8 +1935,113 @@ function createLazyProxy(opts) {
|
|
|
1445
1935
|
};
|
|
1446
1936
|
}
|
|
1447
1937
|
|
|
1938
|
+
// src/process/external.ts
|
|
1939
|
+
import { spawn as spawn4 } from "child_process";
|
|
1940
|
+
import { join as join5 } from "path";
|
|
1941
|
+
var DEFAULT_START_TIMEOUT_S = 60;
|
|
1942
|
+
async function startExternals(externals, opts) {
|
|
1943
|
+
const procs = [];
|
|
1944
|
+
const failed = [];
|
|
1945
|
+
for (const svc of externals) {
|
|
1946
|
+
const proc = spawnExternal(svc, opts);
|
|
1947
|
+
procs.push({ svc, proc, pid: proc.pid ?? null });
|
|
1948
|
+
if (!svc.healthCheck) {
|
|
1949
|
+
opts.onLog?.(svc.name, "\u2705 started (no healthCheck)");
|
|
1950
|
+
continue;
|
|
1951
|
+
}
|
|
1952
|
+
if (svc.healthCheck.type === "tcp" && !svc.port) {
|
|
1953
|
+
opts.onLog?.(svc.name, "\u26A0 tcp healthCheck requires `port` \u2014 skipping wait");
|
|
1954
|
+
continue;
|
|
1955
|
+
}
|
|
1956
|
+
const timeoutMs = (svc.startTimeout ?? DEFAULT_START_TIMEOUT_S) * 1e3;
|
|
1957
|
+
const ok = await waitHealthy(svc, timeoutMs);
|
|
1958
|
+
if (ok) {
|
|
1959
|
+
opts.onLog?.(svc.name, "\u2705 healthy");
|
|
1960
|
+
} else {
|
|
1961
|
+
opts.onLog?.(svc.name, `\u274C never became healthy (timeout ${timeoutMs / 1e3}s)`);
|
|
1962
|
+
failed.push(svc.name);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
return { procs, allHealthy: failed.length === 0, failed };
|
|
1966
|
+
}
|
|
1967
|
+
async function stopExternals(procs, platform, opts = {}) {
|
|
1968
|
+
for (const { svc, proc, pid } of procs) {
|
|
1969
|
+
try {
|
|
1970
|
+
if (pid) platform.killTree(pid);
|
|
1971
|
+
if (svc.stopCmd) {
|
|
1972
|
+
opts.onLog?.(svc.name, `\u{1F9F9} ${svc.stopCmd}`);
|
|
1973
|
+
await new Promise((resolve4) => {
|
|
1974
|
+
const isWin = process.platform === "win32";
|
|
1975
|
+
const shell = isWin ? "cmd.exe" : "sh";
|
|
1976
|
+
const flag = isWin ? "/c" : "-c";
|
|
1977
|
+
const cwd = svc.cwd ? join5(opts.baseCwd, svc.cwd) : opts.baseCwd;
|
|
1978
|
+
const env = { ...opts.env, ...svc.extraEnv ?? {} };
|
|
1979
|
+
const child = spawn4(shell, [flag, svc.stopCmd], { cwd, env, stdio: "ignore" });
|
|
1980
|
+
child.on("close", () => resolve4());
|
|
1981
|
+
child.on("error", () => resolve4());
|
|
1982
|
+
setTimeout(() => resolve4(), 1e4);
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
} catch {
|
|
1986
|
+
}
|
|
1987
|
+
void proc;
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
function spawnExternal(svc, opts) {
|
|
1991
|
+
const isWin = process.platform === "win32";
|
|
1992
|
+
const shell = isWin ? "cmd.exe" : "sh";
|
|
1993
|
+
const flag = isWin ? "/c" : "-c";
|
|
1994
|
+
const cwd = svc.cwd ? join5(opts.baseCwd, svc.cwd) : opts.baseCwd;
|
|
1995
|
+
const env = { ...opts.env, ...svc.extraEnv ?? {} };
|
|
1996
|
+
opts.onLog?.(svc.name, `\u{1F680} ${svc.cmd}`);
|
|
1997
|
+
const child = spawn4(shell, [flag, svc.cmd], {
|
|
1998
|
+
cwd,
|
|
1999
|
+
env,
|
|
2000
|
+
detached: true,
|
|
2001
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2002
|
+
});
|
|
2003
|
+
child.stdout?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
|
|
2004
|
+
child.stderr?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
|
|
2005
|
+
child.on("error", (err) => opts.onLog?.(svc.name, `\u274C spawn error: ${err.message}`));
|
|
2006
|
+
return child;
|
|
2007
|
+
}
|
|
2008
|
+
async function waitHealthy(svc, timeoutMs) {
|
|
2009
|
+
const deadline = Date.now() + timeoutMs;
|
|
2010
|
+
const port = svc.port;
|
|
2011
|
+
while (Date.now() < deadline) {
|
|
2012
|
+
if (await checkHealth(port, svc.healthCheck)) return true;
|
|
2013
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
2014
|
+
}
|
|
2015
|
+
return false;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
// src/tui/tips.ts
|
|
2019
|
+
function pickTip(state) {
|
|
2020
|
+
if (state.crashLoopedCount > 0 && !state.shown.has("crashed")) {
|
|
2021
|
+
return { id: "crashed", message: "tip: press r to restart, or check the log of the failing service" };
|
|
2022
|
+
}
|
|
2023
|
+
if (state.totalLogs > 1e3 && !state.hasSearch && !state.shown.has("search")) {
|
|
2024
|
+
return { id: "search", message: "tip: press / to search in logs" };
|
|
2025
|
+
}
|
|
2026
|
+
if (state.totalLogs > 500 && !state.hasFilter && !state.shown.has("filter")) {
|
|
2027
|
+
return { id: "filter", message: "tip: press f to filter logs by service" };
|
|
2028
|
+
}
|
|
2029
|
+
return null;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
1448
2032
|
// src/tui/App.tsx
|
|
1449
2033
|
import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
2034
|
+
function buildServiceUrl(name, port, proxyActive, proxyOpts) {
|
|
2035
|
+
if (proxyActive && proxyOpts) {
|
|
2036
|
+
const sub = proxyOpts.routes[name];
|
|
2037
|
+
if (sub !== void 0) {
|
|
2038
|
+
const host = sub ? `${sub}.${proxyOpts.domain}` : proxyOpts.domain;
|
|
2039
|
+
const scheme = proxyOpts.tls ? "https" : "http";
|
|
2040
|
+
return `${scheme}://${host}`;
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
return `http://localhost:${port}`;
|
|
2044
|
+
}
|
|
1450
2045
|
function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts, logSink }) {
|
|
1451
2046
|
const { stdout } = useStdout();
|
|
1452
2047
|
const [rows, setRows] = useState5(stdout?.rows ?? 40);
|
|
@@ -1464,6 +2059,9 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
|
|
|
1464
2059
|
const pm = useProcessManager(platform, baseCwd, env, logSink);
|
|
1465
2060
|
const [booted, setBooted] = useState5(false);
|
|
1466
2061
|
const lazyProxies = useRef3(/* @__PURE__ */ new Map());
|
|
2062
|
+
const externals = useRef3([]);
|
|
2063
|
+
const shownTips = useRef3(/* @__PURE__ */ new Set());
|
|
2064
|
+
const [activeTip, setActiveTip] = useState5(null);
|
|
1467
2065
|
const kb = useKeyBindings({
|
|
1468
2066
|
onQuit: () => {
|
|
1469
2067
|
void shutdown();
|
|
@@ -1475,12 +2073,35 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
|
|
|
1475
2073
|
const shutdown = useCallback3(async () => {
|
|
1476
2074
|
lazyProxies.current.forEach((p) => p.destroy());
|
|
1477
2075
|
await pm.cleanup();
|
|
2076
|
+
if (externals.current.length) {
|
|
2077
|
+
await stopExternals(externals.current, platform, {
|
|
2078
|
+
baseCwd,
|
|
2079
|
+
env,
|
|
2080
|
+
onLog: (svc, msg) => pm.pushLog(`ext:${svc}`, msg, 12)
|
|
2081
|
+
});
|
|
2082
|
+
externals.current = [];
|
|
2083
|
+
}
|
|
1478
2084
|
await logSink?.close();
|
|
1479
2085
|
process.exit(0);
|
|
1480
|
-
}, [pm, logSink]);
|
|
2086
|
+
}, [pm, logSink, platform, baseCwd, env]);
|
|
1481
2087
|
useEffect5(() => {
|
|
1482
2088
|
pm.setPaused(kb.logsPaused || kb.logsScrollOffset > 0);
|
|
1483
2089
|
}, [kb.logsPaused, kb.logsScrollOffset, pm]);
|
|
2090
|
+
useEffect5(() => {
|
|
2091
|
+
const tip = pickTip({
|
|
2092
|
+
totalLogs: pm.logs.length,
|
|
2093
|
+
hasSearch: !!kb.searchTerm,
|
|
2094
|
+
hasFilter: !!kb.logFilter,
|
|
2095
|
+
crashLoopedCount: [...pm.states.values()].filter(isCrashLooped).length,
|
|
2096
|
+
shown: shownTips.current
|
|
2097
|
+
});
|
|
2098
|
+
if (tip && tip.id !== activeTip) {
|
|
2099
|
+
shownTips.current.add(tip.id);
|
|
2100
|
+
setActiveTip(tip.message);
|
|
2101
|
+
const timer = setTimeout(() => setActiveTip(null), 12e3);
|
|
2102
|
+
return () => clearTimeout(timer);
|
|
2103
|
+
}
|
|
2104
|
+
}, [pm.logs.length, pm.states, kb.searchTerm, kb.logFilter, activeTip]);
|
|
1484
2105
|
useProxySync(proxyProvider, proxyOpts, pm.states, kb.proxyEnabled);
|
|
1485
2106
|
useEffect5(() => {
|
|
1486
2107
|
if (booted || !pm.manager) return;
|
|
@@ -1489,6 +2110,19 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
|
|
|
1489
2110
|
(async () => {
|
|
1490
2111
|
const lazyMode = cliArgs.lazy;
|
|
1491
2112
|
const lazyTimeout = cliArgs.lazyTimeout;
|
|
2113
|
+
if (config.external?.length) {
|
|
2114
|
+
const result = await startExternals(config.external, {
|
|
2115
|
+
baseCwd,
|
|
2116
|
+
env,
|
|
2117
|
+
platform,
|
|
2118
|
+
onLog: (svc, msg) => pm.pushLog(`ext:${svc}`, msg, 12)
|
|
2119
|
+
});
|
|
2120
|
+
externals.current = result.procs;
|
|
2121
|
+
if (!result.allHealthy) {
|
|
2122
|
+
pm.pushLog("devup", `\u274C external(s) failed: ${result.failed.join(", ")}. Aborting boot.`, 5);
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
1492
2126
|
if (lazyMode && config.lazy) {
|
|
1493
2127
|
const { alwaysOn, lazy } = classifyServices(services, config.lazy);
|
|
1494
2128
|
const aoPhases = groupByPhase(alwaysOn);
|
|
@@ -1582,23 +2216,32 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
|
|
|
1582
2216
|
}, [pm, kb]);
|
|
1583
2217
|
const handleOpenSelect = useCallback3((name) => {
|
|
1584
2218
|
const st = pm.states.get(name);
|
|
1585
|
-
if (st)
|
|
2219
|
+
if (st) {
|
|
2220
|
+
const url = buildServiceUrl(name, st.svc.port, cliArgs.proxy, proxyOpts);
|
|
2221
|
+
platform.openBrowser(url);
|
|
2222
|
+
}
|
|
1586
2223
|
kb.setModal("none");
|
|
1587
|
-
}, [pm, platform, kb]);
|
|
2224
|
+
}, [pm, platform, kb, cliArgs.proxy, proxyOpts]);
|
|
1588
2225
|
const icon = config.icon ?? "\u{1F4E6}";
|
|
1589
2226
|
const modeLabel = cliArgs.lazy && config.lazy ? "lazy" : "normal";
|
|
1590
2227
|
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", height: rows, children: [
|
|
1591
|
-
/* @__PURE__ */
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
2228
|
+
/* @__PURE__ */ jsxs6(Box6, { children: [
|
|
2229
|
+
/* @__PURE__ */ jsxs6(Text6, { bold: true, color: "cyan", children: [
|
|
2230
|
+
" ",
|
|
2231
|
+
icon,
|
|
2232
|
+
" ",
|
|
2233
|
+
config.name,
|
|
2234
|
+
" \u2014 devup \u2014 ",
|
|
2235
|
+
services.length,
|
|
2236
|
+
" services (",
|
|
2237
|
+
modeLabel,
|
|
2238
|
+
") "
|
|
2239
|
+
] }),
|
|
2240
|
+
activeTip && /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
|
|
2241
|
+
" \xB7 ",
|
|
2242
|
+
activeTip
|
|
2243
|
+
] })
|
|
2244
|
+
] }),
|
|
1602
2245
|
/* @__PURE__ */ jsx6(
|
|
1603
2246
|
LogsPanel,
|
|
1604
2247
|
{
|
|
@@ -1636,23 +2279,23 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
|
|
|
1636
2279
|
}
|
|
1637
2280
|
|
|
1638
2281
|
// src/process/log-sink.ts
|
|
1639
|
-
import { existsSync as
|
|
1640
|
-
import { join as
|
|
1641
|
-
import { homedir } from "os";
|
|
2282
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync4, renameSync, createWriteStream } from "fs";
|
|
2283
|
+
import { join as join6, dirname as dirname5 } from "path";
|
|
2284
|
+
import { homedir as homedir2 } from "os";
|
|
1642
2285
|
var LogSink = class {
|
|
1643
2286
|
dir;
|
|
1644
2287
|
rotateOnStart;
|
|
1645
2288
|
streams = /* @__PURE__ */ new Map();
|
|
1646
2289
|
seen = /* @__PURE__ */ new Set();
|
|
1647
2290
|
constructor(opts) {
|
|
1648
|
-
const root = opts.rootDir ??
|
|
1649
|
-
this.dir =
|
|
2291
|
+
const root = opts.rootDir ?? join6(homedir2(), ".devup", "logs");
|
|
2292
|
+
this.dir = join6(root, sanitize2(opts.projectName));
|
|
1650
2293
|
this.rotateOnStart = opts.rotateOnStart ?? true;
|
|
1651
2294
|
mkdirSync4(this.dir, { recursive: true });
|
|
1652
2295
|
}
|
|
1653
2296
|
/** Returns the file path for a service log (useful for tests / UI). */
|
|
1654
2297
|
pathFor(svcName) {
|
|
1655
|
-
return
|
|
2298
|
+
return join6(this.dir, `${sanitize2(svcName)}.log`);
|
|
1656
2299
|
}
|
|
1657
2300
|
write(svcName, line) {
|
|
1658
2301
|
const stream = this.streamFor(svcName);
|
|
@@ -1671,9 +2314,9 @@ var LogSink = class {
|
|
|
1671
2314
|
let s = this.streams.get(svcName);
|
|
1672
2315
|
if (s) return s;
|
|
1673
2316
|
const file = this.pathFor(svcName);
|
|
1674
|
-
if (this.rotateOnStart && !this.seen.has(svcName) &&
|
|
2317
|
+
if (this.rotateOnStart && !this.seen.has(svcName) && existsSync10(file)) {
|
|
1675
2318
|
try {
|
|
1676
|
-
mkdirSync4(
|
|
2319
|
+
mkdirSync4(dirname5(file), { recursive: true });
|
|
1677
2320
|
renameSync(file, file + ".prev");
|
|
1678
2321
|
} catch {
|
|
1679
2322
|
}
|
|
@@ -1686,7 +2329,7 @@ var LogSink = class {
|
|
|
1686
2329
|
return s;
|
|
1687
2330
|
}
|
|
1688
2331
|
};
|
|
1689
|
-
function
|
|
2332
|
+
function sanitize2(name) {
|
|
1690
2333
|
return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
|
|
1691
2334
|
}
|
|
1692
2335
|
|
|
@@ -1696,8 +2339,18 @@ function renderDryRun(opts) {
|
|
|
1696
2339
|
const lines = [];
|
|
1697
2340
|
lines.push(`Project: ${config.icon ?? "\u{1F4E6}"} ${config.name}`);
|
|
1698
2341
|
lines.push(`Mode: ${cliArgs.lazy && config.lazy ? "lazy" : "normal"}`);
|
|
2342
|
+
if (cliArgs.profile) lines.push(`Profile: ${cliArgs.profile}`);
|
|
1699
2343
|
lines.push(`Services: ${services.length}`);
|
|
1700
2344
|
lines.push("");
|
|
2345
|
+
if (config.external?.length) {
|
|
2346
|
+
lines.push(`Externals (${config.external.length}):`);
|
|
2347
|
+
for (const ext of config.external) {
|
|
2348
|
+
const hc = ext.healthCheck;
|
|
2349
|
+
const hcTag = hc ? ` health=${hc.type}${hc.type === "http" ? " " + (hc.path ?? "/") : ""} :${ext.port ?? "?"}` : "";
|
|
2350
|
+
lines.push(` - ${ext.name.padEnd(20)} ${ext.cmd}${hcTag}`);
|
|
2351
|
+
}
|
|
2352
|
+
lines.push("");
|
|
2353
|
+
}
|
|
1701
2354
|
const lazyMode = cliArgs.lazy && !!config.lazy;
|
|
1702
2355
|
let alwaysOn = services;
|
|
1703
2356
|
let lazy = [];
|
|
@@ -1771,6 +2424,26 @@ async function runOnce(opts) {
|
|
|
1771
2424
|
}
|
|
1772
2425
|
}
|
|
1773
2426
|
});
|
|
2427
|
+
let externals = [];
|
|
2428
|
+
if (config.external?.length) {
|
|
2429
|
+
out(`\u25B6 externals (${config.external.length})`);
|
|
2430
|
+
const result = await startExternals(config.external, {
|
|
2431
|
+
baseCwd,
|
|
2432
|
+
env,
|
|
2433
|
+
platform,
|
|
2434
|
+
onLog: (svc, msg) => {
|
|
2435
|
+
logSink?.write(`ext:${svc}`, msg);
|
|
2436
|
+
out(`[ext:${svc}] ${msg}`);
|
|
2437
|
+
}
|
|
2438
|
+
});
|
|
2439
|
+
externals = result.procs;
|
|
2440
|
+
if (!result.allHealthy) {
|
|
2441
|
+
out(`\u2717 externals failed: ${result.failed.join(", ")}`);
|
|
2442
|
+
await stopExternals(externals, platform, { baseCwd, env });
|
|
2443
|
+
await mgr.cleanup();
|
|
2444
|
+
return 1;
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
1774
2447
|
const phases = groupByPhase(services);
|
|
1775
2448
|
const phaseNums = Object.keys(phases).map(Number).sort((a, b) => a - b);
|
|
1776
2449
|
const apiNames = services.filter((s) => s.type === "api").map((s) => s.name);
|
|
@@ -1784,16 +2457,18 @@ async function runOnce(opts) {
|
|
|
1784
2457
|
if (!installed) {
|
|
1785
2458
|
out(`\u2717 install failed for ${svc.name}`);
|
|
1786
2459
|
await mgr.cleanup();
|
|
2460
|
+
await stopExternals(externals, platform, { baseCwd, env });
|
|
1787
2461
|
return 1;
|
|
1788
2462
|
}
|
|
1789
2463
|
await mgr.start(svc, ci);
|
|
1790
2464
|
}
|
|
1791
2465
|
const apis = phases[num].filter((s) => s.type === "api");
|
|
1792
2466
|
for (const api of apis) {
|
|
1793
|
-
const ok = await
|
|
2467
|
+
const ok = await waitHealthy2(api, deadline);
|
|
1794
2468
|
if (!ok) {
|
|
1795
2469
|
out(`\u2717 ${api.name} did not become healthy within ${cliArgs.onceTimeout}s`);
|
|
1796
2470
|
await mgr.cleanup();
|
|
2471
|
+
await stopExternals(externals, platform, { baseCwd, env });
|
|
1797
2472
|
return 1;
|
|
1798
2473
|
}
|
|
1799
2474
|
out(`\u2713 ${api.name} ready`);
|
|
@@ -1807,9 +2482,10 @@ async function runOnce(opts) {
|
|
|
1807
2482
|
const summary = `ready: ${apiNames.length} APIs in ${((cliArgs.onceTimeout * 1e3 - (deadline - Date.now())) / 1e3).toFixed(1)}s`;
|
|
1808
2483
|
out(summary);
|
|
1809
2484
|
await mgr.cleanup();
|
|
2485
|
+
await stopExternals(externals, platform, { baseCwd, env });
|
|
1810
2486
|
return 0;
|
|
1811
2487
|
}
|
|
1812
|
-
async function
|
|
2488
|
+
async function waitHealthy2(svc, deadline) {
|
|
1813
2489
|
while (Date.now() < deadline) {
|
|
1814
2490
|
const ok = await checkHealth(svc.port, svc.healthCheck);
|
|
1815
2491
|
if (ok) return true;
|
|
@@ -1824,9 +2500,46 @@ function defineConfig(config) {
|
|
|
1824
2500
|
}
|
|
1825
2501
|
|
|
1826
2502
|
// src/index.ts
|
|
2503
|
+
function readVersion() {
|
|
2504
|
+
try {
|
|
2505
|
+
const here = dirname6(fileURLToPath2(import.meta.url));
|
|
2506
|
+
const pkgPath = join7(here, "..", "package.json");
|
|
2507
|
+
return JSON.parse(readFileSync2(pkgPath, "utf8")).version ?? "unknown";
|
|
2508
|
+
} catch {
|
|
2509
|
+
return "unknown";
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
1827
2512
|
async function main() {
|
|
2513
|
+
const raw = process.argv.slice(2);
|
|
2514
|
+
if (raw.includes("-v") || raw.includes("--version")) {
|
|
2515
|
+
console.log(readVersion());
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
if (raw.includes("-h") || raw.includes("--help")) {
|
|
2519
|
+
console.log(USAGE);
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
const subcmd = detectSubcommand(raw);
|
|
2523
|
+
if (subcmd === "help") {
|
|
2524
|
+
process.exit(runHelp(raw.slice(1)));
|
|
2525
|
+
}
|
|
1828
2526
|
const cwd = process.cwd();
|
|
1829
|
-
const cliArgs = parseCliArgs(
|
|
2527
|
+
const cliArgs = parseCliArgs(raw);
|
|
2528
|
+
if (subcmd) {
|
|
2529
|
+
const subArgs = raw.slice(1);
|
|
2530
|
+
let cfgPath;
|
|
2531
|
+
try {
|
|
2532
|
+
cfgPath = findConfigFile(cwd, cliArgs.configPath);
|
|
2533
|
+
} catch (e) {
|
|
2534
|
+
console.error(`\u274C ${e.message}`);
|
|
2535
|
+
process.exit(1);
|
|
2536
|
+
}
|
|
2537
|
+
const cfg = await loadConfig(cfgPath);
|
|
2538
|
+
const subOpts = { config: cfg, baseCwd: cwd, env: process.env, logDir: cliArgs.logDir };
|
|
2539
|
+
if (subcmd === "logs") process.exit(await runLogs(subArgs, subOpts));
|
|
2540
|
+
if (subcmd === "install") process.exit(await runInstall(subOpts));
|
|
2541
|
+
if (subcmd === "status") process.exit(await runStatus(subOpts));
|
|
2542
|
+
}
|
|
1830
2543
|
let configPath;
|
|
1831
2544
|
try {
|
|
1832
2545
|
configPath = findConfigFile(cwd, cliArgs.configPath);
|
|
@@ -1841,13 +2554,19 @@ async function main() {
|
|
|
1841
2554
|
${formatValidationErrors(errors)}`);
|
|
1842
2555
|
process.exit(1);
|
|
1843
2556
|
}
|
|
1844
|
-
|
|
2557
|
+
let services;
|
|
2558
|
+
try {
|
|
2559
|
+
services = filterServices(config.services, cliArgs, config);
|
|
2560
|
+
} catch (e) {
|
|
2561
|
+
console.error(`\u274C ${e.message}`);
|
|
2562
|
+
process.exit(1);
|
|
2563
|
+
}
|
|
1845
2564
|
if (!services.length) {
|
|
1846
2565
|
console.error("\u274C No services to run after filtering");
|
|
1847
2566
|
process.exit(1);
|
|
1848
2567
|
}
|
|
1849
2568
|
const platform = await detectPlatform();
|
|
1850
|
-
const envFile = config.envFile ?
|
|
2569
|
+
const envFile = config.envFile ? join7(cwd, config.envFile) : join7(cwd, ".env");
|
|
1851
2570
|
const env = parseEnvFile(envFile, process.env);
|
|
1852
2571
|
if (config.env) {
|
|
1853
2572
|
for (const [k, v] of Object.entries(config.env)) {
|
|
@@ -1864,7 +2583,7 @@ ${formatValidationErrors(errors)}`);
|
|
|
1864
2583
|
routes: config.proxy.routes,
|
|
1865
2584
|
tls: cliArgs.proxyTls ?? config.proxy.tls ?? true,
|
|
1866
2585
|
entrypoint: cliArgs.proxyEntrypoint ?? config.proxy.entrypoint ?? "websecure",
|
|
1867
|
-
confPath: cliArgs.proxyConf ?? config.proxy.confPath ??
|
|
2586
|
+
confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join7(homedir3(), ".traefik", "traefik_conf.yaml")
|
|
1868
2587
|
};
|
|
1869
2588
|
}
|
|
1870
2589
|
if (cliArgs.dryRun) {
|